Compare commits

...
Author SHA1 Message Date
github-actions[bot] f3630399ee chore: version packages (#23645)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-07 23:42:34 +00:00
Anik Dhabal Babu 5375c656af fix: unit test flake (#23648)
* fix: hosts is cancelling booking through apiV1

* fix: unit test flake

* Update booking.ts
2025-09-06 17:21:17 +00:00
Calcom BotandLingo.dev 1792d49634 feat: update translations via @LingoDotDev (#23644)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-06 15:41:11 +00:00
Rajiv Sahal de98578fdd fix: use disableToasts for handling notifications in EventTypeSettings atom (#23490)
* fix: use disableToasts for handling notifications

* add changesets
2025-09-06 19:56:31 +05:30
c0a59a2837 feat: implement recent impersonations list with localStorage storage (#23610)
* feat: implement recent impersonations list with localStorage storage

- Add RecentImpersonationsList component with quick action buttons
- Store up to 5 recent impersonations in localStorage using existing webstorage utility
- Integrate component into admin impersonation page above existing form
- Add translation keys for recent impersonations UI elements
- Update impersonation flow to save successful impersonations locally
- Follow existing List component patterns and button styling

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* refactor: optimize RecentImpersonationsList to use lazy initial state

- Replace useEffect with useState lazy initializer for better performance
- Removes unnecessary re-render on component mount
- Remove unused setRecentImpersonations variable to fix ESLint warning
- Follows React best practices for initial state computation

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* Add styling to match v3 and newer designs

* Update packages/lib/recentImpersonations.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-06 19:56:08 +05:30
Benny Joo 29201006d5 perf: optimize public event query by using lightweight session lookup (#23617)
* refactor event endpoint

* export getSessino
2025-09-06 09:42:27 -03:00
Benny Joo 7a1c780088 refactor: Break @calcom/app-store/components into individual component files (#23637)
* wip

* get rid of app-store/components

* create independent files

* update imports
2025-09-06 09:39:26 -03:00
Anik Dhabal Babu 550f34c128 fix: all e2e flakes [WIP] (#23633)
* fix all e2e flakes

* Update workflow.e2e.ts
2025-09-06 15:16:24 +09:00
d6b17b76db test: add comprehensive unit tests for no-show fee payment functions (#23626)
* feat: add time-based cancellation fees for no-show fee events

- Add configurable time threshold (minutes/hours/days) for cancellation fees
- Show warnings during booking submission and cancellation
- Automatically charge fees when bookings cancelled within threshold
- Exempt organizer/admin cancellations from fees
- Extend existing no-show fee infrastructure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: replace any type with proper type guards for metadata

- Use Record<string, unknown> instead of any for metadata type
- Add proper type assertions for nested metadata properties
- Maintain type safety while avoiding ESLint no-explicit-any warning

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: handle JsonValue type compatibility in shouldChargeCancellationFee

- Add proper type guard for metadata JsonValue from Prisma
- Ensure metadata is object before casting to Record<string, unknown>
- Fixes TypeScript error on line 390 in handleCancelBooking.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Refactor Devin changes to Stripe options

* Undo Devin changes to advanced tab

* Add translations

* Pass props to CancelBooking

* Display no show fee charge for attendee

* WIP

* Anstract shouldChargeNoSHowCancellationFee

* Abstract `handleNoShowFee`

* Add  to

* Refactor `chargeCard.handler`

* Remove Devin code

* Type fix in `shouldChargeNoShowCancellationFee`

* Create `processNoSHowFeeOnCancellation`

* Process no show fee on cancellation

* Type fix

* Skip processing no show fee if organizer or admin is cancelling

* Add translation

* Dynamically get  and  in

* Remove unused translations

* Undo dev change

* Refactor logic

* Type fix

* remove any

* revert WEBAPP_URL_FOR_OAUTH

* Clean up console.log remnants

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All 23 new tests pass successfully, covering:
- Time-based logic with different units (minutes, hours, days)
- Role-based exemptions (organizer, team admin vs regular attendee)
- Payment charging integration with HOLD payment option
- UI warning display during booking submission and cancellation
- Edge cases like invalid metadata and past bookings

Fixed lint issues:
- Replaced 'any' types with proper Record<string, unknown>
- Fixed Playwright test.skip() usage with ESLint disable comments

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All unit and integration tests pass successfully. E2E test implementation complete.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript issues and prettier formatting
- All tests follow existing Cal.com patterns and pass locally

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive unit and E2E tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions and attendee fee charging
- Add UI component tests for cancellation fee warning display in CancelBooking component
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript errors in CancelBooking.tsx with proper type annotations
- All tests follow existing Cal.com patterns and use proper mocking/test utilities

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Revert dev change

* Add missing translation

* Remove Devin code

* Revert Devin changes

* test: fix attendee cancellation test logic and ensure comprehensive test coverage

- Fix userId in attendee cancellation test to properly test fee charging
- Ensure all test files follow Cal.com patterns with proper mocking
- Add comprehensive coverage for time thresholds, role exemptions, and UI warnings
- All tests pass locally with proper timezone handling

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix tests

* test: add comprehensive unit tests for no-show fee payment functions

- Add unit tests for handleNoShowFee.ts covering successful scenarios, error handling, and edge cases
- Add unit tests for processNoShowFeeOnCancellation.ts covering cancellation logic and payment conditions
- Tests include proper mocking of dependencies and comprehensive coverage of all code paths
- All tests pass with TZ=UTC yarn test and type checking passes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Don't block cancel flow for failed payment processing

* Await single attendee getTranslation in `handleNoShowFee`

* Add missing data

* Undo old strings

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-05 23:01:52 +01:00
Udit TakkarandPeer Richelsen c42e803cfa chore: local testing retell (#23577)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-09-06 02:11:21 +05:30
Keith WilliamsandDevin AI 0233c3a11a fix: resolve Sentry version conflicts and import-in-the-middle error (#23551)
- Upgrade API v2 Sentry packages from v8.x to v9.15.0 to match web/API v1
- Add yarn resolution for import-in-the-middle to prevent version conflicts
- Standardize Sentry versions across the monorepo

Fixes import-in-the-middle package version mismatch error:
- Project was using 1.11.2 while Sentry v9.x requires 1.13.1
- Root cause: API v2 used Sentry v8.x while other apps used v9.x
- Solution: Upgrade all Sentry packages to v9.15.0 and force single import-in-the-middle version

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-05 17:12:02 -03:00
Keith Williams 71a2b6956f chore: Add .mdx to all checks ignore list (#23627)
* chore: Add .mdx to all checks ignore list

* Missing a *
2025-09-05 15:38:59 -03:00
Calcom BotandLingo.dev 8282557ebe feat: update translations via @LingoDotDev (#23631)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-05 17:54:29 +00:00
083a162d0e feat: add time-based cancellation fees for no-show fee events (#23595)
* feat: add time-based cancellation fees for no-show fee events

- Add configurable time threshold (minutes/hours/days) for cancellation fees
- Show warnings during booking submission and cancellation
- Automatically charge fees when bookings cancelled within threshold
- Exempt organizer/admin cancellations from fees
- Extend existing no-show fee infrastructure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: replace any type with proper type guards for metadata

- Use Record<string, unknown> instead of any for metadata type
- Add proper type assertions for nested metadata properties
- Maintain type safety while avoiding ESLint no-explicit-any warning

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: handle JsonValue type compatibility in shouldChargeCancellationFee

- Add proper type guard for metadata JsonValue from Prisma
- Ensure metadata is object before casting to Record<string, unknown>
- Fixes TypeScript error on line 390 in handleCancelBooking.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Refactor Devin changes to Stripe options

* Undo Devin changes to advanced tab

* Add translations

* Pass props to CancelBooking

* Display no show fee charge for attendee

* WIP

* Anstract shouldChargeNoSHowCancellationFee

* Abstract `handleNoShowFee`

* Add  to

* Refactor `chargeCard.handler`

* Remove Devin code

* Type fix in `shouldChargeNoShowCancellationFee`

* Create `processNoSHowFeeOnCancellation`

* Process no show fee on cancellation

* Type fix

* Skip processing no show fee if organizer or admin is cancelling

* Add translation

* Dynamically get  and  in

* Remove unused translations

* Undo dev change

* Refactor logic

* Type fix

* remove any

* revert WEBAPP_URL_FOR_OAUTH

* Clean up console.log remnants

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All 23 new tests pass successfully, covering:
- Time-based logic with different units (minutes, hours, days)
- Role-based exemptions (organizer, team admin vs regular attendee)
- Payment charging integration with HOLD payment option
- UI warning display during booking submission and cancellation
- Edge cases like invalid metadata and past bookings

Fixed lint issues:
- Replaced 'any' types with proper Record<string, unknown>
- Fixed Playwright test.skip() usage with ESLint disable comments

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All unit and integration tests pass successfully. E2E test implementation complete.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript issues and prettier formatting
- All tests follow existing Cal.com patterns and pass locally

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive unit and E2E tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions and attendee fee charging
- Add UI component tests for cancellation fee warning display in CancelBooking component
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript errors in CancelBooking.tsx with proper type annotations
- All tests follow existing Cal.com patterns and use proper mocking/test utilities

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Revert dev change

* Add missing translation

* Remove Devin code

* Revert Devin changes

* test: fix attendee cancellation test logic and ensure comprehensive test coverage

- Fix userId in attendee cancellation test to properly test fee charging
- Ensure all test files follow Cal.com patterns with proper mocking
- Add comprehensive coverage for time thresholds, role exemptions, and UI warnings
- All tests pass locally with proper timezone handling

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix tests

* Remove reverted PR translations

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-09-05 21:46:54 +04:00
Benny Joo 60487ea02e chore: remove unused code - sessionMiddleware (#23623) 2025-09-05 17:14:49 +00:00
Morgan 60a8841439 refactor: handle emails and sms side effects (#23578)
* refactor: handle emails and sms side effects

* fix: typo and import part

* fix: types import path

* fix: rename

* refactor: class with logger dependency

* fix: replace structured clone with cloneDeep

* fix: await send reschedule round robin notifs

* fixup! fix: await send reschedule round robin notifs

* fix: avoid oversharing data

* refactor and fix tests

* chore: bump platform lib

* chore: bump platform lib

* chore: use logger with event details

* chore: add try catches

* refactor: remove private methods to send sms and emails

* chore: address code review

* chore: bump platform lib

* chore: address code review
2025-09-05 19:08:35 +03:00
Keith Williams 184d1eb751 docs: Update docs for new Railway template (#23625) 2025-09-05 16:02:14 +00:00
Benny Joo 9fea30a44d perf: Delete viewer.public.session tRPC endpoint (#23615)
* delete unused files

* remove from router
2025-09-05 10:47:47 -03:00
Anshumancanrock 04efcbcdb6 fix: Switch component thumb positioning in RTL languages (#23600)
* Arabic RTL UI Fix

* optimal approach
2025-09-05 13:27:23 +00:00
Hariom Balhara 2b29b5607d Add logs to cancellation flow (#23585) 2025-09-05 13:25:57 +00:00
20f19c2509 docs: add getBusyTimes API documentation and update usage details (#22754)
* docs: add getBusyTimes API documentation and update usage details

* changes

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-05 18:23:07 +05:30
Calcom BotandLingo.dev 5d0e66763e feat: update translations via @LingoDotDev (#23611)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-05 14:07:41 +05:30
sean-brydon bcfbc24e93 add i18n (#23609) 2025-09-05 09:08:45 +01:00
Calcom BotandLingo.dev 37d805b007 feat: update translations via @LingoDotDev (#23608)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-05 09:35:22 +05:30
Alex van Andel 6a246e6c57 Revert "feat: enhance image upload validation across the application (#22766)"
This reverts commit 5acdb208f5.
2025-09-05 02:06:30 +01:00
113 changed files with 4023 additions and 3086 deletions
+5
View File
@@ -386,6 +386,11 @@ UNKEY_ROOT_KEY=
# Used for Cal.ai Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
# Local testing overrides for Retell AI
RETELL_AI_TEST_MODE=false
# JSON mapping of local to production event type IDs (e.g. {"50": 747280} without any string)
RETELL_AI_TEST_EVENT_TYPE_MAP=
RETELL_AI_TEST_CAL_API_KEY=
# Used for buying phone number for cal ai voice agent
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID=
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
with:
filters: |
has-files-requiring-all-checks:
- "!(**.md|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
- "!(**.md|**.mdx|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
- name: Get Latest Commit SHA
id: get_sha
run: |
+4 -21
View File
@@ -3,8 +3,6 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -126,25 +124,10 @@ export async function patchHandler(req: NextApiRequest) {
}
if (avatar) {
const validation = validateBase64Image(avatar);
if (!validation.isValid) {
throw new HttpError({
statusCode: 400,
message: `Invalid avatar image: ${validation.error}`,
});
}
try {
body.avatarUrl = await uploadAvatar({
userId: query.userId,
avatar: await resizeBase64Image(avatar),
});
} catch (error) {
throw new HttpError({
statusCode: 400,
message: error instanceof Error ? error.message : "Failed to upload avatar",
});
}
body.avatarUrl = await uploadAvatar({
userId: query.userId,
avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
});
}
const data = await prisma.user.update({
+4 -4
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.340",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.344",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -55,9 +55,9 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "6.2.1",
"@sentry/nestjs": "^8.37.1",
"@sentry/node": "^8.8.0",
"@sentry/profiling-node": "^8.37.1",
"@sentry/nestjs": "^9.15.0",
"@sentry/node": "^9.15.0",
"@sentry/profiling-node": "^9.15.0",
"@snyk/protect": "latest",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
+4
View File
@@ -9132,6 +9132,8 @@
{
"name": "credentialId",
"in": "query",
"description": "The ID of the user's calendar credential used to access the calendar. Retrieve this from your connected calendar credentials via the `/v2/credentials` endpoint.",
"example": 135,
"required": true,
"schema": {
"type": "number"
@@ -9140,6 +9142,8 @@
{
"name": "externalId",
"in": "query",
"description": "The external calendar identifier from the calendar provider (e.g., email address for Google Calendar). This is returned when listing connected calendars.",
"example": "user@gmail.com",
"required": true,
"schema": {
"type": "string"
-12
View File
@@ -6,7 +6,6 @@ import { z } from "zod";
import { AVATAR_FALLBACK, WEBAPP_URL } from "@calcom/lib/constants";
import { convertSvgToPng } from "@calcom/lib/server/imageUtils";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import prisma from "@calcom/prisma";
const querySchema = z.object({
@@ -47,12 +46,6 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
},
});
const validation = validateBase64Image(data);
if (!validation.isValid) {
const url = new URL(AVATAR_FALLBACK, WEBAPP_URL).toString();
return NextResponse.redirect(url, 302);
}
// Convert SVG to PNG if needed and update the database
if (data.startsWith("data:image/svg+xml;base64,")) {
const pngData = await convertSvgToPng(data);
@@ -79,11 +72,6 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
"Content-Type": "image/png",
"Content-Length": imageResp.length.toString(),
"Cache-Control": "max-age=86400",
// Security headers to prevent XSS
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "default-src 'none'; img-src 'self'",
},
status: 200,
});
+2 -1
View File
@@ -3,9 +3,10 @@ import { useRouter } from "next/navigation";
import type { IframeHTMLAttributes } from "react";
import React, { useEffect, useState } from "react";
import { AppDependencyComponent } from "@calcom/app-store/AppDependencyComponent";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
@@ -2,7 +2,7 @@
import { useEffect, Suspense } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { DestinationCalendarSettingsWebWrapper } from "@calcom/atoms/destination-calendar/wrappers/DestinationCalendarSettingsWebWrapper";
import { SelectedCalendarsSettingsWebWrapper } from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
import AppListCard from "@calcom/features/apps/components/AppListCard";
+63 -6
View File
@@ -6,10 +6,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { shouldChargeNoShowCancellationFee } from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Button } from "@calcom/ui/components/button";
import { Label, Select, TextArea } from "@calcom/ui/components/form";
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
interface InternalNotePresetsSelectProps {
@@ -76,6 +77,12 @@ type Props = {
title?: string;
uid?: string;
id?: number;
startTime: Date;
payment?: {
amount: number;
currency: string;
appId: string | null;
} | null;
};
profile: {
name: string | null;
@@ -100,6 +107,7 @@ type Props = {
};
isHost: boolean;
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
eventTypeMetadata?: Record<string, unknown> | null;
};
export default function CancelBooking(props: Props) {
@@ -112,13 +120,48 @@ export default function CancelBooking(props: Props) {
seatReferenceUid,
bookingCancelledEventProps,
currentUserEmail,
teamId,
eventTypeMetadata,
} = props;
const [loading, setLoading] = useState(false);
const telemetry = useTelemetry();
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
const [internalNote, setInternalNote] = useState<{ id: number; name: string } | null>(null);
const [acknowledgeCancellationNoShowFee, setAcknowledgeCancellationNoShowFee] = useState(false);
const getAppMetadata = (appId: string): Record<string, unknown> | null => {
if (!eventTypeMetadata?.apps || !appId) return null;
const apps = eventTypeMetadata.apps as Record<string, unknown>;
return (apps[appId] as Record<string, unknown>) || null;
};
const timeValue = booking?.payment?.appId
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | null)?.autoChargeNoShowFeeTimeValue
: null;
const timeUnit = booking?.payment?.appId
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | null)?.autoChargeNoShowFeeTimeUnit
: null;
const autoChargeNoShowFee = () => {
if (props.isHost) return false; // Hosts/organizers are exempt
if (!booking?.startTime) return false;
if (!booking?.payment) return false;
return shouldChargeNoShowCancellationFee({
eventTypeMetadata: eventTypeMetadata || null,
booking,
payment: booking.payment,
});
};
const cancellationNoShowFeeWarning = autoChargeNoShowFee();
const hostMissingCancellationReason =
props.isHost &&
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
const cancellationNoShowFeeNotAcknowledged =
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
@@ -187,6 +230,23 @@ export default function CancelBooking(props: Props) {
</p>
</div>
) : null}
{cancellationNoShowFeeWarning && booking?.payment && (
<div>
<div className="bg-attention mb-5 rounded-md p-3">
<CheckboxField
description={t("cancel_booking_acknowledge_no_show_fee", {
timeValue,
timeUnit,
amount: booking.payment.amount / 100,
formatParams: { amount: { currency: booking.payment.currency } },
})}
onChange={(e) => setAcknowledgeCancellationNoShowFee(e.target.checked)}
descriptionClassName="text-info font-semibold"
/>
<p className="text-subtle ml-9 mt-2 text-sm">{t("contact_organizer")}</p>
</div>
</div>
)}
<div className="flex flex-col-reverse rtl:space-x-reverse ">
<div className="ml-auto flex w-full space-x-4 ">
<Button
@@ -197,10 +257,7 @@ export default function CancelBooking(props: Props) {
</Button>
<Button
data-testid="confirm_cancel"
disabled={
props.isHost &&
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id))
}
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
onClick={async () => {
setLoading(true);
@@ -0,0 +1,257 @@
import { render, screen } from "@testing-library/react";
import * as React from "react";
import { describe, expect, it, vi, beforeAll } from "vitest";
import * as shouldChargeModule from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";
import CancelBooking from "../CancelBooking";
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
vi.mock("@calcom/trpc", () => ({
trpc: {
viewer: {
bookings: {
requestReschedule: {
useMutation: () => ({
mutate: vi.fn(),
isLoading: false,
}),
},
},
},
},
}));
vi.mock("next-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "cancellation_fee_warning_cancel") {
const opts = options as {
time?: string;
unit?: string;
amount?: number;
formatParams?: { amount?: { currency?: string } };
};
return `Cancelling within ${opts?.time} ${opts?.unit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to your card.`;
}
return key;
},
}),
}));
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "cancel_booking_acknowledge_no_show_fee") {
const opts = options as {
timeValue?: number;
timeUnit?: string;
amount?: number;
formatParams?: { amount?: { currency?: string } };
};
return `I acknowledge that cancelling within ${opts?.timeValue} ${opts?.timeUnit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to my card.`;
}
return key;
},
}),
}));
vi.mock("@calcom/lib/hooks/useTelemetry", () => ({
useTelemetry: () => ({
event: vi.fn(),
}),
}));
vi.mock("@calcom/lib/hooks/useRefreshData", () => ({
useRefreshData: () => vi.fn(),
}));
vi.mock("next/router", () => ({
useRouter: () => ({
push: vi.fn(),
query: {},
}),
}));
vi.mock("@calcom/lib/payment/shouldChargeNoShowCancellationFee", () => ({
shouldChargeNoShowCancellationFee: vi.fn(),
}));
const mockBookingWithCancellationFee = {
uid: "test-booking-uid",
title: "Test Meeting",
id: 123,
startTime: new Date(Date.now() + 30 * 60 * 1000),
payment: {
amount: 1000,
currency: "usd",
appId: "stripe",
},
};
const mockEventTypeMetadataWithFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};
const mockEventTypeMetadataWithoutFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};
const mockProps = {
recurringEvent: null,
setIsCancellationMode: vi.fn(),
theme: "light",
allRemainingBookings: false,
seatReferenceUid: undefined,
currentUserEmail: "test@example.com",
bookingCancelledEventProps: {
booking: {},
organizer: {
name: "Test Organizer",
email: "organizer@example.com",
timeZone: "UTC",
},
eventType: {},
},
internalNotePresets: [],
};
const mockBookingWithoutCancellationFee = {
uid: "test-booking-uid-2",
title: "Test Meeting 2",
id: 124,
startTime: new Date(Date.now() + 2 * 60 * 60 * 1000),
payment: {
amount: 1000,
currency: "usd",
appId: "stripe",
},
};
describe("CancelBooking Cancellation Fee Warning", () => {
it("should show cancellation fee warning when booking is within time threshold", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(true);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={mockEventTypeMetadataWithFee}
{...mockProps}
/>
);
expect(
screen.getByText(/I acknowledge that cancelling within 1 hours will result in a/)
).toBeInTheDocument();
});
it("should not show cancellation fee warning when booking is outside time threshold", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithoutCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={mockEventTypeMetadataWithoutFee}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
it("should not show cancellation fee warning when user is host", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={true}
eventTypeMetadata={mockEventTypeMetadataWithFee}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
it("should not show cancellation fee warning when cancellation fee is disabled", () => {
const eventTypeMetadataWithDisabledFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: false,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={eventTypeMetadataWithDisabledFee}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
it("should not show cancellation fee warning when payment option is not HOLD", () => {
const eventTypeMetadataWithOnBookingPayment = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "ON_BOOKING" as const,
},
},
};
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={eventTypeMetadataWithOnBookingPayment}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
});
@@ -1,8 +1,8 @@
import Link from "next/link";
import { useState } from "react";
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/InstallAppButtonWithoutPlanCheck";
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -0,0 +1,79 @@
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getRecentImpersonations, type RecentImpersonation } from "@calcom/lib/recentImpersonations";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import {
TableNew,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@calcom/ui/components/table";
interface RecentImpersonationsListProps {
onImpersonate?: (username: string) => void;
}
export default function RecentImpersonationsList({ onImpersonate }: RecentImpersonationsListProps) {
const { t } = useLocale();
const [recentImpersonations] = useState<RecentImpersonation[]>(() => getRecentImpersonations());
const handleQuickImpersonate = (username: string) => {
if (onImpersonate) {
onImpersonate(username);
} else {
signIn("impersonation-auth", {
username: username.toLowerCase(),
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}
};
if (recentImpersonations.length === 0) {
return null;
}
return (
<PanelCard title={t("recent_impersonations")}>
<div className="overflow-x-auto">
<TableNew className="border-0">
<TableHeader>
<TableRow>
<TableHead className="w-[40%]">User</TableHead>
<TableHead>Impersonated At</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentImpersonations.map((impersonation) => (
<TableRow key={impersonation.username}>
<TableCell className="font-medium">{impersonation.username}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(impersonation.timestamp).toLocaleDateString()}{" "}
{new Date(impersonation.timestamp).toLocaleTimeString()}
</TableCell>
<TableCell className="text-right">
<Button
type="button"
color="secondary"
size="sm"
onClick={() => handleQuickImpersonate(impersonation.username)}
data-testid={`quick-impersonate-${impersonation.username}`}>
{t("quick_impersonate")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</TableNew>
</div>
</PanelCard>
);
}
@@ -184,6 +184,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingId: bookingInfo.id,
},
select: {
appId: true,
success: true,
refunded: true,
currency: true,
@@ -867,7 +867,10 @@ export default function Success(props: PageProps) {
uid: bookingInfo?.uid,
title: bookingInfo?.title,
id: bookingInfo?.id,
startTime: bookingInfo?.startTime,
payment: props.paymentStatus,
}}
eventTypeMetadata={eventType.metadata}
profile={{ name: props.profile.name, slug: props.profile.slug }}
recurringEvent={eventType.recurringEvent}
team={eventType?.team?.name}
@@ -2,23 +2,29 @@
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useRef, useEffect } from "react";
import { useRef, useEffect, useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TextField } from "@calcom/ui/components/form";
import { addRecentImpersonation } from "@calcom/lib/recentImpersonations";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import { TextField } from "@calcom/ui/components/form";
import RecentImpersonationsList from "../../../components/settings/admin/RecentImpersonationsList";
const ImpersonationView = () => {
const { t } = useLocale();
const usernameRef = useRef<HTMLInputElement>(null);
const searchParams = useSearchParams();
const [refreshKey, setRefreshKey] = useState(0);
const username = searchParams?.get("username")?.toLowerCase();
useEffect(() => {
if (username) {
const enteredUsername = username.toLowerCase();
addRecentImpersonation(enteredUsername);
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
@@ -26,32 +32,52 @@ const ImpersonationView = () => {
}
}, [username]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const enteredUsername = usernameRef.current?.value.toLowerCase();
if (enteredUsername) {
addRecentImpersonation(enteredUsername);
setRefreshKey((prev) => prev + 1);
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}
};
const handleQuickImpersonate = (username: string) => {
if (usernameRef.current) {
usernameRef.current.value = username;
}
addRecentImpersonation(username);
setRefreshKey((prev) => prev + 1);
signIn("impersonation-auth", {
username: username.toLowerCase(),
callbackUrl: `${WEBAPP_URL}/event-types`,
});
};
return (
<form
className="mb-6 w-full"
onSubmit={(e) => {
e.preventDefault();
const enteredUsername = usernameRef.current?.value.toLowerCase();
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<TextField
containerClassName="w-full [&_input:-webkit-autofill]:!shadow-[0_0_0_1000px_white_inset]"
name={t("user_impersonation_heading")}
addOnLeading={<>{process.env.NEXT_PUBLIC_WEBSITE_URL}/</>}
ref={usernameRef}
hint={t("impersonate_user_tip")}
defaultValue={undefined}
data-testid="admin-impersonation-input"
/>
<Button type="submit" data-testid="impersonation-submit" className="mt-[-8px]">
{t("impersonate")}
</Button>
</div>
</form>
<div className="flex flex-col gap-4">
<PanelCard title={t("user_impersonation_heading")} subtitle={t("impersonate_user_tip")}>
<form className="p-4" onSubmit={handleSubmit}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<TextField
containerClassName="w-full"
name=""
addOnLeading={<>{process.env.NEXT_PUBLIC_WEBSITE_URL}/</>}
ref={usernameRef}
defaultValue={undefined}
data-testid="admin-impersonation-input"
/>
<Button type="submit" data-testid="impersonation-submit">
{t("impersonate")}
</Button>
</div>
</form>
</PanelCard>
<RecentImpersonationsList key={refreshKey} onImpersonate={handleQuickImpersonate} />
</div>
);
};
@@ -0,0 +1,94 @@
import { expect } from "@playwright/test";
import type Prisma from "@prisma/client";
import { test } from "./lib/fixtures";
import { IS_STRIPE_ENABLED, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Cancellation Fee Warning E2E", () => {
test("Should display cancellation fee warning during booking submission", async ({ page, users }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
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.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
await page.goto(`/event-types/${eventType?.id}?tabName=advanced`);
await page.locator('[data-testid="cancellation-fee-toggle"]').click();
await page.locator('[data-testid="cancellation-fee-time-value"]').fill("2");
await page.locator('[data-testid="cancellation-fee-time-unit"]').selectOption("hours");
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${eventType?.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "Test Booker");
await page.fill('[name="email"]', "booker@example.com");
await expect(page.getByText(/I acknowledge that if I cancel within 2 hours/)).toBeVisible();
});
test("Should display cancellation fee warning during cancellation", async ({ page, users }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
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.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
await page.goto(`/event-types/${eventType?.id}?tabName=advanced`);
await page.locator('[data-testid="cancellation-fee-toggle"]').click();
await page.locator('[data-testid="cancellation-fee-time-value"]').fill("24");
await page.locator('[data-testid="cancellation-fee-time-unit"]').selectOption("hours");
await page.getByTestId("update-eventtype").click();
await user.bookAndPayEvent(eventType);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await page.goto("/bookings/upcoming");
await page.click('[data-testid="cancel"]');
await expect(page.getByText(/Cancelling within 24 hours will result in a/)).toBeVisible();
});
test("Should not display cancellation fee warning when outside time threshold", async ({ page, users }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
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.installStripePersonal({ skip: true });
await user.setupEventWithPrice(eventType, "stripe");
await page.goto(`/event-types/${eventType?.id}?tabName=advanced`);
await page.locator('[data-testid="cancellation-fee-toggle"]').click();
await page.locator('[data-testid="cancellation-fee-time-value"]').fill("1");
await page.locator('[data-testid="cancellation-fee-time-unit"]').selectOption("minutes");
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/${eventType?.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "Test Booker");
await page.fill('[name="email"]', "booker@example.com");
await expect(page.getByText(/I acknowledge that if I cancel within/)).toBeHidden();
});
});
+6 -6
View File
@@ -7,7 +7,7 @@ test.describe("Change App Theme Test", () => {
const pro = await users.create();
await pro.apiLogin();
await page.goto("/settings/my-account/appearance");
await expect(page.locator('text="Dashboard theme"')).toBeVisible();
await expect(page.getByTestId("dashboard-shell").getByText("Dashboard theme")).toBeVisible();
await page.click('[data-testid="appTheme-dark"]');
await page.click('[data-testid="update-app-theme-btn"]');
@@ -25,7 +25,7 @@ test.describe("Change App Theme Test", () => {
const pro = await users.create();
await pro.apiLogin();
await page.goto("/settings/my-account/appearance");
await expect(page.locator('text="Dashboard theme"')).toBeVisible();
await expect(page.getByTestId("dashboard-shell").getByText("Dashboard theme")).toBeVisible();
await page.click('[data-testid="appTheme-light"]');
await page.click('[data-testid="update-app-theme-btn"]');
@@ -44,7 +44,7 @@ test.describe("Change App Theme Test", () => {
await pro.apiLogin();
await page.goto("/settings/my-account/appearance");
await expect(page.locator('text="Dashboard theme"')).toBeVisible();
await expect(page.getByTestId("dashboard-shell").getByText("Dashboard theme")).toBeVisible();
await page.click('[data-testid="appTheme-light"]');
await page.click('[data-testid="update-app-theme-btn"]');
const toast1 = await page.waitForSelector('[data-testid="toast-success"]');
@@ -72,7 +72,7 @@ test.describe("Change Booking Page Theme Test", () => {
await pro.apiLogin();
await page.goto("/settings/my-account/appearance");
await expect(page.locator('text="Dashboard theme"')).toBeVisible();
await expect(page.getByTestId("dashboard-shell").getByText("Dashboard theme")).toBeVisible();
//Click the "Dark" theme label
await page.click('[data-testid="theme-dark"]');
@@ -94,7 +94,7 @@ test.describe("Change Booking Page Theme Test", () => {
await pro.apiLogin();
await page.goto("/settings/my-account/appearance");
await expect(page.locator('text="Dashboard theme"')).toBeVisible();
await expect(page.getByTestId("dashboard-shell").getByText("Dashboard theme")).toBeVisible();
//Click the "Light" theme label
await page.click('[data-testid="theme-light"]');
@@ -114,7 +114,7 @@ test.describe("Change Booking Page Theme Test", () => {
await pro.apiLogin();
await page.goto("/settings/my-account/appearance");
await expect(page.locator('text="Dashboard theme"')).toBeVisible();
await expect(page.getByTestId("dashboard-shell").getByText("Dashboard theme")).toBeVisible();
await page.click('[data-testid="theme-light"]');
await page.click('[data-testid="update-theme-btn"]');
+2 -1
View File
@@ -26,7 +26,8 @@ test.describe("Workflow Tab - Event Type", () => {
await fillNameInput("Edited Workflow");
await saveWorkflow();
await page.getByTestId("go-back-button").click();
page.getByTestId("workflow-list").waitFor();
await page.getByTestId("workflow-list").waitFor();
await page.reload();
await hasWorkflowInList("Edited Workflow");
});
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "التضمينات",
"impersonation": "انتحال الشخصية",
"impersonation_description": "إعدادات إدارة انتحال شخصية مستخدم",
"recent_impersonations": "عمليات انتحال الشخصية الأخيرة",
"quick_impersonate": "انتحال شخصية سريع",
"no_recent_impersonations": "لا توجد عمليات انتحال شخصية حديثة",
"users": "المستخدمون",
"user": "المستخدم",
"profile_description": "إدارة الإعدادات لملفك الشخصي على {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "عند اختبار سير العمل هذا، يجب أن تدرك أنه لا يمكن جدولة رسائل البريد الإلكتروني والرسائل القصيرة إلا قبل ساعة واحدة على الأقل",
"insights_no_data_found_for_filter": "لم يتم العثور على بيانات لعامل التصفية المحدد أو التواريخ المحددة.",
"acknowledge_booking_no_show_fee": "أقر بأنه إذا لم أحضر هذا الحدث فسيتم سحب رسوم عدم حضور من بطاقتي تبلغ {{amount, currency}}.",
"days": "أيام",
"card_details": "تفاصيل البطاقة",
"something_went_wrong_on_our_end": "حدث خطأ ما من جانبنا. تواصل مع فريق الدعم لدينا، وسنقوم بإصلاحه فورًا لك.",
"please_provide_following_text_to_suppport": "يرجى تقديم النص التالي عند الاتصال بالدعم لمساعدتك بشكل أفضل",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "الحجوزات",
"pbac_resource_insights": "الرؤى",
"pbac_resource_role": "الأدوار",
"pbac_resource_routing_form": "نماذج التوجيه",
"pbac_resource_workflow": "سير العمل",
"pbac_action_all": "جميع الإجراءات",
"pbac_action_create": "إنشاء",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "جميع الإجراءات على الحجوزات",
"pbac_desc_view_team_insights": "عرض رؤى الفريق",
"pbac_desc_manage_team_insights": "إدارة رؤى الفريق",
"pbac_desc_create_routing_forms": "إنشاء نماذج التوجيه",
"pbac_desc_view_routing_forms": "عرض نماذج التوجيه",
"pbac_desc_update_routing_forms": "تحديث نماذج التوجيه",
"pbac_desc_delete_routing_forms": "حذف نماذج التوجيه",
"read_permission_auto_enabled_tooltip": "يتم تمكين إذن القراءة تلقائيًا عند إنشاء أو تحديث أو حذف مورد",
"choose_restriction_schedule": "إضافة جدول القيود",
"restriction_schedule_description": "تقييد توفر المضيفين لعرض الفترات الزمنية التي تقع ضمن جدول زمني محدد فقط.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "البيانات الوصفية",
"stats": "الإحصائيات",
"booking_status": "حالة الحجز",
"unsupported_file_type": "لا يمكن رفع ملفات {{type}} كصور",
"only_image_files_allowed": "يُسمح فقط بملفات الصور",
"failed_to_validate_image_file": "فشل التحقق من صحة ملف الصورة",
"invalid_image_file_format": "تنسيق ملف الصورة غير صالح",
"svg_contains_dangerous_content": "ملف SVG يحتوي على محتوى قد يكون خطيرًا",
"unrecognized_image_format": "تنسيق صورة غير معروف أو ملف غير صالح",
"invalid_base64_format": "تنسيق base64 غير صالح",
"empty_image_data": "بيانات الصورة فارغة",
"visit": "زيارة",
"location_custom_label_input_label": "تسمية مخصصة على صفحة الحجز",
"meeting_link": "رابط الاجتماع",
@@ -3605,5 +3606,9 @@
"search_variables": "البحث عن المتغيرات",
"no_variables_found": "لم يتم العثور على متغيرات",
"api_key_name_too_long": "يجب ألا يتجاوز الاسم {{max}} حرفًا",
"auto_charge_for_last_minute_cancellation": "فرض رسوم التخلف عن الحضور تلقائياً للإلغاءات في اللحظة الأخيرة",
"before_scheduled_start_time": "قبل وقت البدء المحدد",
"cancel_booking_acknowledge_no_show_fee": "أقر بأنه عند إلغاء الحجز قبل {{timeValue}} {{timeUnit}} من وقت البدء سيتم فرض رسوم التخلف عن الحضور بقيمة {{amount, currency}}",
"contact_organizer": "إذا كانت لديك أي أسئلة، يرجى التواصل مع المنظم.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Daxiletmələr",
"impersonation": "İmitasiya",
"impersonation_description": "İstifadəçi imitasiya idarəetmə parametrləri",
"recent_impersonations": "Son təqlidlər",
"quick_impersonate": "Sürətli təqlid",
"no_recent_impersonations": "Son təqlidlər yoxdur",
"users": "İstifadəçilər",
"user": "İstifadəçi",
"profile_description": "{{appName}} profiliniz üçün parametrləri idarə edin",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Bu iş axınını sınaqdan keçirərkən, e-poçtların və SMS-lərin ən azı 1 saat əvvəl planlaşdırıla biləcəyini unutmayın",
"insights_no_data_found_for_filter": "Seçilmiş filtr və ya tarixlər üçün məlumat tapılmadı.",
"acknowledge_booking_no_show_fee": "Bu tədbirə qatılmasam, kartıma {{amount, currency}} gəlməmə haqqı tətbiq olunacağını qəbul edirəm.",
"days": "günlər",
"card_details": "Kart məlumatları",
"something_went_wrong_on_our_end": "Bizim tərəfimizdə bir şey səhv getdi. Dəstək komandamızla əlaqə saxlayın və biz bunu sizin üçün dərhal həll edəcəyik.",
"please_provide_following_text_to_suppport": "Dəstək ilə əlaqə saxlayarkən aşağıdakı mətni təqdim edin ki, sizə daha yaxşı kömək edə bilək",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Rezervasiyalar",
"pbac_resource_insights": "Görüşlər",
"pbac_resource_role": "Rollar",
"pbac_resource_routing_form": "Marşrutlaşdırma Formları",
"pbac_resource_workflow": "İş axınları",
"pbac_action_all": "Bütün Fəaliyyətlər",
"pbac_action_create": "Yaratmaq",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Rezervasiyalar üzərində bütün əməliyyatlar",
"pbac_desc_view_team_insights": "Komanda analitikasına baxmaq",
"pbac_desc_manage_team_insights": "Komanda analitikasını idarə etmək",
"pbac_desc_create_routing_forms": "Marşrutlaşdırma formalarını yarat",
"pbac_desc_view_routing_forms": "Marşrutlaşdırma formalarına bax",
"pbac_desc_update_routing_forms": "Marşrutlaşdırma formalarını yenilə",
"pbac_desc_delete_routing_forms": "Marşrutlaşdırma formalarını sil",
"read_permission_auto_enabled_tooltip": "Resurs yaradarkən, yeniləyərkən və ya silənərkən oxuma icazəsi avtomatik olaraq aktivləşdirilir",
"choose_restriction_schedule": "Məhdudiyyət qrafiki əlavə edin",
"restriction_schedule_description": "Ev sahiblərinin əlçatanlığını yalnız müəyyən edilmiş qrafik daxilində olan slotları göstərməklə məhdudlaşdırın.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Statistika",
"booking_status": "Rezervasiya statusu",
"unsupported_file_type": "{{type}} faylları şəkil kimi yüklənə bilməz",
"only_image_files_allowed": "Yalnız şəkil fayllarına icazə verilir",
"failed_to_validate_image_file": "Şəkil faylını doğrulamaq mümkün olmadı",
"invalid_image_file_format": "Yanlış şəkil fayl formatı",
"svg_contains_dangerous_content": "SVG potensial təhlükəli məzmun ehtiva edir",
"unrecognized_image_format": "Tanınmayan şəkil formatı və ya yanlış fayl",
"invalid_base64_format": "Yanlış base64 formatı",
"empty_image_data": "Boş şəkil məlumatı",
"visit": "Ziyarət et",
"location_custom_label_input_label": "Rezervasiya səhifəsində xüsusi etiket",
"meeting_link": "Görüş linki",
@@ -3605,5 +3606,9 @@
"search_variables": "Dəyişənləri axtar",
"no_variables_found": "Heç bir dəyişən tapılmadı",
"api_key_name_too_long": "Ad {{max}} simvolu keçməməlidir",
"auto_charge_for_last_minute_cancellation": "Son dəqiqə ləğvləri üçün avtomatik olaraq gəlməmə haqqını tutun",
"before_scheduled_start_time": "Planlaşdırılmış başlama vaxtından əvvəl",
"cancel_booking_acknowledge_no_show_fee": "Təsdiq edirəm ki, rezervasiyanı başlama vaxtından {{timeValue}} {{timeUnit}} ərzində ləğv etməklə mənə {{amount, currency}} məbləğində gəlməmə haqqı tutulacaq",
"contact_organizer": "Hər hansı sualınız varsa, təşkilatçı ilə əlaqə saxlayın.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Вграждания",
"impersonation": "Представяне като друг потребител",
"impersonation_description": "Настройки за управление на представянето като друг потребител",
"recent_impersonations": "Скорошни представяния",
"quick_impersonate": "Бързо представяне",
"no_recent_impersonations": "Няма скорошни представяния",
"users": "Потребители",
"user": "Потребител",
"profile_description": "Управлявайте настройките на вашия {{appName}} профил",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Когато тествате този работен процес, имайте предвид, че имейлите и SMS съобщенията могат да бъдат планирани само поне 1 час предварително",
"insights_no_data_found_for_filter": "Не са намерени данни за избрания филтър или избраните дати.",
"acknowledge_booking_no_show_fee": "Потвърждавам, че ако не присъствам на това събитие, ще бъде начислена такса за неявяване в размер на {{amount, currency}} на моята карта.",
"days": "дни",
"card_details": "Данни на картата",
"something_went_wrong_on_our_end": "Нещо се обърка от наша страна. Свържете се с нашия екип за поддръжка и ние ще го оправим веднага за вас.",
"please_provide_following_text_to_suppport": "Моля, предоставете следния текст при контакт с поддръжката за по-добра помощ",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Резервации",
"pbac_resource_insights": "Анализи",
"pbac_resource_role": "Роли",
"pbac_resource_routing_form": "Формуляри за маршрутизиране",
"pbac_resource_workflow": "Работни процеси",
"pbac_action_all": "Всички действия",
"pbac_action_create": "Създаване",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Всички действия върху резервации",
"pbac_desc_view_team_insights": "Преглед на анализи на екипа",
"pbac_desc_manage_team_insights": "Управление на анализи на екипа",
"pbac_desc_create_routing_forms": "Създаване на формуляри за маршрутизиране",
"pbac_desc_view_routing_forms": "Преглед на формуляри за маршрутизиране",
"pbac_desc_update_routing_forms": "Актуализиране на формуляри за маршрутизиране",
"pbac_desc_delete_routing_forms": "Изтриване на формуляри за маршрутизиране",
"read_permission_auto_enabled_tooltip": "Разрешението за четене се активира автоматично при създаване, актуализиране или изтриване на ресурс",
"choose_restriction_schedule": "Добавяне на график с ограничения",
"restriction_schedule_description": "Ограничете достъпността на домакините, за да се показват само слотове, които попадат в определен график.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Метаданни",
"stats": "Статистика",
"booking_status": "Статус на резервацията",
"unsupported_file_type": "Файлове {{type}} не могат да бъдат качени като изображения",
"only_image_files_allowed": "Разрешени са само файлове с изображения",
"failed_to_validate_image_file": "Неуспешна валидация на файла с изображение",
"invalid_image_file_format": "Невалиден формат на файла с изображение",
"svg_contains_dangerous_content": "SVG съдържа потенциално опасно съдържание",
"unrecognized_image_format": "Неразпознат формат на изображението или невалиден файл",
"invalid_base64_format": "Невалиден base64 формат",
"empty_image_data": "Празни данни на изображението",
"visit": "Посети",
"location_custom_label_input_label": "Персонализиран етикет на страницата за резервация",
"meeting_link": "Линк за среща",
@@ -3605,5 +3606,9 @@
"search_variables": "Търсене на променливи",
"no_variables_found": "Не са намерени променливи",
"api_key_name_too_long": "Името не трябва да надвишава {{max}} символа",
"auto_charge_for_last_minute_cancellation": "Автоматично таксуване на такса за неявяване при отмяна в последния момент",
"before_scheduled_start_time": "Преди планираното начално време",
"cancel_booking_acknowledge_no_show_fee": "Потвърждавам, че при отмяна на резервацията в рамките на {{timeValue}} {{timeUnit}} преди началния час ще ми бъде начислена такса за неявяване в размер на {{amount, currency}}",
"contact_organizer": "Ако имате въпроси, моля, свържете се с организатора.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавете новите си низове над този ред ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "এম্বেড",
"impersonation": "ছদ্মবেশ",
"impersonation_description": "ব্যবহারকারীর ছদ্মবেশ পরিচালনা করতে সেটিংস",
"recent_impersonations": "সাম্প্রতিক প্রতিরূপণ",
"quick_impersonate": "দ্রুত প্রতিরূপণ",
"no_recent_impersonations": "কোনো সাম্প্রতিক প্রতিরূপণ নেই",
"users": "ব্যবহারকারীরা",
"user": "ব্যবহারকারী",
"profile_description": "আপনার জন্য সেটিংস পরিচালনা করুন {{appName}} প্রোফাইল",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "এই কর্মপ্রবাহটি পরীক্ষা করার সময়, সচেতন হন যে ইমেলগুলি এবং এসএমএস কেবলমাত্র কমপক্ষে 1 ঘন্টা আগে নির্ধারিত হতে পারে",
"insights_no_data_found_for_filter": "নির্বাচিত ফিল্টার বা নির্বাচিত তারিখগুলির জন্য কোনও ডেটা পাওয়া যায় নি।",
"acknowledge_booking_no_show_fee": "আমি স্বীকার করি যে আমি যদি এই ইভেন্টে অংশ না নিই তবে ক {{amount, currency}} আমার কার্ডে কোনও শো ফি প্রয়োগ করা হবে না।",
"days": "দিন",
"card_details": "কার্ডের বিশদ",
"something_went_wrong_on_our_end": "আমাদের দিকে কিছু সমস্যা হয়েছে। আমাদের সাপোর্ট টিমের সাথে যোগাযোগ করুন, এবং আমরা আপনার জন্য এটি দ্রুত ঠিক করে দেব।",
"please_provide_following_text_to_suppport": "আপনাকে আরও ভাল সহায়তা করতে সহায়তার সাথে যোগাযোগ করার সময় দয়া করে নিম্নলিখিত পাঠ্যটি সরবরাহ করুন",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "বুকিং",
"pbac_resource_insights": "অন্তর্দৃষ্টি",
"pbac_resource_role": "ভূমিকা",
"pbac_resource_routing_form": "রাউটিং ফর্ম",
"pbac_resource_workflow": "কর্মপ্রবাহ",
"pbac_action_all": "সমস্ত ক্রিয়া",
"pbac_action_create": "তৈরি করুন",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "বুকিংয়ের সকল কার্যক্রম",
"pbac_desc_view_team_insights": "দলের অন্তর্দৃষ্টি দেখুন",
"pbac_desc_manage_team_insights": "দলের অন্তর্দৃষ্টি পরিচালনা করুন",
"pbac_desc_create_routing_forms": "রাউটিং ফর্ম তৈরি করুন",
"pbac_desc_view_routing_forms": "রাউটিং ফর্ম দেখুন",
"pbac_desc_update_routing_forms": "রাউটিং ফর্ম আপডেট করুন",
"pbac_desc_delete_routing_forms": "রাউটিং ফর্ম মুছুন",
"read_permission_auto_enabled_tooltip": "কোনো রিসোর্স তৈরি, আপডেট বা মুছে ফেলার সময় পড়ার অনুমতি স্বয়ংক্রিয়ভাবে সক্রিয় হয়",
"choose_restriction_schedule": "বিধিনিষেধ সময়সূচি যোগ করুন",
"restriction_schedule_description": "হোস্টদের উপলব্ধতা সীমিত করুন যাতে শুধুমাত্র নির্দিষ্ট সময়সূচীর মধ্যে পড়া স্লটগুলি প্রদর্শিত হয়।",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "মেটাডাটা",
"stats": "পরিসংখ্যান",
"booking_status": "বুকিংয়ের স্থিতি",
"unsupported_file_type": "{{type}} ফাইলগুলি ছবি হিসাবে আপলোড করা যাবে না",
"only_image_files_allowed": "শুধুমাত্র ছবির ফাইল অনুমোদিত",
"failed_to_validate_image_file": "ছবির ফাইল যাচাই করতে ব্যর্থ হয়েছে",
"invalid_image_file_format": "অবৈধ ছবির ফাইল ফরম্যাট",
"svg_contains_dangerous_content": "SVG-তে সম্ভাব্য বিপজ্জনক কন্টেন্ট রয়েছে",
"unrecognized_image_format": "অপরিচিত ছবির ফরম্যাট বা অবৈধ ফাইল",
"invalid_base64_format": "অবৈধ base64 ফরম্যাট",
"empty_image_data": "খালি ছবির ডাটা",
"visit": "দেখুন",
"location_custom_label_input_label": "বুকিং পৃষ্ঠায় কাস্টম লেবেল",
"meeting_link": "সভার লিঙ্ক",
@@ -3605,5 +3606,9 @@
"search_variables": "ভেরিয়েবল অনুসন্ধান করুন",
"no_variables_found": "কোন ভেরিয়েবল পাওয়া যায়নি",
"api_key_name_too_long": "নাম অবশ্যই {{max}} অক্ষরের বেশি হবে না",
"auto_charge_for_last_minute_cancellation": "শেষ মুহূর্তের বাতিলকরণের জন্য স্বয়ংক্রিয়ভাবে অনুপস্থিতি ফি চার্জ করুন",
"before_scheduled_start_time": "নির্ধারিত শুরুর সময়ের আগে",
"cancel_booking_acknowledge_no_show_fee": "আমি স্বীকার করছি যে শুরুর সময়ের {{timeValue}} {{timeUnit}} এর মধ্যে বুকিং বাতিল করলে আমাকে {{amount, currency}} অনুপস্থিতি ফি চার্জ করা হবে",
"contact_organizer": "আপনার কোন প্রশ্ন থাকলে, অনুগ্রহ করে আয়োজকের সাথে যোগাযোগ করুন।",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑ুষান্ত"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Incrustacions",
"impersonation": "Suplantació",
"impersonation_description": "Configuració per gestionar la suplantació d'usuaris",
"recent_impersonations": "Suplantacions recents",
"quick_impersonate": "Suplantació ràpida",
"no_recent_impersonations": "No hi ha suplantacions recents",
"users": "Usuaris",
"user": "Usuari",
"profile_description": "Gestiona la configuració del teu perfil de {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Quan provis aquest flux de treball, tingues en compte que els correus electrònics i SMS només es poden programar amb almenys 1 hora d'antelació",
"insights_no_data_found_for_filter": "No s'han trobat dades per al filtre seleccionat o les dates seleccionades.",
"acknowledge_booking_no_show_fee": "Reconec que si no assisteixo a aquest esdeveniment, s'aplicarà una penalització de {{amount, currency}} a la meva targeta.",
"days": "dies",
"card_details": "Detalls de la targeta",
"something_went_wrong_on_our_end": "Alguna cosa ha anat malament per la nostra part. Contacta amb el nostre equip de suport i ho solucionarem de seguida per a tu.",
"please_provide_following_text_to_suppport": "Si us plau, proporciona el següent text quan contactis amb suport per ajudar-te millor",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Reserves",
"pbac_resource_insights": "Estadístiques",
"pbac_resource_role": "Rols",
"pbac_resource_routing_form": "Formularis de redirecció",
"pbac_resource_workflow": "Fluxos de treball",
"pbac_action_all": "Totes les accions",
"pbac_action_create": "Crear",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Totes les accions sobre reserves",
"pbac_desc_view_team_insights": "Veure estadístiques d'equip",
"pbac_desc_manage_team_insights": "Gestionar estadístiques d'equip",
"pbac_desc_create_routing_forms": "Crear formularis de redirecció",
"pbac_desc_view_routing_forms": "Veure formularis de redirecció",
"pbac_desc_update_routing_forms": "Actualitzar formularis de redirecció",
"pbac_desc_delete_routing_forms": "Eliminar formularis de redirecció",
"read_permission_auto_enabled_tooltip": "El permís de lectura s'activa automàticament quan es crea, actualitza o elimina un recurs",
"choose_restriction_schedule": "Afegir horari de restricció",
"restriction_schedule_description": "Limita la disponibilitat dels amfitrions per mostrar només franges horàries que es trobin dins d'un horari especificat.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadades",
"stats": "Estadístiques",
"booking_status": "Estat de la reserva",
"unsupported_file_type": "Els fitxers {{type}} no es poden pujar com a imatges",
"only_image_files_allowed": "Només es permeten fitxers d'imatge",
"failed_to_validate_image_file": "No s'ha pogut validar el fitxer d'imatge",
"invalid_image_file_format": "Format de fitxer d'imatge no vàlid",
"svg_contains_dangerous_content": "L'SVG conté contingut potencialment perillós",
"unrecognized_image_format": "Format d'imatge no reconegut o fitxer no vàlid",
"invalid_base64_format": "Format base64 no vàlid",
"empty_image_data": "Dades d'imatge buides",
"visit": "Visita",
"location_custom_label_input_label": "Etiqueta personalitzada a la pàgina de reserva",
"meeting_link": "Enllaç de la reunió",
@@ -3605,5 +3606,9 @@
"search_variables": "Cerca variables",
"no_variables_found": "No s'han trobat variables",
"api_key_name_too_long": "El nom no ha de superar els {{max}} caràcters",
"auto_charge_for_last_minute_cancellation": "Càrrec automàtic de la tarifa per no assistència en cas de cancel·lacions d'última hora",
"before_scheduled_start_time": "Abans de l'hora d'inici programada",
"cancel_booking_acknowledge_no_show_fee": "Reconec que en cancel·lar la reserva dins dels {{timeValue}} {{timeUnit}} abans de l'hora d'inici se'm cobrarà la tarifa per no assistència de {{amount, currency}}",
"contact_organizer": "Si teniu alguna pregunta, poseu-vos en contacte amb l'organitzador.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Afegiu les vostres noves cadenes a dalt ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Vložené kalendáře",
"impersonation": "Ztotožnění",
"impersonation_description": "Nastavení správy ztotožnění uživatele",
"recent_impersonations": "Nedávná vydávání se za jiné",
"quick_impersonate": "Rychlé vydávání se za jiného",
"no_recent_impersonations": "Žádná nedávná vydávání se za jiné",
"users": "Uživatelé",
"user": "Uživatel",
"profile_description": "Správa nastavení profilu {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Při testování tohoto pracovního postupu mějte na paměti, že e-maily a SMS lze naplánovat pouze nejméně 1 hodinu předem.",
"insights_no_data_found_for_filter": "Pro vybraný filtr nebo vybraná data nebyla nalezena žádná data.",
"acknowledge_booking_no_show_fee": "Beru na vědomí, že pokud se této události nezúčastním, bude z mé karty stržen poplatek za nedostavení se ve výši {{amount, currency}}.",
"days": "dny",
"card_details": "Údaje o kartě",
"something_went_wrong_on_our_end": "Na naší straně došlo k chybě. Kontaktujte náš tým podpory a my to pro vás co nejdříve opravíme.",
"please_provide_following_text_to_suppport": "Při kontaktování podpory uveďte v zájmu lepší efektivity následující",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Rezervace",
"pbac_resource_insights": "Přehledy",
"pbac_resource_role": "Role",
"pbac_resource_routing_form": "Směrovací formuláře",
"pbac_resource_workflow": "Pracovní postupy",
"pbac_action_all": "Všechny akce",
"pbac_action_create": "Vytvořit",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Všechny akce s rezervacemi",
"pbac_desc_view_team_insights": "Zobrazit přehledy týmu",
"pbac_desc_manage_team_insights": "Spravovat přehledy týmu",
"pbac_desc_create_routing_forms": "Vytvářet směrovací formuláře",
"pbac_desc_view_routing_forms": "Zobrazit směrovací formuláře",
"pbac_desc_update_routing_forms": "Aktualizovat směrovací formuláře",
"pbac_desc_delete_routing_forms": "Odstranit směrovací formuláře",
"read_permission_auto_enabled_tooltip": "Oprávnění ke čtení je automaticky povoleno při vytváření, aktualizaci nebo mazání zdroje",
"choose_restriction_schedule": "Přidat plán omezení",
"restriction_schedule_description": "Omezit dostupnost hostitelů tak, aby se zobrazovaly pouze časové úseky, které spadají do určeného rozvrhu.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Statistiky",
"booking_status": "Stav rezervace",
"unsupported_file_type": "Soubory {{type}} nelze nahrát jako obrázky",
"only_image_files_allowed": "Povoleny jsou pouze obrázkové soubory",
"failed_to_validate_image_file": "Nepodařilo se ověřit obrázkový soubor",
"invalid_image_file_format": "Neplatný formát obrázkového souboru",
"svg_contains_dangerous_content": "SVG obsahuje potenciálně nebezpečný obsah",
"unrecognized_image_format": "Nerozpoznaný formát obrázku nebo neplatný soubor",
"invalid_base64_format": "Neplatný formát base64",
"empty_image_data": "Prázdná obrazová data",
"visit": "Navštívit",
"location_custom_label_input_label": "Vlastní popisek na stránce rezervace",
"meeting_link": "Odkaz na schůzku",
@@ -3605,5 +3606,9 @@
"search_variables": "Hledat proměnné",
"no_variables_found": "Nebyly nalezeny žádné proměnné",
"api_key_name_too_long": "Název nesmí přesáhnout {{max}} znaků",
"auto_charge_for_last_minute_cancellation": "Automaticky účtovat poplatek za nedostavení se při zrušení na poslední chvíli",
"before_scheduled_start_time": "Před plánovaným časem zahájení",
"cancel_booking_acknowledge_no_show_fee": "Beru na vědomí, že zrušením rezervace v době kratší než {{timeValue}} {{timeUnit}} před začátkem mi bude účtován poplatek za nedostavení se ve výši {{amount, currency}}",
"contact_organizer": "Pokud máte jakékoli dotazy, kontaktujte prosím organizátora.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Indlejringer",
"impersonation": "Impersonation",
"impersonation_description": "Indstillinger til håndtering af bruger-impersonation",
"recent_impersonations": "Seneste imitationer",
"quick_impersonate": "Hurtig imitation",
"no_recent_impersonations": "Ingen seneste imitationer",
"users": "Brugere",
"user": "Bruger",
"profile_description": "Administrér indstillinger for din {{appName}} profil",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Når du tester denne arbejdsgang, skal du være opmærksom på, at e-mails og SMS'er kun kan planlægges mindst 1 time i forvejen",
"insights_no_data_found_for_filter": "Ingen data fundet for det valgte filter eller de valgte datoer.",
"acknowledge_booking_no_show_fee": "Jeg anerkender, at hvis jeg ikke deltager i denne begivenhed, vil et gebyr for udeblivelse på {{amount, currency}} blive opkrævet på mit kort.",
"days": "dage",
"card_details": "Kortoplysninger",
"something_went_wrong_on_our_end": "Noget gik galt hos os. Kontakt vores supportteam, så løser vi det straks for dig.",
"please_provide_following_text_to_suppport": "Angiv venligst følgende tekst, når du kontakter support, for at hjælpe os bedre",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Bookinger",
"pbac_resource_insights": "Indsigter",
"pbac_resource_role": "Roller",
"pbac_resource_routing_form": "Ruteformularer",
"pbac_resource_workflow": "Workflows",
"pbac_action_all": "Alle handlinger",
"pbac_action_create": "Opret",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Alle handlinger på bookinger",
"pbac_desc_view_team_insights": "Se teamindsigter",
"pbac_desc_manage_team_insights": "Administrer teamindsigter",
"pbac_desc_create_routing_forms": "Opret ruteformularer",
"pbac_desc_view_routing_forms": "Se ruteformularer",
"pbac_desc_update_routing_forms": "Opdater ruteformularer",
"pbac_desc_delete_routing_forms": "Slet ruteformularer",
"read_permission_auto_enabled_tooltip": "Læs tilladelse aktiveres automatisk, når en ressource oprettes, opdateres eller slettes",
"choose_restriction_schedule": "Tilføj restriktionsskema",
"restriction_schedule_description": "Begræns værternes tilgængelighed til kun at vise tidsrum, der falder inden for en specificeret tidsplan.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Statistik",
"booking_status": "Bookingstatus",
"unsupported_file_type": "{{type}}-filer kan ikke uploades som billeder",
"only_image_files_allowed": "Kun billedfiler er tilladt",
"failed_to_validate_image_file": "Kunne ikke validere billedfilen",
"invalid_image_file_format": "Ugyldigt billedfilformat",
"svg_contains_dangerous_content": "SVG indeholder potentielt farligt indhold",
"unrecognized_image_format": "Ukendt billedformat eller ugyldig fil",
"invalid_base64_format": "Ugyldigt base64-format",
"empty_image_data": "Tom billeddata",
"visit": "Besøg",
"location_custom_label_input_label": "Brugerdefineret etiket på bookingsiden",
"meeting_link": "Mødelink",
@@ -3605,5 +3606,9 @@
"search_variables": "Søg i variabler",
"no_variables_found": "Ingen variable fundet",
"api_key_name_too_long": "Navnet må ikke overstige {{max}} tegn",
"auto_charge_for_last_minute_cancellation": "Automatisk opkrævning af gebyr for udeblivelse ved aflysning i sidste øjeblik",
"before_scheduled_start_time": "Før den planlagte starttid",
"cancel_booking_acknowledge_no_show_fee": "Jeg anerkender, at ved at aflyse bookingen inden for {{timeValue}} {{timeUnit}} før starttiden, vil jeg blive opkrævet gebyret for udeblivelse på {{amount, currency}}",
"contact_organizer": "Hvis du har spørgsmål, bedes du kontakte arrangøren.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Embeds",
"impersonation": "Benutzeridentitätswechsel",
"impersonation_description": "Einstellungen zur Verwaltung der Benutzeridentifikation",
"recent_impersonations": "Letzte Identitätswechsel",
"quick_impersonate": "Schneller Identitätswechsel",
"no_recent_impersonations": "Keine kürzlichen Identitätswechsel",
"users": "Benutzer",
"user": "Benutzer",
"profile_description": "Einstellungen für Ihr {{appName}} Profil verwalten",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Bitte beachten Sie beim Testen dieses Workflows, dass E-Mails und SMS spätestens 1 Stunde im Voraus geplant werden können",
"insights_no_data_found_for_filter": "Keine Daten für den ausgewählten Filter oder das ausgewählte Datum gefunden.",
"acknowledge_booking_no_show_fee": "Ich bestätige, dass meiner Karte eine Gebühr von {{amount, currency}} berechnet wird, sofern ich nicht an diesem Ereignis teilnehme.",
"days": "Tage",
"card_details": "Karteninformationen",
"something_went_wrong_on_our_end": "Bei uns ist etwas schiefgelaufen. Kontaktiere unseren Support und wir werden das Problem umgehend für dich beheben.",
"please_provide_following_text_to_suppport": "Bitte geben Sie den folgenden Text an, wenn Sie den Support kontaktieren, damit wir Ihnen besser helfen können",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Buchungen",
"pbac_resource_insights": "Erkenntnisse",
"pbac_resource_role": "Rollen",
"pbac_resource_routing_form": "Weiterleitungsformulare",
"pbac_resource_workflow": "Workflows",
"pbac_action_all": "Alle Aktionen",
"pbac_action_create": "Erstellen",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Alle Aktionen für Buchungen",
"pbac_desc_view_team_insights": "Team-Insights anzeigen",
"pbac_desc_manage_team_insights": "Team-Insights verwalten",
"pbac_desc_create_routing_forms": "Weiterleitungsformulare erstellen",
"pbac_desc_view_routing_forms": "Weiterleitungsformulare anzeigen",
"pbac_desc_update_routing_forms": "Weiterleitungsformulare aktualisieren",
"pbac_desc_delete_routing_forms": "Weiterleitungsformulare löschen",
"read_permission_auto_enabled_tooltip": "Leseberechtigung wird automatisch aktiviert, wenn eine Ressource erstellt, aktualisiert oder gelöscht wird",
"choose_restriction_schedule": "Einschränkungszeitplan hinzufügen",
"restriction_schedule_description": "Beschränken Sie die Verfügbarkeit von Gastgebern, um nur Zeitfenster anzuzeigen, die in einen festgelegten Zeitplan fallen.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadaten",
"stats": "Statistiken",
"booking_status": "Buchungsstatus",
"unsupported_file_type": "{{type}}-Dateien können nicht als Bilder hochgeladen werden",
"only_image_files_allowed": "Es sind nur Bilddateien erlaubt",
"failed_to_validate_image_file": "Bilddatei konnte nicht validiert werden",
"invalid_image_file_format": "Ungültiges Bilddateiformat",
"svg_contains_dangerous_content": "SVG enthält potenziell gefährliche Inhalte",
"unrecognized_image_format": "Nicht erkanntes Bildformat oder ungültige Datei",
"invalid_base64_format": "Ungültiges Base64-Format",
"empty_image_data": "Leere Bilddaten",
"visit": "Besuchen",
"location_custom_label_input_label": "Benutzerdefinierte Bezeichnung auf der Buchungsseite",
"meeting_link": "Meeting-Link",
@@ -3605,5 +3606,9 @@
"search_variables": "Variablen durchsuchen",
"no_variables_found": "Keine Variablen gefunden",
"api_key_name_too_long": "Der Name darf {{max}} Zeichen nicht überschreiten",
"auto_charge_for_last_minute_cancellation": "Automatische Berechnung der Ausfallgebühr für kurzfristige Stornierungen",
"before_scheduled_start_time": "Vor dem geplanten Startzeitpunkt",
"cancel_booking_acknowledge_no_show_fee": "Ich bestätige, dass mir bei einer Stornierung innerhalb von {{timeValue}} {{timeUnit}} vor dem Startzeitpunkt die Ausfallgebühr von {{amount, currency}} berechnet wird",
"contact_organizer": "Bei Fragen wenden Sie sich bitte an den Organisator.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Ενσωματώσεις",
"impersonation": "Προσωποποίηση",
"impersonation_description": "Ρυθμίσεις για τη διαχείριση προσωποποίησης χρηστών",
"recent_impersonations": "Πρόσφατες προσωποποιήσεις",
"quick_impersonate": "Γρήγορη προσωποποίηση",
"no_recent_impersonations": "Δεν υπάρχουν πρόσφατες προσωποποιήσεις",
"users": "Χρήστες",
"user": "Χρήστης",
"profile_description": "Διαχείριση ρυθμίσεων για το προφίλ σας στο {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Κατά τη δοκιμή αυτής της ροής εργασίας, λάβετε υπόψη ότι τα email και τα SMS μπορούν να προγραμματιστούν τουλάχιστον 1 ώρα νωρίτερα",
"insights_no_data_found_for_filter": "Δεν βρέθηκαν δεδομένα για το επιλεγμένο φίλτρο ή τις επιλεγμένες ημερομηνίες.",
"acknowledge_booking_no_show_fee": "Αναγνωρίζω ότι εάν δεν παρευρεθώ σε αυτήν την εκδήλωση, θα χρεωθεί στην κάρτα μου τέλος μη προσέλευσης ύψους {{amount, currency}}.",
"days": "ημέρες",
"card_details": "Στοιχεία κάρτας",
"something_went_wrong_on_our_end": "Κάτι πήγε στραβά από την πλευρά μας. Επικοινωνήστε με την ομάδα υποστήριξής μας και θα το διορθώσουμε αμέσως για εσάς.",
"please_provide_following_text_to_suppport": "Παρακαλούμε παρέχετε το ακόλουθο κείμενο όταν επικοινωνείτε με την υποστήριξη για καλύτερη βοήθεια",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Κρατήσεις",
"pbac_resource_insights": "Αναλύσεις",
"pbac_resource_role": "Ρόλοι",
"pbac_resource_routing_form": "Φόρμες δρομολόγησης",
"pbac_resource_workflow": "Ροές εργασίας",
"pbac_action_all": "Όλες οι ενέργειες",
"pbac_action_create": "Δημιουργία",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Όλες οι ενέργειες στις κρατήσεις",
"pbac_desc_view_team_insights": "Προβολή αναλύσεων ομάδας",
"pbac_desc_manage_team_insights": "Διαχείριση αναλύσεων ομάδας",
"pbac_desc_create_routing_forms": "Δημιουργία φορμών δρομολόγησης",
"pbac_desc_view_routing_forms": "Προβολή φορμών δρομολόγησης",
"pbac_desc_update_routing_forms": "Ενημέρωση φορμών δρομολόγησης",
"pbac_desc_delete_routing_forms": "Διαγραφή φορμών δρομολόγησης",
"read_permission_auto_enabled_tooltip": "Το δικαίωμα ανάγνωσης ενεργοποιείται αυτόματα κατά τη δημιουργία, ενημέρωση ή διαγραφή ενός πόρου",
"choose_restriction_schedule": "Προσθήκη προγράμματος περιορισμών",
"restriction_schedule_description": "Περιορίστε τη διαθεσιμότητα των οικοδεσποτών ώστε να εμφανίζονται μόνο χρονοθυρίδες που εμπίπτουν σε ένα καθορισμένο πρόγραμμα.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Μεταδεδομένα",
"stats": "Στατιστικά",
"booking_status": "Κατάσταση κράτησης",
"unsupported_file_type": "Τα αρχεία {{type}} δεν μπορούν να μεταφορτωθούν ως εικόνες",
"only_image_files_allowed": "Επιτρέπονται μόνο αρχεία εικόνων",
"failed_to_validate_image_file": "Αποτυχία επικύρωσης αρχείου εικόνας",
"invalid_image_file_format": "Μη έγκυρη μορφή αρχείου εικόνας",
"svg_contains_dangerous_content": "Το SVG περιέχει δυνητικά επικίνδυνο περιεχόμενο",
"unrecognized_image_format": "Μη αναγνωρίσιμη μορφή εικόνας ή μη έγκυρο αρχείο",
"invalid_base64_format": "Μη έγκυρη μορφή base64",
"empty_image_data": "Κενά δεδομένα εικόνας",
"visit": "Επίσκεψη",
"location_custom_label_input_label": "Προσαρμοσμένη ετικέτα στη σελίδα κράτησης",
"meeting_link": "Σύνδεσμος συνάντησης",
@@ -3605,5 +3606,9 @@
"search_variables": "Αναζήτηση μεταβλητών",
"no_variables_found": "Δεν βρέθηκαν μεταβλητές",
"api_key_name_too_long": "Το όνομα δεν πρέπει να υπερβαίνει τους {{max}} χαρακτήρες",
"auto_charge_for_last_minute_cancellation": "Αυτόματη χρέωση τέλους μη εμφάνισης για ακυρώσεις τελευταίας στιγμής",
"before_scheduled_start_time": "Πριν την προγραμματισμένη ώρα έναρξης",
"cancel_booking_acknowledge_no_show_fee": "Αναγνωρίζω ότι με την ακύρωση της κράτησης εντός {{timeValue}} {{timeUnit}} από την ώρα έναρξης θα χρεωθώ το τέλος μη εμφάνισης ύψους {{amount, currency}}",
"contact_organizer": "Εάν έχετε οποιεσδήποτε ερωτήσεις, παρακαλώ επικοινωνήστε με τον διοργανωτή.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Προσθέστε τις νέες συμβολοσειρές σας πάνω από εδώ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Embeds",
"impersonation": "Impersonation",
"impersonation_description": "Settings to manage user impersonation",
"recent_impersonations": "Recent Impersonations",
"quick_impersonate": "Quick Impersonate",
"no_recent_impersonations": "No recent impersonations",
"users": "Users",
"user": "User",
"profile_description": "Manage settings for your {{appName}} profile",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "When testing this workflow, be aware that Emails and SMS can only be scheduled at least 1 hour in advance",
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
"days": "days",
"card_details": "Card details",
"something_went_wrong_on_our_end": "Something went wrong on our end. Get in touch with our support team, and we'll get it fixed right away for you.",
"please_provide_following_text_to_suppport": "Please provide the following text when contacting support to better help you",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Bookings",
"pbac_resource_insights": "Insights",
"pbac_resource_role": "Roles",
"pbac_resource_routing_form": "Routing Forms",
"pbac_resource_workflow": "Workflows",
"pbac_action_all": "All Actions",
"pbac_action_create": "Create",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "All actions on bookings",
"pbac_desc_view_team_insights": "View team insights",
"pbac_desc_manage_team_insights": "Manage team insights",
"pbac_desc_create_routing_forms": "Create routing forms",
"pbac_desc_view_routing_forms": "View routing forms",
"pbac_desc_update_routing_forms": "Update routing forms",
"pbac_desc_delete_routing_forms": "Delete routing forms",
"read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource",
"choose_restriction_schedule": "Add restriction schedule",
"restriction_schedule_description": "Limit availability of hosts to only display slots that fall within a specified schedule.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"unsupported_file_type": "{{type}} files cannot be uploaded as images",
"only_image_files_allowed": "Only image files are allowed",
"failed_to_validate_image_file": "Failed to validate image file",
"invalid_image_file_format": "Invalid image file format",
"svg_contains_dangerous_content": "SVG contains potentially dangerous content",
"unrecognized_image_format": "Unrecognized image format or invalid file",
"invalid_base64_format": "Invalid base64 format",
"empty_image_data": "Empty image data",
"visit": "Visit",
"location_custom_label_input_label": "Custom label on booking page",
"meeting_link": "Meeting link",
@@ -3605,5 +3606,9 @@
"search_variables": "Search variables",
"no_variables_found": "No variables found",
"api_key_name_too_long": "Name must not exceed {{max}} characters",
"auto_charge_for_last_minute_cancellation": "Auto charge no-show fee for last minute cancellations",
"before_scheduled_start_time": "Before scheduled start time",
"cancel_booking_acknowledge_no_show_fee": "I acknowledge that by cancelling the booking within {{timeValue}} {{timeUnit}} of the start time I will be charged the no show fee of {{amount, currency}}",
"contact_organizer": "If you have any questions, please contact the organizer.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1645,6 +1645,9 @@
"embeds": "Incrustaciones",
"impersonation": "Suplantación",
"impersonation_description": "Configuraciones para gestionar la suplantación de usuarios",
"recent_impersonations": "Suplantaciones recientes",
"quick_impersonate": "Suplantar rápidamente",
"no_recent_impersonations": "No hay suplantaciones recientes",
"users": "Usuarios",
"user": "Usuario",
"profile_description": "Gestiona las configuraciones de tu perfil de {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Al probar este flujo de trabajo, tenga en cuenta que los correos electrónicos y SMS solo se pueden programar con al menos 1 hora de anticipación",
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro o las fechas seleccionadas.",
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará una tarifa de no presentación de {{amount, currency}} a mi tarjeta.",
"days": "días",
"card_details": "Detalles de la tarjeta",
"something_went_wrong_on_our_end": "Algo salió mal de nuestra parte. Ponte en contacto con nuestro equipo de soporte y lo solucionaremos de inmediato para ti.",
"please_provide_following_text_to_suppport": "Por favor, proporcione el siguiente texto al contactar con el soporte para ayudarle mejor",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Reservas",
"pbac_resource_insights": "Perspectivas",
"pbac_resource_role": "Roles",
"pbac_resource_routing_form": "Formularios de enrutamiento",
"pbac_resource_workflow": "Flujos de trabajo",
"pbac_action_all": "Todas las acciones",
"pbac_action_create": "Crear",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Todas las acciones en reservas",
"pbac_desc_view_team_insights": "Ver estadísticas del equipo",
"pbac_desc_manage_team_insights": "Administrar estadísticas del equipo",
"pbac_desc_create_routing_forms": "Crear formularios de enrutamiento",
"pbac_desc_view_routing_forms": "Ver formularios de enrutamiento",
"pbac_desc_update_routing_forms": "Actualizar formularios de enrutamiento",
"pbac_desc_delete_routing_forms": "Eliminar formularios de enrutamiento",
"read_permission_auto_enabled_tooltip": "El permiso de lectura se habilita automáticamente al crear, actualizar o eliminar un recurso",
"choose_restriction_schedule": "Agregar horario de restricción",
"restriction_schedule_description": "Limita la disponibilidad de los anfitriones para mostrar solo espacios que se encuentren dentro de un horario específico.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadatos",
"stats": "Estadísticas",
"booking_status": "Estado de la reserva",
"unsupported_file_type": "Los archivos {{type}} no se pueden cargar como imágenes",
"only_image_files_allowed": "Solo se permiten archivos de imagen",
"failed_to_validate_image_file": "No se pudo validar el archivo de imagen",
"invalid_image_file_format": "Formato de archivo de imagen no válido",
"svg_contains_dangerous_content": "El SVG contiene contenido potencialmente peligroso",
"unrecognized_image_format": "Formato de imagen no reconocido o archivo no válido",
"invalid_base64_format": "Formato base64 no válido",
"empty_image_data": "Datos de imagen vacíos",
"visit": "Visitar",
"location_custom_label_input_label": "Etiqueta personalizada en la página de reservas",
"meeting_link": "Enlace de la reunión",
@@ -3605,5 +3606,9 @@
"search_variables": "Buscar variables",
"no_variables_found": "No se encontraron variables",
"api_key_name_too_long": "El nombre no debe exceder los {{max}} caracteres",
"auto_charge_for_last_minute_cancellation": "Cobrar automáticamente la tarifa por no presentarse en caso de cancelaciones de último minuto",
"before_scheduled_start_time": "Antes de la hora de inicio programada",
"cancel_booking_acknowledge_no_show_fee": "Reconozco que al cancelar la reserva dentro de los {{timeValue}} {{timeUnit}} previos a la hora de inicio, se me cobrará la tarifa por no presentarme de {{amount, currency}}",
"contact_organizer": "Si tienes alguna pregunta, comunícate con el organizador.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Inserciones",
"impersonation": "Suplantación",
"impersonation_description": "Configuración para administrar la suplantación del usuario",
"recent_impersonations": "Suplantaciones recientes",
"quick_impersonate": "Suplantar rápidamente",
"no_recent_impersonations": "No hay suplantaciones recientes",
"users": "Usuarios",
"user": "Usuario",
"profile_description": "Administra los ajustes para tu perfil de {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Al probar este flujo de trabajo, tenga en cuenta que los correos electrónicos y los SMS solo se pueden programar con al menos 1 hora de anticipación",
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro seleccionado o las fechas seleccionadas.",
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará a mi tarjeta una tarifa de {{amount, currency}} por no presentarme.",
"days": "días",
"card_details": "Detalles de la tarjeta",
"something_went_wrong_on_our_end": "Algo salió mal por nuestra parte. Ponte en contacto con nuestro equipo de soporte y lo solucionaremos de inmediato.",
"please_provide_following_text_to_suppport": "Proporcione el siguiente texto cuando se comunique con el soporte para ayudarlo mejor",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Reservas",
"pbac_resource_insights": "Perspectivas",
"pbac_resource_role": "Roles",
"pbac_resource_routing_form": "Formularios de enrutamiento",
"pbac_resource_workflow": "Flujos de trabajo",
"pbac_action_all": "Todas las acciones",
"pbac_action_create": "Crear",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Todas las acciones en reservas",
"pbac_desc_view_team_insights": "Ver estadísticas del equipo",
"pbac_desc_manage_team_insights": "Gestionar estadísticas del equipo",
"pbac_desc_create_routing_forms": "Crear formularios de enrutamiento",
"pbac_desc_view_routing_forms": "Ver formularios de enrutamiento",
"pbac_desc_update_routing_forms": "Actualizar formularios de enrutamiento",
"pbac_desc_delete_routing_forms": "Eliminar formularios de enrutamiento",
"read_permission_auto_enabled_tooltip": "El permiso de lectura se habilita automáticamente al crear, actualizar o eliminar un recurso",
"choose_restriction_schedule": "Añadir horario de restricción",
"restriction_schedule_description": "Limita la disponibilidad de los anfitriones para mostrar solo los espacios que se encuentran dentro de un horario específico.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadatos",
"stats": "Estadísticas",
"booking_status": "Estado de la reserva",
"unsupported_file_type": "Los archivos {{type}} no se pueden subir como imágenes",
"only_image_files_allowed": "Solo se permiten archivos de imagen",
"failed_to_validate_image_file": "Error al validar el archivo de imagen",
"invalid_image_file_format": "Formato de archivo de imagen no válido",
"svg_contains_dangerous_content": "El SVG contiene contenido potencialmente peligroso",
"unrecognized_image_format": "Formato de imagen no reconocido o archivo no válido",
"invalid_base64_format": "Formato base64 no válido",
"empty_image_data": "Datos de imagen vacíos",
"visit": "Visitar",
"location_custom_label_input_label": "Etiqueta personalizada en la página de reservas",
"meeting_link": "Enlace de la reunión",
@@ -3605,5 +3606,9 @@
"search_variables": "Buscar variables",
"no_variables_found": "No se encontraron variables",
"api_key_name_too_long": "El nombre no debe exceder los {{max}} caracteres",
"auto_charge_for_last_minute_cancellation": "Cobrar automáticamente la tarifa por no presentarse en caso de cancelaciones de último minuto",
"before_scheduled_start_time": "Antes de la hora de inicio programada",
"cancel_booking_acknowledge_no_show_fee": "Reconozco que al cancelar la reserva dentro de los {{timeValue}} {{timeUnit}} previos a la hora de inicio, se me cobrará la tarifa por no presentarme de {{amount, currency}}",
"contact_organizer": "Si tienes alguna pregunta, por favor contacta al organizador.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Manustad",
"impersonation": "Esinemine kellegi teisena",
"impersonation_description": "Kasutaja kellegi teisena esinemise haldamise seaded",
"recent_impersonations": "Hiljutised esindamised",
"quick_impersonate": "Kiir-esindamine",
"no_recent_impersonations": "Hiljutisi esindamisi pole",
"users": "Kasutajad",
"user": "Kasutaja",
"profile_description": "Hallake oma {{appName}} profiili seadeid",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Selle töövoo testimisel pidage meeles, et e-kirju ja SMS-e saab ajastada ainult vähemalt 1 tund ette",
"insights_no_data_found_for_filter": "Valitud filtri või valitud kuupäevade kohta andmeid ei leitud.",
"acknowledge_booking_no_show_fee": "Ma tean, et kui ma sellel üritusel ei osale, rakendatakse minu kaardile {{summa, valuuta}} mitteilmumise tasu.",
"days": "päevad",
"card_details": "Kaardi andmed",
"something_went_wrong_on_our_end": "Midagi läks meie poolel valesti. Võtke ühendust meie tugitiimiga ja me lahendame selle kohe teie jaoks.",
"please_provide_following_text_to_suppport": "Teid paremaks abistamiseks esitage tugiteenusega ühenduse võtmisel järgmine tekst",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Broneeringud",
"pbac_resource_insights": "Teadmised",
"pbac_resource_role": "Rollid",
"pbac_resource_routing_form": "Marsruutimisvormid",
"pbac_resource_workflow": "Töövood",
"pbac_action_all": "Kõik toimingud",
"pbac_action_create": "Loo",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Kõik toimingud broneeringutega",
"pbac_desc_view_team_insights": "Vaata meeskonna ülevaateid",
"pbac_desc_manage_team_insights": "Halda meeskonna ülevaateid",
"pbac_desc_create_routing_forms": "Loo marsruutimisvorme",
"pbac_desc_view_routing_forms": "Vaata marsruutimisvorme",
"pbac_desc_update_routing_forms": "Uuenda marsruutimisvorme",
"pbac_desc_delete_routing_forms": "Kustuta marsruutimisvorme",
"read_permission_auto_enabled_tooltip": "Lugemisõigus lubatakse automaatselt ressursi loomisel, uuendamisel või kustutamisel",
"choose_restriction_schedule": "Lisa piirangute ajakava",
"restriction_schedule_description": "Piira hostide saadavust, et kuvada ainult ajavahemikke, mis jäävad määratud ajakava sisse.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metaandmed",
"stats": "Statistika",
"booking_status": "Broneeringu staatus",
"unsupported_file_type": "{{type}} faile ei saa üles laadida piltidena",
"only_image_files_allowed": "Lubatud on ainult pildifailid",
"failed_to_validate_image_file": "Pildifaili valideerimine ebaõnnestus",
"invalid_image_file_format": "Vigane pildifaili formaat",
"svg_contains_dangerous_content": "SVG sisaldab potentsiaalselt ohtlikku sisu",
"unrecognized_image_format": "Tundmatu pildiformaat või vigane fail",
"invalid_base64_format": "Vigane base64 formaat",
"empty_image_data": "Tühi pildifaili andmestik",
"visit": "Külastus",
"location_custom_label_input_label": "Kohandatud silt broneerimislehel",
"meeting_link": "Koosoleku link",
@@ -3605,5 +3606,9 @@
"search_variables": "Otsi muutujaid",
"no_variables_found": "Muutujaid ei leitud",
"api_key_name_too_long": "Nimi ei tohi ületada {{max}} tähemärki",
"auto_charge_for_last_minute_cancellation": "Viimase hetke tühistamiste eest puudumise tasu automaatne arvestamine",
"before_scheduled_start_time": "Enne planeeritud algusaega",
"cancel_booking_acknowledge_no_show_fee": "Mõistan, et tühistades broneeringu {{timeValue}} {{timeUnit}} jooksul enne algusaega, võetakse minult puudumise tasu summas {{amount, currency}}",
"contact_organizer": "Kui teil on küsimusi, võtke palun ühendust korraldajaga.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Txertaketak",
"impersonation": "Ordezkatzea",
"impersonation_description": "Erabiltzaile ordezkatzearen ezarpenak kudeatu",
"recent_impersonations": "Azken nortasun-ordezkatzeak",
"quick_impersonate": "Nortasun-ordezkatze azkarra",
"no_recent_impersonations": "Ez dago azken nortasun-ordezkatzerik",
"users": "Erabiltzaileak",
"user": "Erabiltzailea",
"profile_description": "Kudeatu zure {{appName}} profilaren ezarpenak",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Lan-fluxu hau probatzean, kontuan izan e-mailak eta SMSak gutxienez ordubete lehenago soilik programa daitezkeela",
"insights_no_data_found_for_filter": "Ez da daturik aurkitu hautatutako iragazkirako edo hautatutako datetan.",
"acknowledge_booking_no_show_fee": "Onartzen dut ekitaldi honetara ez joatekotan nire txartelean {{amount, currency}}-ko ez agertzeagatiko tarifa kobratuko zaidala.",
"days": "egun",
"card_details": "Txartelaren xehetasunak",
"something_went_wrong_on_our_end": "Zerbait gaizki joan da gure aldetik. Jarri harremanetan gure laguntza-taldearekin, eta berehala konponduko dizugu.",
"please_provide_following_text_to_suppport": "Mesedez, eman ondorengo testua laguntzarekin harremanetan jartzerakoan hobeto lagundu ahal izateko",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Erreserbak",
"pbac_resource_insights": "Ikuspegiak",
"pbac_resource_role": "Eginkizunak",
"pbac_resource_routing_form": "Bideratze-formularioak",
"pbac_resource_workflow": "Lan-fluxuak",
"pbac_action_all": "Ekintza guztiak",
"pbac_action_create": "Sortu",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Erreserbetan ekintza guztiak",
"pbac_desc_view_team_insights": "Taldearen estatistikak ikusi",
"pbac_desc_manage_team_insights": "Taldearen estatistikak kudeatu",
"pbac_desc_create_routing_forms": "Bideratze-formularioak sortu",
"pbac_desc_view_routing_forms": "Bideratze-formularioak ikusi",
"pbac_desc_update_routing_forms": "Bideratze-formularioak eguneratu",
"pbac_desc_delete_routing_forms": "Bideratze-formularioak ezabatu",
"read_permission_auto_enabled_tooltip": "Irakurtzeko baimena automatikoki gaituta dago baliabide bat sortu, eguneratu edo ezabatzean",
"choose_restriction_schedule": "Gehitu murrizketa-ordutegia",
"restriction_schedule_description": "Ostalariaren erabilgarritasuna mugatu zehaztutako ordutegi baten barruan dauden txandak soilik erakusteko.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadatuak",
"stats": "Estatistikak",
"booking_status": "Erreserba egoera",
"unsupported_file_type": "{{type}} fitxategiak ezin dira irudi gisa kargatu",
"only_image_files_allowed": "Irudi fitxategiak bakarrik onartzen dira",
"failed_to_validate_image_file": "Ezin izan da irudi fitxategia balioztatu",
"invalid_image_file_format": "Irudi fitxategiaren formatu baliogabea",
"svg_contains_dangerous_content": "SVG-ak potentzialki arriskutsua den edukia du",
"unrecognized_image_format": "Ezagutzen ez den irudi formatua edo fitxategi baliogabea",
"invalid_base64_format": "Base64 formatu baliogabea",
"empty_image_data": "Irudi datu hutsak",
"visit": "Bisitatu",
"location_custom_label_input_label": "Etiketa pertsonalizatua erreserba orrian",
"meeting_link": "Bilera esteka",
@@ -3605,5 +3606,9 @@
"search_variables": "Bilatu aldagaiak",
"no_variables_found": "Ez da aldagairik aurkitu",
"api_key_name_too_long": "Izenak ezin ditu {{max}} karaktere baino gehiago izan",
"auto_charge_for_last_minute_cancellation": "Azken minutuko ezeztapenengatik ez agertzeko tasa automatikoki kobratu",
"before_scheduled_start_time": "Programatutako hasiera ordua baino lehen",
"cancel_booking_acknowledge_no_show_fee": "Onartzen dut hasiera ordua baino {{timeValue}} {{timeUnit}} lehenago erreserba ezeztatuz gero, {{amount, currency}}-ko ez agertzeko tasa kobratuko zaidala",
"contact_organizer": "Galderarik baduzu, jarri harremanetan antolatzailearekin.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Gehitu zure kate berriak honen gainean ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Upotukset",
"impersonation": "Käyttäjänä esiintyminen",
"impersonation_description": "Käyttäjänä esiintymisen hallinta-asetukset",
"recent_impersonations": "Viimeaikaiset esiintymiset",
"quick_impersonate": "Nopea esiintyminen",
"no_recent_impersonations": "Ei viimeaikaisia esiintymisiä",
"users": "Käyttäjät",
"user": "Käyttäjä",
"profile_description": "Hallinnoi {{appName}}-profiilisi asetuksia",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Huomioi tätä työnkulkua testatessasi, että sähköpostit ja tekstiviestit voidaan ajastaa vain vähintään 1 tunnin päähän",
"insights_no_data_found_for_filter": "Valituilla suodattimilla tai päivämäärillä ei löytynyt tietoja.",
"acknowledge_booking_no_show_fee": "Hyväksyn, että mikäli en saavu paikalle, kortiltani veloitetaan {{amount, currency}} saapumattajättämismaksu.",
"days": "päivää",
"card_details": "Kortin tiedot",
"something_went_wrong_on_our_end": "Jotain meni vikaan meidän päässämme. Ota yhteyttä asiakaspalveluumme, ja korjaamme ongelman sinulle välittömästi.",
"please_provide_following_text_to_suppport": "Anna seuraava teksti ottaessasi yhteyttä tukeen, jotta voimme auttaa sinua paremmin",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Varaukset",
"pbac_resource_insights": "Analytiikka",
"pbac_resource_role": "Roolit",
"pbac_resource_routing_form": "Reitityskaavakkeet",
"pbac_resource_workflow": "Työnkulut",
"pbac_action_all": "Kaikki toiminnot",
"pbac_action_create": "Luo",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Kaikki varauksiin liittyvät toiminnot",
"pbac_desc_view_team_insights": "Näytä tiimin analytiikka",
"pbac_desc_manage_team_insights": "Hallinnoi tiimin analytiikkaa",
"pbac_desc_create_routing_forms": "Luo reitityskaavakkeita",
"pbac_desc_view_routing_forms": "Tarkastele reitityskaavakkeita",
"pbac_desc_update_routing_forms": "Päivitä reitityskaavakkeita",
"pbac_desc_delete_routing_forms": "Poista reitityskaavakkeita",
"read_permission_auto_enabled_tooltip": "Lukuoikeus otetaan automaattisesti käyttöön, kun luot, päivität tai poistat resurssin",
"choose_restriction_schedule": "Lisää rajoitusaikataulu",
"restriction_schedule_description": "Rajoita isäntien saatavuutta näyttämään vain aikavälit, jotka osuvat määritellyn aikataulun sisälle.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metatiedot",
"stats": "Tilastot",
"booking_status": "Varauksen tila",
"unsupported_file_type": "{{type}}-tiedostoja ei voi ladata kuvina",
"only_image_files_allowed": "Vain kuvatiedostot ovat sallittuja",
"failed_to_validate_image_file": "Kuvatiedoston vahvistaminen epäonnistui",
"invalid_image_file_format": "Virheellinen kuvatiedostomuoto",
"svg_contains_dangerous_content": "SVG sisältää mahdollisesti vaarallista sisältöä",
"unrecognized_image_format": "Tunnistamaton kuvamuoto tai virheellinen tiedosto",
"invalid_base64_format": "Virheellinen base64-muoto",
"empty_image_data": "Tyhjä kuvadata",
"visit": "Käynti",
"location_custom_label_input_label": "Mukautettu nimike varaussivulla",
"meeting_link": "Tapaamislinkki",
@@ -3605,5 +3606,9 @@
"search_variables": "Etsi muuttujia",
"no_variables_found": "Muuttujia ei löytynyt",
"api_key_name_too_long": "Nimi ei saa ylittää {{max}} merkkiä",
"auto_charge_for_last_minute_cancellation": "Veloita automaattisesti peruutusmaksu viime hetken peruutuksista",
"before_scheduled_start_time": "Ennen sovittua aloitusaikaa",
"cancel_booking_acknowledge_no_show_fee": "Ymmärrän, että peruuttaessani varauksen {{timeValue}} {{timeUnit}} sisällä aloitusajasta minulta veloitetaan peruutusmaksu {{amount, currency}}",
"contact_organizer": "Jos sinulla on kysyttävää, ota yhteyttä järjestäjään.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Lisää uudet merkkijonot tämän yläpuolelle ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Intégrations",
"impersonation": "Emprunt d'identité",
"impersonation_description": "Gérez les paramètres d'emprunt d'identité.",
"recent_impersonations": "Usurpations récentes",
"quick_impersonate": "Usurpation rapide",
"no_recent_impersonations": "Aucune usurpation récente",
"users": "Utilisateurs",
"user": "Utilisateur",
"profile_description": "Gérez les paramètres de votre profil {{appName}}.",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Lors du test de ce workflow, sachez que les e-mails et les SMS ne peuvent être programmés qu'au moins 1 heure à l'avance",
"insights_no_data_found_for_filter": "Aucune donnée trouvée pour le filtre ou les dates sélectionnés.",
"acknowledge_booking_no_show_fee": "Je reconnais que si je ne participe pas à cet événement, des frais d'absence de {{amount, currency}} seront appliqués à ma carte.",
"days": "jours",
"card_details": "Détails de la carte",
"something_went_wrong_on_our_end": "Un problème est survenu de notre côté. Contactez notre équipe d'assistance, et nous le résoudrons immédiatement pour vous.",
"please_provide_following_text_to_suppport": "Veuillez fournir le texte suivant lorsque vous contactez le support pour mieux vous aider",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Réservations",
"pbac_resource_insights": "Statistiques",
"pbac_resource_role": "Rôles",
"pbac_resource_routing_form": "Formulaires de routage",
"pbac_resource_workflow": "Workflows",
"pbac_action_all": "Toutes les actions",
"pbac_action_create": "Créer",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Toutes les actions sur les réservations",
"pbac_desc_view_team_insights": "Voir les analyses de l'équipe",
"pbac_desc_manage_team_insights": "Gérer les analyses de l'équipe",
"pbac_desc_create_routing_forms": "Créer des formulaires de routage",
"pbac_desc_view_routing_forms": "Voir les formulaires de routage",
"pbac_desc_update_routing_forms": "Mettre à jour les formulaires de routage",
"pbac_desc_delete_routing_forms": "Supprimer les formulaires de routage",
"read_permission_auto_enabled_tooltip": "L'autorisation de lecture est automatiquement activée lors de la création, la mise à jour ou la suppression d'une ressource",
"choose_restriction_schedule": "Ajouter un calendrier de restriction",
"restriction_schedule_description": "Limiter la disponibilité des hôtes pour n'afficher que les créneaux qui correspondent à un horaire spécifié.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Métadonnées",
"stats": "Statistiques",
"booking_status": "Statut de la réservation",
"unsupported_file_type": "Les fichiers {{type}} ne peuvent pas être téléchargés comme images",
"only_image_files_allowed": "Seuls les fichiers image sont autorisés",
"failed_to_validate_image_file": "Échec de la validation du fichier image",
"invalid_image_file_format": "Format de fichier image non valide",
"svg_contains_dangerous_content": "Le SVG contient du contenu potentiellement dangereux",
"unrecognized_image_format": "Format d'image non reconnu ou fichier invalide",
"invalid_base64_format": "Format base64 non valide",
"empty_image_data": "Données d'image vides",
"visit": "Visiter",
"location_custom_label_input_label": "Étiquette personnalisée sur la page de réservation",
"meeting_link": "Lien de réunion",
@@ -3605,5 +3606,9 @@
"search_variables": "Rechercher des variables",
"no_variables_found": "Aucune variable trouvée",
"api_key_name_too_long": "Le nom ne doit pas dépasser {{max}} caractères",
"auto_charge_for_last_minute_cancellation": "Facturer automatiquement des frais de non-présentation pour les annulations de dernière minute",
"before_scheduled_start_time": "Avant l'heure de début prévue",
"cancel_booking_acknowledge_no_show_fee": "Je reconnais qu'en annulant la réservation dans les {{timeValue}} {{timeUnit}} précédant l'heure de début, des frais de non-présentation de {{amount, currency}} me seront facturés",
"contact_organizer": "Si vous avez des questions, veuillez contacter l'organisateur.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "הטמעות",
"impersonation": "התחזות",
"impersonation_description": "הגדרות לניהול התחזות למשתמשים",
"recent_impersonations": "התחזויות אחרונות",
"quick_impersonate": "התחזות מהירה",
"no_recent_impersonations": "אין התחזויות אחרונות",
"users": "משתמשים",
"user": "משתמש",
"profile_description": "ניהול הגדרות עבור פרופיל {{appName}} שלך",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "במהלך בדיקת תהליך העבודה הזה, קח/י בחשבון שניתן לתזמן הודעות דוא\"ל ו-SMS לפחות שעה אחת מראש",
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
"days": "ימים",
"card_details": "פרטי כרטיס",
"something_went_wrong_on_our_end": "משהו השתבש בצד שלנו. צור/י קשר עם צוות התמיכה שלנו, ואנו נתקן זאת עבורך בהקדם.",
"please_provide_following_text_to_suppport": "כשפונים לתמיכה, יש לספק את הטקסט הבא כדי שנוכל לסייע לך בצורה יעילה יותר",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "הזמנות",
"pbac_resource_insights": "תובנות",
"pbac_resource_role": "תפקידים",
"pbac_resource_routing_form": "טפסי ניתוב",
"pbac_resource_workflow": "תהליכים",
"pbac_action_all": "כל הפעולות",
"pbac_action_create": "יצירה",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "כל הפעולות על הזמנות",
"pbac_desc_view_team_insights": "צפייה בתובנות הצוות",
"pbac_desc_manage_team_insights": "ניהול תובנות הצוות",
"pbac_desc_create_routing_forms": "יצירת טפסי ניתוב",
"pbac_desc_view_routing_forms": "צפייה בטפסי ניתוב",
"pbac_desc_update_routing_forms": "עדכון טפסי ניתוב",
"pbac_desc_delete_routing_forms": "מחיקת טפסי ניתוב",
"read_permission_auto_enabled_tooltip": "הרשאת קריאה מופעלת אוטומטית בעת יצירה, עדכון או מחיקה של משאב",
"choose_restriction_schedule": "הוספת לוח זמנים מגביל",
"restriction_schedule_description": "הגבל את הזמינות של המארחים כך שיוצגו רק משבצות זמן שנופלות בתוך לוח זמנים מוגדר.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "מטא-נתונים",
"stats": "סטטיסטיקות",
"booking_status": "סטטוס הזמנה",
"unsupported_file_type": "לא ניתן להעלות קבצי {{type}} כתמונות",
"only_image_files_allowed": "מותר להעלות קבצי תמונה בלבד",
"failed_to_validate_image_file": "אימות קובץ התמונה נכשל",
"invalid_image_file_format": "פורמט קובץ תמונה לא חוקי",
"svg_contains_dangerous_content": "קובץ SVG מכיל תוכן שעלול להיות מסוכן",
"unrecognized_image_format": "פורמט תמונה לא מזוהה או קובץ לא חוקי",
"invalid_base64_format": "פורמט base64 לא חוקי",
"empty_image_data": "נתוני תמונה ריקים",
"visit": "ביקור",
"location_custom_label_input_label": "תווית מותאמת אישית בדף ההזמנה",
"meeting_link": "קישור לפגישה",
@@ -3605,5 +3606,9 @@
"search_variables": "חפש משתנים",
"no_variables_found": "לא נמצאו משתנים",
"api_key_name_too_long": "השם לא יכול לעלות על {{max}} תווים",
"auto_charge_for_last_minute_cancellation": "חיוב אוטומטי של דמי אי-הופעה עבור ביטולים ברגע האחרון",
"before_scheduled_start_time": "לפני זמן ההתחלה המתוכנן",
"cancel_booking_acknowledge_no_show_fee": "אני מאשר/ת שבביטול ההזמנה תוך {{timeValue}} {{timeUnit}} מזמן ההתחלה אחויב בדמי אי-הופעה בסך {{amount, currency}}",
"contact_organizer": "אם יש לך שאלות כלשהן, אנא צור/י קשר עם המארגן.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Beágyazás",
"impersonation": "Megszemélyesítés",
"impersonation_description": "Beállítások a felhasználói megszemélyesítés kezeléséhez",
"recent_impersonations": "Legutóbbi megszemélyesítések",
"quick_impersonate": "Gyors megszemélyesítés",
"no_recent_impersonations": "Nincsenek legutóbbi megszemélyesítések",
"users": "Felhasználók",
"user": "Felhasználó",
"profile_description": "Kezelje {{appName}}-profiljának beállításait",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "A munkafolyamat tesztelésekor ügyeljen arra, hogy az e-mailek és SMS-ek legalább 1 órával későbbre ütemezhetők",
"insights_no_data_found_for_filter": "Nem található adat a kiválasztott szűrőhöz vagy a kiválasztott dátumokhoz.",
"acknowledge_booking_no_show_fee": "Tudomásul veszem, hogy ha nem veszek részt ezen az eseményen, {{amount, currency}} meg nem jelenési díj kerül felszámításra.",
"days": "nap",
"card_details": "Kártya adatai",
"something_went_wrong_on_our_end": "Valami hiba történt a mi oldalunkon. Vedd fel a kapcsolatot az ügyfélszolgálatunkkal, és azonnal kijavítjuk neked.",
"please_provide_following_text_to_suppport": "Kérjük, adja meg a következő szöveget, amikor kapcsolatba lép az ügyfélszolgálattal, hogy jobban segíthessünk",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Foglalások",
"pbac_resource_insights": "Betekintések",
"pbac_resource_role": "Szerepkörök",
"pbac_resource_routing_form": "Irányító űrlapok",
"pbac_resource_workflow": "Munkafolyamatok",
"pbac_action_all": "Minden művelet",
"pbac_action_create": "Létrehozás",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Minden művelet a foglalásokon",
"pbac_desc_view_team_insights": "Csapat elemzéseinek megtekintése",
"pbac_desc_manage_team_insights": "Csapat elemzéseinek kezelése",
"pbac_desc_create_routing_forms": "Irányító űrlapok létrehozása",
"pbac_desc_view_routing_forms": "Irányító űrlapok megtekintése",
"pbac_desc_update_routing_forms": "Irányító űrlapok frissítése",
"pbac_desc_delete_routing_forms": "Irányító űrlapok törlése",
"read_permission_auto_enabled_tooltip": "Az olvasási jogosultság automatikusan engedélyezve van erőforrás létrehozásakor, frissítésekor vagy törlésekor",
"choose_restriction_schedule": "Korlátozási ütemterv hozzáadása",
"restriction_schedule_description": "Korlátozza a házigazdák elérhetőségét, hogy csak a megadott időbeosztáson belüli időpontokat jelenítse meg.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metaadatok",
"stats": "Statisztikák",
"booking_status": "Foglalás állapota",
"unsupported_file_type": "{{type}} fájlokat nem lehet képként feltölteni",
"only_image_files_allowed": "Csak képfájlok engedélyezettek",
"failed_to_validate_image_file": "Nem sikerült ellenőrizni a képfájlt",
"invalid_image_file_format": "Érvénytelen képfájl formátum",
"svg_contains_dangerous_content": "Az SVG potenciálisan veszélyes tartalmat tartalmaz",
"unrecognized_image_format": "Felismerhetetlen képformátum vagy érvénytelen fájl",
"invalid_base64_format": "Érvénytelen base64 formátum",
"empty_image_data": "Üres képadat",
"visit": "Látogatás",
"location_custom_label_input_label": "Egyéni címke a foglalási oldalon",
"meeting_link": "Találkozó link",
@@ -3605,5 +3606,9 @@
"search_variables": "Változók keresése",
"no_variables_found": "Nem találhatók változók",
"api_key_name_too_long": "A név nem haladhatja meg a {{max}} karaktert",
"auto_charge_for_last_minute_cancellation": "Automatikus lemondási díj felszámolása az utolsó pillanatban történő lemondásokért",
"before_scheduled_start_time": "A tervezett kezdési idő előtt",
"cancel_booking_acknowledge_no_show_fee": "Tudomásul veszem, hogy a foglalás lemondásával a kezdési időpont előtti {{timeValue}} {{timeUnit}}on belül felszámításra kerül a meg nem jelenési díj: {{amount, currency}}",
"contact_organizer": "Ha bármilyen kérdése van, kérjük, vegye fel a kapcsolatot a szervezővel.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adja hozzá az új karakterláncokat fent ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Integrazioni",
"impersonation": "Accesso con altra identità",
"impersonation_description": "Impostazioni per gestire gli accessi con altra identità",
"recent_impersonations": "Impersonificazioni recenti",
"quick_impersonate": "Impersonificazione rapida",
"no_recent_impersonations": "Nessuna impersonificazione recente",
"users": "Utenti",
"user": "Utente",
"profile_description": "Gestisci le impostazioni del tuo profilo {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Durante il test di questo flusso di lavoro, tieni presente che le e-mail e i messaggi SMS devono essere programmati con almeno 1 ora di anticipo",
"insights_no_data_found_for_filter": "Nessun dato trovato per il filtro selezionato o le date selezionate.",
"acknowledge_booking_no_show_fee": "Confermo che in caso di mia mancata partecipazione a questo evento, una penale per mancata presentazione di {{amount, currency}} verrà addebitata sulla mia carta.",
"days": "giorni",
"card_details": "Dati della carta",
"something_went_wrong_on_our_end": "Qualcosa è andato storto da parte nostra. Contatta il nostro team di supporto e risolveremo il problema immediatamente per te.",
"please_provide_following_text_to_suppport": "Fornisci il seguente testo quando contatterai il team di supporto per permeterci di aiutarti meglio",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Prenotazioni",
"pbac_resource_insights": "Approfondimenti",
"pbac_resource_role": "Ruoli",
"pbac_resource_routing_form": "Moduli di instradamento",
"pbac_resource_workflow": "Flussi di lavoro",
"pbac_action_all": "Tutte le azioni",
"pbac_action_create": "Crea",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Tutte le azioni sulle prenotazioni",
"pbac_desc_view_team_insights": "Visualizza insight del team",
"pbac_desc_manage_team_insights": "Gestisci insight del team",
"pbac_desc_create_routing_forms": "Crea moduli di instradamento",
"pbac_desc_view_routing_forms": "Visualizza moduli di instradamento",
"pbac_desc_update_routing_forms": "Aggiorna moduli di instradamento",
"pbac_desc_delete_routing_forms": "Elimina moduli di instradamento",
"read_permission_auto_enabled_tooltip": "Il permesso di lettura viene abilitato automaticamente quando si crea, aggiorna o elimina una risorsa",
"choose_restriction_schedule": "Aggiungi programma di restrizione",
"restriction_schedule_description": "Limita la disponibilità degli host per mostrare solo gli slot che rientrano in un programma specificato.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadati",
"stats": "Statistiche",
"booking_status": "Stato della prenotazione",
"unsupported_file_type": "I file {{type}} non possono essere caricati come immagini",
"only_image_files_allowed": "Sono consentiti solo file di immagine",
"failed_to_validate_image_file": "Impossibile convalidare il file immagine",
"invalid_image_file_format": "Formato del file immagine non valido",
"svg_contains_dangerous_content": "L'SVG contiene contenuti potenzialmente pericolosi",
"unrecognized_image_format": "Formato immagine non riconosciuto o file non valido",
"invalid_base64_format": "Formato base64 non valido",
"empty_image_data": "Dati immagine vuoti",
"visit": "Visita",
"location_custom_label_input_label": "Etichetta personalizzata sulla pagina di prenotazione",
"meeting_link": "Link della riunione",
@@ -3605,5 +3606,9 @@
"search_variables": "Cerca variabili",
"no_variables_found": "Nessuna variabile trovata",
"api_key_name_too_long": "Il nome non deve superare i {{max}} caratteri",
"auto_charge_for_last_minute_cancellation": "Addebita automaticamente la penale per cancellazioni dell'ultimo minuto",
"before_scheduled_start_time": "Prima dell'orario di inizio programmato",
"cancel_booking_acknowledge_no_show_fee": "Riconosco che cancellando la prenotazione entro {{timeValue}} {{timeUnit}} dall'orario di inizio mi verrà addebitata la penale di mancata presenza di {{amount, currency}}",
"contact_organizer": "Per qualsiasi domanda, contatta l'organizzatore.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "埋め込み",
"impersonation": "なりすまし",
"impersonation_description": "ユーザーのなりすましを管理するための設定です",
"recent_impersonations": "最近のなりすまし",
"quick_impersonate": "クイックなりすまし",
"no_recent_impersonations": "最近のなりすましはありません",
"users": "ユーザー",
"user": "ユーザー",
"profile_description": "{{appName}} プロフィールの設定を管理します",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "このワークフローをテストする場合、少なくとも 1 時間前でなければメールと SMS のスケジュール設定ができないことにご注意ください",
"insights_no_data_found_for_filter": "選択したフィルターまたは日付のデータが見つかりませんでした。",
"acknowledge_booking_no_show_fee": "このイベントに出席しなかった場合、不参加費用として {{amount, currency}} が私のカードに請求されることに同意します。",
"days": "日",
"card_details": "カードの詳細",
"something_went_wrong_on_our_end": "当社側で問題が発生しました。サポートチームにお問い合わせください。すぐに修正いたします。",
"please_provide_following_text_to_suppport": "サポートにお問い合わせいただく際に次のテキストをご提供いただければ、解決に役立ちます。",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "予約",
"pbac_resource_insights": "インサイト",
"pbac_resource_role": "ロール",
"pbac_resource_routing_form": "ルーティングフォーム",
"pbac_resource_workflow": "ワークフロー",
"pbac_action_all": "すべてのアクション",
"pbac_action_create": "作成",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "予約に関するすべてのアクション",
"pbac_desc_view_team_insights": "チームインサイトの表示",
"pbac_desc_manage_team_insights": "チームインサイトの管理",
"pbac_desc_create_routing_forms": "ルーティングフォームを作成する",
"pbac_desc_view_routing_forms": "ルーティングフォームを表示する",
"pbac_desc_update_routing_forms": "ルーティングフォームを更新する",
"pbac_desc_delete_routing_forms": "ルーティングフォームを削除する",
"read_permission_auto_enabled_tooltip": "リソースの作成、更新、削除時に読み取り権限は自動的に有効になります",
"choose_restriction_schedule": "制限スケジュールを追加",
"restriction_schedule_description": "ホストの利用可能時間を指定されたスケジュール内の枠のみに制限します。",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "メタデータ",
"stats": "統計",
"booking_status": "予約状況",
"unsupported_file_type": "{{type}}ファイルは画像としてアップロードできません",
"only_image_files_allowed": "画像ファイルのみが許可されています",
"failed_to_validate_image_file": "画像ファイルの検証に失敗しました",
"invalid_image_file_format": "無効な画像ファイル形式です",
"svg_contains_dangerous_content": "SVGに潜在的に危険なコンテンツが含まれています",
"unrecognized_image_format": "認識できない画像形式または無効なファイルです",
"invalid_base64_format": "無効なbase64形式です",
"empty_image_data": "画像データが空です",
"visit": "訪問",
"location_custom_label_input_label": "予約ページのカスタムラベル",
"meeting_link": "ミーティングリンク",
@@ -3605,5 +3606,9 @@
"search_variables": "変数を検索",
"no_variables_found": "変数が見つかりません",
"api_key_name_too_long": "名前は{{max}}文字を超えてはいけません",
"auto_charge_for_last_minute_cancellation": "直前のキャンセルに対して自動的に無断キャンセル料を請求する",
"before_scheduled_start_time": "予定開始時刻より前",
"cancel_booking_acknowledge_no_show_fee": "開始時刻の{{timeValue}}{{timeUnit}}以内にキャンセルした場合、{{amount, currency}}の無断キャンセル料が請求されることを承諾します",
"contact_organizer": "ご質問がある場合は、主催者にお問い合わせください。",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Embeds",
"impersonation": "ការបន្លំជាអ្នកដទៃ",
"impersonation_description": "ការកំណត់ដើម្បីគ្រប់គ្រងការបន្លំជាអ្នកដទៃ",
"recent_impersonations": "ការក្លែងបន្លំថ្មីៗ",
"quick_impersonate": "ក្លែងបន្លំរហ័ស",
"no_recent_impersonations": "គ្មានការក្លែងបន្លំថ្មីៗទេ",
"users": "អ្នកប្រើប្រាស់",
"user": "អ្នកប្រើប្រាស់",
"profile_description": "គ្រប់គ្រងការកំណត់សម្រាប់ប្រវត្តិរូប {{appName}} របស់អ្នក",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "នៅពេលសាកល្បងដំណើរការនេះ សូមចំណាំថា អ៊ីមែល និងសារ SMS អាចត្រូវបានកំណត់ពេលយ៉ាងហោចណាស់ 1 ម៉ោងមុន",
"insights_no_data_found_for_filter": "មិនមានទិន្នន័យសម្រាប់តម្រង ឬកាលបរិច្ឆេទដែលបានជ្រើស។",
"acknowledge_booking_no_show_fee": "ខ្ញុំយល់ព្រមថា ប្រសិនបើខ្ញុំមិនចូលរួមព្រឹត្តិការណ៍នេះ នោះថ្លៃសេវាកម្មមិនមកចូលរួមចំនួន {{amount, currency}} នឹងត្រូវបានគិតលើកាតរបស់ខ្ញុំ។",
"days": "ថ្ងៃ",
"card_details": "ព័ត៌មានកាត",
"something_went_wrong_on_our_end": "មានបញ្ហាខាងយើង។ ទាក់ទងក្រុមគាំទ្ររបស់យើង ហើយយើងនឹងជួសជុលវាភ្លាមៗសម្រាប់អ្នក។",
"please_provide_following_text_to_suppport": "សូមផ្តល់អត្ថបទខាងក្រោមនៅពេលទាក់ទងគាំទ្រ ដើម្បីជួយអ្នកបានល្អប្រសើរ",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "ការកក់",
"pbac_resource_insights": "ព័ត៌មានជ្រៅ",
"pbac_resource_role": "តួនាទី",
"pbac_resource_routing_form": "ទម្រង់បញ្ជូន",
"pbac_resource_workflow": "លំហូរការងារ",
"pbac_action_all": "សកម្មភាពទាំងអស់",
"pbac_action_create": "បង្កើត",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "សកម្មភាពទាំងអស់លើការកក់",
"pbac_desc_view_team_insights": "មើលការយល់ដឹងរបស់ក្រុម",
"pbac_desc_manage_team_insights": "គ្រប់គ្រងការយល់ដឹងរបស់ក្រុម",
"pbac_desc_create_routing_forms": "បង្កើតទម្រង់បញ្ជូន",
"pbac_desc_view_routing_forms": "មើលទម្រង់បញ្ជូន",
"pbac_desc_update_routing_forms": "ធ្វើបច្ចុប្បន្នភាពទម្រង់បញ្ជូន",
"pbac_desc_delete_routing_forms": "លុបទម្រង់បញ្ជូន",
"read_permission_auto_enabled_tooltip": "សិទ្ធិអានត្រូវបានបើកដោយស្វ័យប្រវត្តិនៅពេលបង្កើត ធ្វើបច្ចុប្បន្នភាព ឬលុបធនធាន",
"choose_restriction_schedule": "បន្ថែមកាលវិភាគដែនកំណត់",
"restriction_schedule_description": "កំណត់កម្រិតលទ្ធភាពរបស់ម្ចាស់ដើម្បីបង្ហាញតែពេលវេលាដែលស្ថិតក្នុងកាលវិភាគដែលបានបញ្ជាក់។",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "ទិន្នន័យមេតា",
"stats": "ស្ថិតិ",
"booking_status": "ស្ថានភាពនៃការកក់",
"unsupported_file_type": "ឯកសារ {{type}} មិនអាចបញ្ចូលជារូបភាពបានទេ",
"only_image_files_allowed": "អនុញ្ញាតតែឯកសាររូបភាពប៉ុណ្ណោះ",
"failed_to_validate_image_file": "បរាជ័យក្នុងការផ្ទៀងផ្ទាត់ឯកសាររូបភាព",
"invalid_image_file_format": "ទម្រង់ឯកសាររូបភាពមិនត្រឹមត្រូវ",
"svg_contains_dangerous_content": "SVG មានខ្លឹមសារដែលអាចមានគ្រោះថ្នាក់",
"unrecognized_image_format": "ទម្រង់រូបភាពមិនស្គាល់ឬឯកសារមិនត្រឹមត្រូវ",
"invalid_base64_format": "ទម្រង់ base64 មិនត្រឹមត្រូវ",
"empty_image_data": "ទិន្នន័យរូបភាពទទេ",
"visit": "ទស្សនា",
"location_custom_label_input_label": "ស្លាកផ្ទាល់ខ្លួននៅលើទំព័រកក់",
"meeting_link": "តំណភ្ជាប់ការប្រជុំ",
@@ -3605,5 +3606,9 @@
"search_variables": "ស្វែងរកអថេរ",
"no_variables_found": "រកមិនឃើញអថេរទេ",
"api_key_name_too_long": "ឈ្មោះមិនត្រូវលើសពី {{max}} តួអក្សរទេ",
"auto_charge_for_last_minute_cancellation": "គិតថ្លៃសេវាដោយស្វ័យប្រវត្តិសម្រាប់ការលុបចោលនៅនាទីចុងក្រោយ",
"before_scheduled_start_time": "មុនពេលចាប់ផ្តើមដែលបានកំណត់",
"cancel_booking_acknowledge_no_show_fee": "ខ្ញុំទទួលស្គាល់ថាដោយការលុបចោលការកក់ក្នុងរយៈពេល {{timeValue}} {{timeUnit}} មុនពេលចាប់ផ្តើម ខ្ញុំនឹងត្រូវបង់ថ្លៃសេវាមិនបានចូលរួមចំនួន {{amount, currency}}",
"contact_organizer": "ប្រសិនបើអ្នកមានសំណួរណាមួយ សូមទាក់ទងអ្នករៀបចំ។",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ បន្ថែមខ្សែអក្សរថ្មីរបស់អ្នកនៅខាងលើនេះ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "내장",
"impersonation": "가장",
"impersonation_description": "사용자 가장을 관리하기 위한 설정",
"recent_impersonations": "최근 사칭",
"quick_impersonate": "빠른 사칭",
"no_recent_impersonations": "최근 사칭 없음",
"users": "사용자",
"user": "사용자",
"profile_description": "사용자 {{appName}} 프로필 설정 관리",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "이 워크플로를 테스트할 때 이메일 및 SMS는 최소 1시간 전에만 예약할 수 있다는 점에 유의하세요",
"insights_no_data_found_for_filter": "선택한 필터 또는 날짜에 대한 데이터를 찾을 수 없습니다.",
"acknowledge_booking_no_show_fee": "본인은 이 이벤트에 참석하지 않을 경우 {{amount, currency}}에 대한 노쇼 수수료가 내 카드에 부과됨을 인정합니다.",
"days": "일",
"card_details": "카드 세부정보",
"something_went_wrong_on_our_end": "저희 쪽에서 문제가 발생했습니다. 지원 팀에 문의하시면 즉시 해결해 드리겠습니다.",
"please_provide_following_text_to_suppport": "더 나은 도움을 받으려면 지원팀에 문의할 때 다음 텍스트를 제공해 주세요",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "예약",
"pbac_resource_insights": "인사이트",
"pbac_resource_role": "역할",
"pbac_resource_routing_form": "라우팅 양식",
"pbac_resource_workflow": "워크플로",
"pbac_action_all": "모든 작업",
"pbac_action_create": "생성",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "예약에 대한 모든 작업",
"pbac_desc_view_team_insights": "팀 인사이트 보기",
"pbac_desc_manage_team_insights": "팀 인사이트 관리",
"pbac_desc_create_routing_forms": "라우팅 양식 생성",
"pbac_desc_view_routing_forms": "라우팅 양식 보기",
"pbac_desc_update_routing_forms": "라우팅 양식 업데이트",
"pbac_desc_delete_routing_forms": "라우팅 양식 삭제",
"read_permission_auto_enabled_tooltip": "리소스를 생성, 업데이트 또는 삭제할 때 읽기 권한이 자동으로 활성화됩니다",
"choose_restriction_schedule": "제한 일정 추가",
"restriction_schedule_description": "호스트의 가용성을 지정된 일정 내에 있는 슬롯만 표시하도록 제한합니다.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "메타데이터",
"stats": "통계",
"booking_status": "예약 상태",
"unsupported_file_type": "{{type}} 파일은 이미지로 업로드할 수 없습니다",
"only_image_files_allowed": "이미지 파일만 허용됩니다",
"failed_to_validate_image_file": "이미지 파일 검증에 실패했습니다",
"invalid_image_file_format": "잘못된 이미지 파일 형식입니다",
"svg_contains_dangerous_content": "SVG에 잠재적으로 위험한 콘텐츠가 포함되어 있습니다",
"unrecognized_image_format": "인식할 수 없는 이미지 형식 또는 유효하지 않은 파일입니다",
"invalid_base64_format": "유효하지 않은 base64 형식입니다",
"empty_image_data": "이미지 데이터가 비어 있습니다",
"visit": "방문",
"location_custom_label_input_label": "예약 페이지의 커스텀 라벨",
"meeting_link": "회의 링크",
@@ -3605,5 +3606,9 @@
"search_variables": "변수 검색",
"no_variables_found": "변수를 찾을 수 없음",
"api_key_name_too_long": "이름은 {{max}}자를 초과할 수 없습니다",
"auto_charge_for_last_minute_cancellation": "마지막 순간 취소에 대해 노쇼 수수료 자동 청구",
"before_scheduled_start_time": "예약된 시작 시간 전",
"cancel_booking_acknowledge_no_show_fee": "시작 시간으로부터 {{timeValue}} {{timeUnit}} 이내에 예약을 취소할 경우 {{amount, currency}}의 노쇼 수수료가 청구됨을 인정합니다",
"contact_organizer": "질문이 있으시면 주최자에게 문의하세요.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Ingesloten opties",
"impersonation": "Imitatie",
"impersonation_description": "Instellingen voor imitatie van gebruiker beheren",
"recent_impersonations": "Recente identiteitsovernames",
"quick_impersonate": "Snel identiteit overnemen",
"no_recent_impersonations": "Geen recente identiteitsovernames",
"users": "Gebruikers",
"user": "Gebruiker",
"profile_description": "Beheer de instellingen van uw {{appName}}-profiel",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Let er bij het testen van deze werkstroom op dat e-mails en sms minimaal 1 uur van tevoren kunnen gepland moeten worden",
"insights_no_data_found_for_filter": "Geen gegevens gevonden voor het geselecteerde filter of de geselecteerde datums.",
"acknowledge_booking_no_show_fee": "Ik erken dat als ik deze gebeurtenis niet bijwoon, er {{amount, currency}} aan afwezigheidsvergoeding van mijn kaart worden afgeschreven.",
"days": "dagen",
"card_details": "Kaartgegevens",
"something_went_wrong_on_our_end": "Er is iets misgegaan aan onze kant. Neem contact op met ons ondersteuningsteam, en we lossen het meteen voor je op.",
"please_provide_following_text_to_suppport": "Vermeld de volgende tekst wanneer u contact opneemt met ondersteuning, zodat we u beter kunnen helpen",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Afspraken",
"pbac_resource_insights": "Inzichten",
"pbac_resource_role": "Functies",
"pbac_resource_routing_form": "Routeringsformulieren",
"pbac_resource_workflow": "Werkstromen",
"pbac_action_all": "Alle acties",
"pbac_action_create": "Aanmaken",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Alle acties op boekingen",
"pbac_desc_view_team_insights": "Team-inzichten bekijken",
"pbac_desc_manage_team_insights": "Team-inzichten beheren",
"pbac_desc_create_routing_forms": "Routeringsformulieren maken",
"pbac_desc_view_routing_forms": "Routeringsformulieren bekijken",
"pbac_desc_update_routing_forms": "Routeringsformulieren bijwerken",
"pbac_desc_delete_routing_forms": "Routeringsformulieren verwijderen",
"read_permission_auto_enabled_tooltip": "Leesrechten worden automatisch ingeschakeld bij het aanmaken, bijwerken of verwijderen van een resource",
"choose_restriction_schedule": "Beperkingsschema toevoegen",
"restriction_schedule_description": "Beperk beschikbaarheid van hosts om alleen tijdsloten weer te geven die binnen een specifiek schema vallen.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Statistieken",
"booking_status": "Boekingsstatus",
"unsupported_file_type": "{{type}} bestanden kunnen niet als afbeeldingen worden geüpload",
"only_image_files_allowed": "Alleen afbeeldingsbestanden zijn toegestaan",
"failed_to_validate_image_file": "Validatie van afbeeldingsbestand mislukt",
"invalid_image_file_format": "Ongeldig afbeeldingsbestandsformaat",
"svg_contains_dangerous_content": "SVG bevat mogelijk gevaarlijke inhoud",
"unrecognized_image_format": "Niet-herkend afbeeldingsformaat of ongeldig bestand",
"invalid_base64_format": "Ongeldig base64-formaat",
"empty_image_data": "Lege afbeeldingsgegevens",
"visit": "Bezoek",
"location_custom_label_input_label": "Aangepast label op boekingspagina",
"meeting_link": "Vergaderlink",
@@ -3605,5 +3606,9 @@
"search_variables": "Variabelen zoeken",
"no_variables_found": "Geen variabelen gevonden",
"api_key_name_too_long": "Naam mag niet langer zijn dan {{max}} tekens",
"auto_charge_for_last_minute_cancellation": "Automatisch no-show kosten in rekening brengen voor last-minute annuleringen",
"before_scheduled_start_time": "Voor geplande starttijd",
"cancel_booking_acknowledge_no_show_fee": "Ik erken dat door de boeking te annuleren binnen {{timeValue}} {{timeUnit}} voor de starttijd, mij de no-show kosten van {{amount, currency}} in rekening worden gebracht",
"contact_organizer": "Als je vragen hebt, neem dan contact op met de organisator.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Voeg uw nieuwe strings hierboven toe ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Innbygginger",
"impersonation": "Etterligning",
"impersonation_description": "Innstillinger for å administrere etterligning av brukere",
"recent_impersonations": "Nylige utgivelser",
"quick_impersonate": "Rask utgivelse",
"no_recent_impersonations": "Ingen nylige utgivelser",
"users": "Brukere",
"user": "Bruker",
"profile_description": "Administrer innstillinger for {{appName}} profilen din",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Når du tester denne arbeidsflyten, vær oppmerksom på at e-poster og SMS kun kan planlegges minst 1 time i forveien",
"insights_no_data_found_for_filter": "Ingen data funnet for det valgte filteret eller de valgte datoene.",
"acknowledge_booking_no_show_fee": "Jeg erkjenner at hvis jeg ikke deltar på dette arrangementet, vil et gebyr på {{amount, currency}} for manglende oppmøte bli belastet kortet mitt.",
"days": "dager",
"card_details": "Kortdetaljer",
"something_went_wrong_on_our_end": "Noe gikk galt på vår side. Ta kontakt med vårt supportteam, så fikser vi det for deg med en gang.",
"please_provide_following_text_to_suppport": "Vennligst oppgi følgende tekst når du kontakter support for å hjelpe oss med å hjelpe deg bedre",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Bookinger",
"pbac_resource_insights": "Innsikt",
"pbac_resource_role": "Roller",
"pbac_resource_routing_form": "Rutingsskjemaer",
"pbac_resource_workflow": "Arbeidsflyter",
"pbac_action_all": "Alle handlinger",
"pbac_action_create": "Opprett",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Alle handlinger på bookinger",
"pbac_desc_view_team_insights": "Se teaminnsikt",
"pbac_desc_manage_team_insights": "Administrer teaminnsikt",
"pbac_desc_create_routing_forms": "Opprett rutingsskjemaer",
"pbac_desc_view_routing_forms": "Vis rutingsskjemaer",
"pbac_desc_update_routing_forms": "Oppdater rutingsskjemaer",
"pbac_desc_delete_routing_forms": "Slett rutingsskjemaer",
"read_permission_auto_enabled_tooltip": "Lesetillatelse aktiveres automatisk ved opprettelse, oppdatering eller sletting av en ressurs",
"choose_restriction_schedule": "Legg til begrensningsplan",
"restriction_schedule_description": "Begrens tilgjengeligheten til verter for kun å vise tidsluker som faller innenfor en spesifisert timeplan.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Statistikk",
"booking_status": "Bookingstatus",
"unsupported_file_type": "{{type}}-filer kan ikke lastes opp som bilder",
"only_image_files_allowed": "Kun bildefiler er tillatt",
"failed_to_validate_image_file": "Kunne ikke validere bildefilen",
"invalid_image_file_format": "Ugyldig bildefilformat",
"svg_contains_dangerous_content": "SVG inneholder potensielt farlig innhold",
"unrecognized_image_format": "Ukjent bildeformat eller ugyldig fil",
"invalid_base64_format": "Ugyldig base64-format",
"empty_image_data": "Tomme bildedata",
"visit": "Besøk",
"location_custom_label_input_label": "Egendefinert etikett på bookingsiden",
"meeting_link": "Møtelenke",
@@ -3605,5 +3606,9 @@
"search_variables": "Søk i variabler",
"no_variables_found": "Ingen variabler funnet",
"api_key_name_too_long": "Navnet må ikke overstige {{max}} tegn",
"auto_charge_for_last_minute_cancellation": "Auto-belast gebyr for uteblivelse ved avbestillinger i siste liten",
"before_scheduled_start_time": "Før planlagt starttid",
"cancel_booking_acknowledge_no_show_fee": "Jeg erkjenner at ved å avbestille bookingen innen {{timeValue}} {{timeUnit}} før starttidspunktet vil jeg bli belastet et gebyr for uteblivelse på {{amount, currency}}",
"contact_organizer": "Hvis du har spørsmål, vennligst kontakt arrangøren.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Legg til dine nye strenger over her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Osadzenia",
"impersonation": "Podszywanie się",
"impersonation_description": "Ustawienia do zarządzania podszywaniem się pod użytkownika",
"recent_impersonations": "Ostatnie podszycia",
"quick_impersonate": "Szybkie podszycie",
"no_recent_impersonations": "Brak ostatnich podszyć",
"users": "Użytkownicy",
"user": "Użytkownik",
"profile_description": "Zarządzaj ustawieniami swojego profilu {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Podczas testowania tego przepływu pracy pamiętaj, że wiadomości e-mail i SMS można zaplanować jedynie z co najmniej 1-godzinnym wyprzedzeniem",
"insights_no_data_found_for_filter": "Nie znaleziono danych dotyczących wybranego filtru lub wskazanych dat.",
"acknowledge_booking_no_show_fee": "Rozumiem, że jeśli nie wezmę udziału w wydarzeniu, z mojej karty pobrana zostanie opłata za niestawienie się w wysokości {{amount, currency}}.",
"days": "dni",
"card_details": "Dane karty",
"something_went_wrong_on_our_end": "Coś poszło nie tak po naszej stronie. Skontaktuj się z naszym zespołem wsparcia, a my natychmiast to naprawimy.",
"please_provide_following_text_to_suppport": "W razie kontaktu z pomocą techniczną podaj następujący tekst, abyśmy mogli lepiej Ci pomóc.",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Rezerwacje",
"pbac_resource_insights": "Wglądy",
"pbac_resource_role": "Role",
"pbac_resource_routing_form": "Formularze routingu",
"pbac_resource_workflow": "Przepływy pracy",
"pbac_action_all": "Wszystkie działania",
"pbac_action_create": "Utwórz",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Wszystkie działania na rezerwacjach",
"pbac_desc_view_team_insights": "Zobacz statystyki zespołu",
"pbac_desc_manage_team_insights": "Zarządzaj statystykami zespołu",
"pbac_desc_create_routing_forms": "Twórz formularze routingu",
"pbac_desc_view_routing_forms": "Przeglądaj formularze routingu",
"pbac_desc_update_routing_forms": "Aktualizuj formularze routingu",
"pbac_desc_delete_routing_forms": "Usuwaj formularze routingu",
"read_permission_auto_enabled_tooltip": "Uprawnienie do odczytu jest automatycznie włączane podczas tworzenia, aktualizowania lub usuwania zasobu",
"choose_restriction_schedule": "Dodaj harmonogram ograniczeń",
"restriction_schedule_description": "Ogranicz dostępność hostów, aby wyświetlać tylko przedziały czasowe mieszczące się w określonym harmonogramie.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadane",
"stats": "Statystyki",
"booking_status": "Status rezerwacji",
"unsupported_file_type": "Pliki typu {{type}} nie mogą być przesyłane jako obrazy",
"only_image_files_allowed": "Dozwolone są tylko pliki graficzne",
"failed_to_validate_image_file": "Nie udało się zweryfikować pliku graficznego",
"invalid_image_file_format": "Nieprawidłowy format pliku graficznego",
"svg_contains_dangerous_content": "Plik SVG zawiera potencjalnie niebezpieczną zawartość",
"unrecognized_image_format": "Nierozpoznany format obrazu lub nieprawidłowy plik",
"invalid_base64_format": "Nieprawidłowy format base64",
"empty_image_data": "Puste dane obrazu",
"visit": "Odwiedź",
"location_custom_label_input_label": "Niestandardowa etykieta na stronie rezerwacji",
"meeting_link": "Link do spotkania",
@@ -3605,5 +3606,9 @@
"search_variables": "Szukaj zmiennych",
"no_variables_found": "Nie znaleziono zmiennych",
"api_key_name_too_long": "Nazwa nie może przekraczać {{max}} znaków",
"auto_charge_for_last_minute_cancellation": "Automatyczne naliczanie opłaty za niepojawienie się w przypadku odwołania w ostatniej chwili",
"before_scheduled_start_time": "Przed zaplanowanym czasem rozpoczęcia",
"cancel_booking_acknowledge_no_show_fee": "Potwierdzam, że odwołując rezerwację w ciągu {{timeValue}} {{timeUnit}} przed czasem rozpoczęcia, zostanie naliczona opłata za niepojawienie się w wysokości {{amount, currency}}",
"contact_organizer": "Jeśli masz jakiekolwiek pytania, skontaktuj się z organizatorem.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodaj nowe ciągi powyżej ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1645,6 +1645,9 @@
"embeds": "Incorporados",
"impersonation": "Representação",
"impersonation_description": "Configurações para gerenciar representação de usuário",
"recent_impersonations": "Personificações recentes",
"quick_impersonate": "Personificação rápida",
"no_recent_impersonations": "Sem personificações recentes",
"users": "Usuários",
"user": "Usuário",
"profile_description": "Gerencie as configurações para o seu perfil {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Ao testar este fluxo de trabalho, não se esqueça de que e-mails e SMS só podem ser reprogramados com pelo menos uma hora de antecedência",
"insights_no_data_found_for_filter": "Nenhum dado encontrado para o filtro ou dados selecionados.",
"acknowledge_booking_no_show_fee": "Estou ciente de que, se eu não comparecer a este evento, será cobrada no meu cartão uma taxa de não comparecimento de {{amount, currency}}.",
"days": "dias",
"card_details": "Detalhes do cartão",
"something_went_wrong_on_our_end": "Algo deu errado do nosso lado. Entre em contato com nossa equipe de suporte, e resolveremos isso imediatamente para você.",
"please_provide_following_text_to_suppport": "Para obter mais ajuda, insira o texto a seguir ao entrar em contato com o suporte",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Reservas",
"pbac_resource_insights": "Insights",
"pbac_resource_role": "Funções",
"pbac_resource_routing_form": "Formulários de Roteamento",
"pbac_resource_workflow": "Fluxos de trabalho",
"pbac_action_all": "Todas as ações",
"pbac_action_create": "Criar",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Todas as ações em reservas",
"pbac_desc_view_team_insights": "Ver insights da equipe",
"pbac_desc_manage_team_insights": "Gerenciar insights da equipe",
"pbac_desc_create_routing_forms": "Criar formulários de roteamento",
"pbac_desc_view_routing_forms": "Visualizar formulários de roteamento",
"pbac_desc_update_routing_forms": "Atualizar formulários de roteamento",
"pbac_desc_delete_routing_forms": "Excluir formulários de roteamento",
"read_permission_auto_enabled_tooltip": "A permissão de leitura é ativada automaticamente ao criar, atualizar ou excluir um recurso",
"choose_restriction_schedule": "Adicionar agenda de restrição",
"restriction_schedule_description": "Limite a disponibilidade dos anfitriões para exibir apenas horários que se enquadrem em um cronograma específico.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadados",
"stats": "Estatísticas",
"booking_status": "Status da reserva",
"unsupported_file_type": "Arquivos {{type}} não podem ser carregados como imagens",
"only_image_files_allowed": "Apenas arquivos de imagem são permitidos",
"failed_to_validate_image_file": "Falha ao validar arquivo de imagem",
"invalid_image_file_format": "Formato de arquivo de imagem inválido",
"svg_contains_dangerous_content": "SVG contém conteúdo potencialmente perigoso",
"unrecognized_image_format": "Formato de imagem não reconhecido ou arquivo inválido",
"invalid_base64_format": "Formato base64 inválido",
"empty_image_data": "Dados de imagem vazios",
"visit": "Visitar",
"location_custom_label_input_label": "Rótulo personalizado na página de reserva",
"meeting_link": "Link da reunião",
@@ -3605,5 +3606,9 @@
"search_variables": "Pesquisar variáveis",
"no_variables_found": "Nenhuma variável encontrada",
"api_key_name_too_long": "O nome não deve exceder {{max}} caracteres",
"auto_charge_for_last_minute_cancellation": "Cobrar automaticamente taxa de não comparecimento para cancelamentos de última hora",
"before_scheduled_start_time": "Antes do horário de início agendado",
"cancel_booking_acknowledge_no_show_fee": "Reconheço que, ao cancelar a reserva dentro de {{timeValue}} {{timeUnit}} antes do horário de início, serei cobrado pela taxa de não comparecimento de {{amount, currency}}",
"contact_organizer": "Se você tiver alguma dúvida, entre em contato com o organizador.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Incorporações",
"impersonation": "Representação",
"impersonation_description": "Definições para gerir a personificação de utilizadores",
"recent_impersonations": "Personificações recentes",
"quick_impersonate": "Personificação rápida",
"no_recent_impersonations": "Sem personificações recentes",
"users": "Utilizadores",
"user": "Utilizador",
"profile_description": "Gerir as definições para o seu perfil {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Ao testar este fluxo de trabalho tenha em consideração que e-mails e SMS só podem ser agendados com, pelo menos, 1 hora de antecedência",
"insights_no_data_found_for_filter": "Não foram encontrados dados para as datas ou filtros selecionados.",
"acknowledge_booking_no_show_fee": "Eu aceito que caso eu não compareça a este evento, será cobrada uma taxa de não-comparência de {{amount, currency}} no meu cartão.",
"days": "dias",
"card_details": "Detalhes do cartão",
"something_went_wrong_on_our_end": "Algo deu errado do nosso lado. Entre em contato com nossa equipe de suporte, e resolveremos isso imediatamente para você.",
"please_provide_following_text_to_suppport": "Forneça o seguinte texto ao contactar o apoio para conseguirmos prestar um melhor apoio",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Reservas",
"pbac_resource_insights": "Insights",
"pbac_resource_role": "Funções",
"pbac_resource_routing_form": "Formulários de roteamento",
"pbac_resource_workflow": "Fluxos de trabalho",
"pbac_action_all": "Todas as ações",
"pbac_action_create": "Criar",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Todas as ações em reservas",
"pbac_desc_view_team_insights": "Ver insights da equipe",
"pbac_desc_manage_team_insights": "Gerenciar insights da equipe",
"pbac_desc_create_routing_forms": "Criar formulários de roteamento",
"pbac_desc_view_routing_forms": "Visualizar formulários de roteamento",
"pbac_desc_update_routing_forms": "Atualizar formulários de roteamento",
"pbac_desc_delete_routing_forms": "Excluir formulários de roteamento",
"read_permission_auto_enabled_tooltip": "A permissão de leitura é ativada automaticamente ao criar, atualizar ou excluir um recurso",
"choose_restriction_schedule": "Adicionar agenda de restrição",
"restriction_schedule_description": "Limite a disponibilidade dos anfitriões para exibir apenas horários que se enquadrem em um cronograma específico.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadados",
"stats": "Estatísticas",
"booking_status": "Estado da reserva",
"unsupported_file_type": "Arquivos {{type}} não podem ser carregados como imagens",
"only_image_files_allowed": "Apenas arquivos de imagem são permitidos",
"failed_to_validate_image_file": "Falha ao validar arquivo de imagem",
"invalid_image_file_format": "Formato de arquivo de imagem inválido",
"svg_contains_dangerous_content": "SVG contém conteúdo potencialmente perigoso",
"unrecognized_image_format": "Formato de imagem não reconhecido ou arquivo inválido",
"invalid_base64_format": "Formato base64 inválido",
"empty_image_data": "Dados de imagem vazios",
"visit": "Visitar",
"location_custom_label_input_label": "Etiqueta personalizada na página de reserva",
"meeting_link": "Link da reunião",
@@ -3605,5 +3606,9 @@
"search_variables": "Pesquisar variáveis",
"no_variables_found": "Nenhuma variável encontrada",
"api_key_name_too_long": "O nome não deve exceder {{max}} caracteres",
"auto_charge_for_last_minute_cancellation": "Cobrar automaticamente taxa de não comparecimento para cancelamentos de última hora",
"before_scheduled_start_time": "Antes do horário de início agendado",
"cancel_booking_acknowledge_no_show_fee": "Reconheço que ao cancelar a reserva dentro de {{timeValue}} {{timeUnit}} antes do horário de início, serei cobrado pela taxa de não comparecimento de {{amount, currency}}",
"contact_organizer": "Se tiver alguma dúvida, entre em contato com o organizador.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Încorporări",
"impersonation": "Reprezentare",
"impersonation_description": "Setări pentru gestionarea reprezentării utilizatorului",
"recent_impersonations": "Impersonări recente",
"quick_impersonate": "Impersonare rapidă",
"no_recent_impersonations": "Nicio impersonare recentă",
"users": "Utilizatori",
"user": "Utilizator",
"profile_description": "Gestionați setările profilului dvs. {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "La testarea acestui flux de lucru, țineți cont de faptul că e-mailurile și SMS-urile pot fi programate doar cu cel puțin o oră în prealabil",
"insights_no_data_found_for_filter": "Nu s-au găsit date pentru filtrul selectat sau pentru datele selectate.",
"acknowledge_booking_no_show_fee": "Dacă nu particip la acest eveniment, accept să mi se perceapă de pe card o taxă de neprezentare în valoare de {{amount, currency}}.",
"days": "zile",
"card_details": "Detalii card",
"something_went_wrong_on_our_end": "Ceva nu a funcționat corect din partea noastră. Contactează echipa noastră de suport și vom rezolva problema imediat pentru tine.",
"please_provide_following_text_to_suppport": "Furnizați următorul text atunci când contactați echipa de asistență pentru a vă putea ajuta mai bine",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Rezervări",
"pbac_resource_insights": "Informații",
"pbac_resource_role": "Roluri",
"pbac_resource_routing_form": "Formulare de rutare",
"pbac_resource_workflow": "Fluxuri de lucru",
"pbac_action_all": "Toate acțiunile",
"pbac_action_create": "Creare",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Toate acțiunile asupra rezervărilor",
"pbac_desc_view_team_insights": "Vizualizează perspectivele echipei",
"pbac_desc_manage_team_insights": "Gestionează perspectivele echipei",
"pbac_desc_create_routing_forms": "Creare formulare de rutare",
"pbac_desc_view_routing_forms": "Vizualizare formulare de rutare",
"pbac_desc_update_routing_forms": "Actualizare formulare de rutare",
"pbac_desc_delete_routing_forms": "Ștergere formulare de rutare",
"read_permission_auto_enabled_tooltip": "Permisiunea de citire este activată automat atunci când se creează, actualizează sau șterge o resursă",
"choose_restriction_schedule": "Adaugă program de restricție",
"restriction_schedule_description": "Limitați disponibilitatea gazdelor pentru a afișa doar intervalele care se încadrează într-un program specificat.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadate",
"stats": "Statistici",
"booking_status": "Status rezervare",
"unsupported_file_type": "Fișierele de tip {{type}} nu pot fi încărcate ca imagini",
"only_image_files_allowed": "Sunt permise doar fișierele de tip imagine",
"failed_to_validate_image_file": "Nu s-a reușit validarea fișierului de imagine",
"invalid_image_file_format": "Format de fișier imagine invalid",
"svg_contains_dangerous_content": "Fișierul SVG conține conținut potențial periculos",
"unrecognized_image_format": "Format de imagine nerecunoscut sau fișier invalid",
"invalid_base64_format": "Format base64 invalid",
"empty_image_data": "Datele imaginii sunt goale",
"visit": "Vizită",
"location_custom_label_input_label": "Etichetă personalizată pe pagina de rezervare",
"meeting_link": "Link întâlnire",
@@ -3605,5 +3606,9 @@
"search_variables": "Caută variabile",
"no_variables_found": "Nu s-au găsit variabile",
"api_key_name_too_long": "Numele nu trebuie să depășească {{max}} caractere",
"auto_charge_for_last_minute_cancellation": "Taxare automată a taxei de neprezentare pentru anulările de ultim moment",
"before_scheduled_start_time": "Înainte de ora programată de început",
"cancel_booking_acknowledge_no_show_fee": "Recunosc că, anulând rezervarea cu {{timeValue}} {{timeUnit}} înainte de ora de început, voi fi taxat cu taxa de neprezentare de {{amount, currency}}",
"contact_organizer": "Dacă aveți întrebări, vă rugăm să contactați organizatorul.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adăugați stringurile noi deasupra acestui rând ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Встраивание",
"impersonation": "Вход от вашего имени",
"impersonation_description": "Настройки для управления входом в аккаунт от имени пользователя",
"recent_impersonations": "Недавние входы от имени других пользователей",
"quick_impersonate": "Быстрый вход от имени",
"no_recent_impersonations": "Нет недавних входов от имени других пользователей",
"users": "Пользователи",
"user": "Пользователь",
"profile_description": "Настройка вашего профиля в {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "При тестировании этого рабочего процесса помните, что отправку писем и SMS можно запланировать не менее чем за 1 час до начала",
"insights_no_data_found_for_filter": "Данные, соответствующие выбранному фильтру или диапазону дат, не найдены.",
"acknowledge_booking_no_show_fee": "Я подтверждаю, что в случае моей неявки на событие с моей карты будет списана комиссия в размере {{amount, currency}}.",
"days": "дни",
"card_details": "Реквизиты карты",
"something_went_wrong_on_our_end": "На нашей стороне произошла ошибка. Свяжитесь с нашей службой поддержки, и мы сразу же всё исправим.",
"please_provide_following_text_to_suppport": "Чтобы специалисты техподдержки могли быстрее вам помочь, укажите следующую информацию",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Бронирования",
"pbac_resource_insights": "Аналитика",
"pbac_resource_role": "Роли",
"pbac_resource_routing_form": "Формы маршрутизации",
"pbac_resource_workflow": "Рабочие процессы",
"pbac_action_all": "Все действия",
"pbac_action_create": "Создать",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Все действия с бронированиями",
"pbac_desc_view_team_insights": "Просмотр аналитики команды",
"pbac_desc_manage_team_insights": "Управление аналитикой команды",
"pbac_desc_create_routing_forms": "Создавать формы маршрутизации",
"pbac_desc_view_routing_forms": "Просматривать формы маршрутизации",
"pbac_desc_update_routing_forms": "Обновлять формы маршрутизации",
"pbac_desc_delete_routing_forms": "Удалять формы маршрутизации",
"read_permission_auto_enabled_tooltip": "Разрешение на чтение автоматически включается при создании, обновлении или удалении ресурса",
"choose_restriction_schedule": "Добавить расписание ограничений",
"restriction_schedule_description": "Ограничьте доступность хостов, чтобы отображать только временные слоты, которые соответствуют указанному расписанию.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Метаданные",
"stats": "Статистика",
"booking_status": "Статус бронирования",
"unsupported_file_type": "Файлы типа {{type}} не могут быть загружены как изображения",
"only_image_files_allowed": "Разрешены только файлы изображений",
"failed_to_validate_image_file": "Не удалось проверить файл изображения",
"invalid_image_file_format": "Недопустимый формат файла изображения",
"svg_contains_dangerous_content": "SVG содержит потенциально опасное содержимое",
"unrecognized_image_format": "Неопознанный формат изображения или недопустимый файл",
"invalid_base64_format": "Недопустимый формат base64",
"empty_image_data": "Пустые данные изображения",
"visit": "Посетить",
"location_custom_label_input_label": "Пользовательская метка на странице бронирования",
"meeting_link": "Ссылка на встречу",
@@ -3605,5 +3606,9 @@
"search_variables": "Поиск переменных",
"no_variables_found": "Переменные не найдены",
"api_key_name_too_long": "Имя не должно превышать {{max}} символов",
"auto_charge_for_last_minute_cancellation": "Автоматическое списание платы за неявку при отмене в последний момент",
"before_scheduled_start_time": "До запланированного времени начала",
"cancel_booking_acknowledge_no_show_fee": "Я подтверждаю, что, отменяя бронирование за {{timeValue}} {{timeUnit}} до времени начала, с меня будет списана плата за неявку в размере {{amount, currency}}",
"contact_organizer": "Если у вас есть вопросы, пожалуйста, свяжитесь с организатором.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавьте строки выше ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1645,6 +1645,9 @@
"embeds": "Vloženia",
"impersonation": "Impersonácia",
"impersonation_description": "Nastavenia na správu impersonácie používateľov",
"recent_impersonations": "Nedávne vydávania sa za iných",
"quick_impersonate": "Rýchle vydávanie sa za iného",
"no_recent_impersonations": "Žiadne nedávne vydávania sa za iných",
"users": "Používatelia",
"user": "Používateľ",
"profile_description": "Spravujte nastavenia svojho profilu {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Pri testovaní tohto pracovného postupu majte na pamäti, že e-maily a SMS môžu byť naplánované iba minimálne 1 hodinu vopred.",
"insights_no_data_found_for_filter": "Pre vybraný filter alebo vybrané dátumy neboli nájdené žiadne údaje.",
"acknowledge_booking_no_show_fee": "Potvrdzujem, že ak sa nezúčastním tejto udalosti, bude na moju kartu uplatnený poplatok za neúčasť vo výške {{amount, currency}}.",
"days": "dni",
"card_details": "Podrobnosti o karte",
"something_went_wrong_on_our_end": "Na našej strane sa niečo pokazilo. Kontaktujte náš tím podpory a my to pre vás okamžite opravíme.",
"please_provide_following_text_to_suppport": "Pri kontakte s podporou prosím poskytnite nasledujúci text, aby sme vám mohli lepšie pomôcť",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Rezervácie",
"pbac_resource_insights": "Prehľady",
"pbac_resource_role": "Role",
"pbac_resource_routing_form": "Smerovacie formuláre",
"pbac_resource_workflow": "Pracovné postupy",
"pbac_action_all": "Všetky akcie",
"pbac_action_create": "Vytvoriť",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Všetky akcie s rezerváciami",
"pbac_desc_view_team_insights": "Zobraziť prehľady tímu",
"pbac_desc_manage_team_insights": "Spravovať prehľady tímu",
"pbac_desc_create_routing_forms": "Vytvárať smerovacie formuláre",
"pbac_desc_view_routing_forms": "Zobraziť smerovacie formuláre",
"pbac_desc_update_routing_forms": "Aktualizovať smerovacie formuláre",
"pbac_desc_delete_routing_forms": "Odstrániť smerovacie formuláre",
"read_permission_auto_enabled_tooltip": "Povolenie na čítanie je automaticky aktivované pri vytváraní, aktualizácii alebo odstraňovaní zdroja",
"choose_restriction_schedule": "Pridať plán obmedzení",
"restriction_schedule_description": "Obmedzte dostupnosť hostiteľov tak, aby sa zobrazovali iba termíny, ktoré spadajú do určeného rozvrhu.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadáta",
"stats": "Štatistiky",
"booking_status": "Stav rezervácie",
"unsupported_file_type": "Súbory typu {{type}} nemožno nahrať ako obrázky",
"only_image_files_allowed": "Povolené sú len obrázkové súbory",
"failed_to_validate_image_file": "Nepodarilo sa overiť obrázkový súbor",
"invalid_image_file_format": "Neplatný formát obrázkového súboru",
"svg_contains_dangerous_content": "SVG obsahuje potenciálne nebezpečný obsah",
"unrecognized_image_format": "Nerozpoznaný formát obrázka alebo neplatný súbor",
"invalid_base64_format": "Neplatný formát base64",
"empty_image_data": "Prázdne údaje obrázka",
"visit": "Navštíviť",
"location_custom_label_input_label": "Vlastný popis na stránke rezervácie",
"meeting_link": "Odkaz na stretnutie",
@@ -3605,5 +3606,9 @@
"search_variables": "Vyhľadať premenné",
"no_variables_found": "Neboli nájdené žiadne premenné",
"api_key_name_too_long": "Názov nesmie presiahnuť {{max}} znakov",
"auto_charge_for_last_minute_cancellation": "Automaticky účtovať poplatok za neúčasť pri zrušení na poslednú chvíľu",
"before_scheduled_start_time": "Pred plánovaným časom začiatku",
"cancel_booking_acknowledge_no_show_fee": "Beriem na vedomie, že zrušením rezervácie v rámci {{timeValue}} {{timeUnit}} pred začiatkom mi bude účtovaný poplatok za neúčasť vo výške {{amount, currency}}",
"contact_organizer": "Ak máte akékoľvek otázky, kontaktujte organizátora.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Pridajte svoje nové reťazce nad túto líniu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Ugrađivanja",
"impersonation": "Predstavljanje kao",
"impersonation_description": "Podešavanja za upravljanje prijavljivanjem na nalog u ime korisnika",
"recent_impersonations": "Nedavna predstavljanja",
"quick_impersonate": "Brzo predstavljanje",
"no_recent_impersonations": "Nema nedavnih predstavljanja",
"users": "Korisnici",
"user": "Korisnik",
"profile_description": "Upravljajte podešavanjima vašeg {{appName}} profila",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Kada testirate ovaj radni tok, imajte na umu da imejlovi i SMS-ovi mogu da budu zakazani najmanje 1 sat unapred",
"insights_no_data_found_for_filter": "Nisu pronađeni podaci za izabrani filter ili izabrane datume.",
"acknowledge_booking_no_show_fee": "Jasno mi je da će mi biti naplaćena naknada za izostanak putem kartice u iznosu od {{amount, currency}} ukoliko ne prisustvujem ovom događaju.",
"days": "dana",
"card_details": "Informacije o kartici",
"something_went_wrong_on_our_end": "Nešto je pošlo naopako na našoj strani. Kontaktirajte naš tim za podršku, i odmah ćemo to popraviti za vas.",
"please_provide_following_text_to_suppport": "Prilikom kontaktiranja sa korisničkom službom, dostavite sledeći tekst kako bi vam bolje pomogli",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Rezervacije",
"pbac_resource_insights": "Uvidi",
"pbac_resource_role": "Uloge",
"pbac_resource_routing_form": "Obrasci za usmeravanje",
"pbac_resource_workflow": "Radni tokovi",
"pbac_action_all": "Sve radnje",
"pbac_action_create": "Kreiraj",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Sve akcije na rezervacijama",
"pbac_desc_view_team_insights": "Pregled uvida o timu",
"pbac_desc_manage_team_insights": "Upravljanje uvidima o timu",
"pbac_desc_create_routing_forms": "Kreiranje obrazaca za usmeravanje",
"pbac_desc_view_routing_forms": "Pregled obrazaca za usmeravanje",
"pbac_desc_update_routing_forms": "Ažuriranje obrazaca za usmeravanje",
"pbac_desc_delete_routing_forms": "Brisanje obrazaca za usmeravanje",
"read_permission_auto_enabled_tooltip": "Dozvola za čitanje se automatski omogućava prilikom kreiranja, ažuriranja ili brisanja resursa",
"choose_restriction_schedule": "Dodaj raspored ograničenja",
"restriction_schedule_description": "Ograničite dostupnost domaćina tako da se prikazuju samo termini koji spadaju u određeni raspored.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metapodaci",
"stats": "Statistika",
"booking_status": "Status rezervacije",
"unsupported_file_type": "{{type}} datoteke ne mogu biti otpremljene kao slike",
"only_image_files_allowed": "Dozvoljene su samo datoteke slika",
"failed_to_validate_image_file": "Neuspešna validacija datoteke slike",
"invalid_image_file_format": "Nevažeći format datoteke slike",
"svg_contains_dangerous_content": "SVG sadrži potencijalno opasan sadržaj",
"unrecognized_image_format": "Neprepoznat format slike ili nevažeća datoteka",
"invalid_base64_format": "Nevažeći base64 format",
"empty_image_data": "Prazni podaci slike",
"visit": "Poseti",
"location_custom_label_input_label": "Prilagođena oznaka na stranici za rezervacije",
"meeting_link": "Link za sastanak",
@@ -3605,5 +3606,9 @@
"search_variables": "Pretraži promenljive",
"no_variables_found": "Nisu pronađene promenljive",
"api_key_name_too_long": "Ime ne sme biti duže od {{max}} karaktera",
"auto_charge_for_last_minute_cancellation": "Auto naplata naknade za nedolazak kod otkazivanja u poslednjem trenutku",
"before_scheduled_start_time": "Pre zakazanog vremena početka",
"cancel_booking_acknowledge_no_show_fee": "Potvrđujem da ću otkazivanjem rezervacije unutar {{timeValue}} {{timeUnit}} od vremena početka biti zadužen/a naknadom za nedolazak u iznosu od {{amount, currency}}",
"contact_organizer": "Ako imate bilo kakvih pitanja, molimo kontaktirajte organizatora.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodajte svoje nove stringove iznad ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Inbäddningar",
"impersonation": "Imitering",
"impersonation_description": "Inställningar för att hantera användarpersonifiering",
"recent_impersonations": "Senaste imitationer",
"quick_impersonate": "Snabb imitation",
"no_recent_impersonations": "Inga senaste imitationer",
"users": "Användare",
"user": "Användare",
"profile_description": "Hantera inställningar för din {{appName}}-profil",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "När det här arbetsflödet testas ska du vara medveten om att e-postmeddelanden och SMS endast kan schemaläggas minst en timme i förväg",
"insights_no_data_found_for_filter": "Inga data hittades för valt filter eller valda datum.",
"acknowledge_booking_no_show_fee": "Jag bekräftar att en avgift på {{amount, currency}} för utebliven närvaro debiteras mitt kort om jag inte deltar i händelsen.",
"days": "dagar",
"card_details": "Kortuppgifter",
"something_went_wrong_on_our_end": "Något gick fel på vår sida. Kontakta vårt supportteam så fixar vi det omedelbart åt dig.",
"please_provide_following_text_to_suppport": "Ange följande text när du kontaktar supporten så att vi kan hjälpa dig bättre",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Bokningar",
"pbac_resource_insights": "Insikter",
"pbac_resource_role": "Roller",
"pbac_resource_routing_form": "Routningsformulär",
"pbac_resource_workflow": "Arbetsflöden",
"pbac_action_all": "Alla åtgärder",
"pbac_action_create": "Skapa",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Alla åtgärder för bokningar",
"pbac_desc_view_team_insights": "Visa teaminsikter",
"pbac_desc_manage_team_insights": "Hantera teaminsikter",
"pbac_desc_create_routing_forms": "Skapa routningsformulär",
"pbac_desc_view_routing_forms": "Visa routningsformulär",
"pbac_desc_update_routing_forms": "Uppdatera routningsformulär",
"pbac_desc_delete_routing_forms": "Ta bort routningsformulär",
"read_permission_auto_enabled_tooltip": "Läsbehörighet aktiveras automatiskt när en resurs skapas, uppdateras eller tas bort",
"choose_restriction_schedule": "Lägg till begränsningsschema",
"restriction_schedule_description": "Begränsa värdarnas tillgänglighet till att endast visa tider som faller inom ett specificerat schema.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Statistik",
"booking_status": "Bokningsstatus",
"unsupported_file_type": "{{type}}-filer kan inte laddas upp som bilder",
"only_image_files_allowed": "Endast bildfiler är tillåtna",
"failed_to_validate_image_file": "Det gick inte att validera bildfilen",
"invalid_image_file_format": "Ogiltigt bildfilformat",
"svg_contains_dangerous_content": "SVG-filen innehåller potentiellt farligt innehåll",
"unrecognized_image_format": "Okänt bildformat eller ogiltig fil",
"invalid_base64_format": "Ogiltigt base64-format",
"empty_image_data": "Tom bilddata",
"visit": "Besök",
"location_custom_label_input_label": "Anpassad etikett på bokningssidan",
"meeting_link": "Möteslänk",
@@ -3605,5 +3606,9 @@
"search_variables": "Sök variabler",
"no_variables_found": "Inga variabler hittades",
"api_key_name_too_long": "Namnet får inte överstiga {{max}} tecken",
"auto_charge_for_last_minute_cancellation": "Automatisk debitering av uteblivandeavgift vid sena avbokningar",
"before_scheduled_start_time": "Före schemalagd starttid",
"cancel_booking_acknowledge_no_show_fee": "Jag bekräftar att genom att avboka inom {{timeValue}} {{timeUnit}} före starttiden kommer jag att debiteras uteblivandeavgiften på {{amount, currency}}",
"contact_organizer": "Om du har några frågor, vänligen kontakta arrangören.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Gömülüler",
"impersonation": "Kimliğe bürünme",
"impersonation_description": "Kullanıcı kimliğine bürünmeyi yönetmek için ayarlar",
"recent_impersonations": "Son kimliğe bürünmeler",
"quick_impersonate": "Hızlı kimliğe bürünme",
"no_recent_impersonations": "Son kimliğe bürünme yok",
"users": "Kullanıcılar",
"user": "Kullanıcı",
"profile_description": "{{appName}} profiliniz için ayarları yönetin",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Bu iş akışını test ederken, E-postaların ve SMS'lerin yalnızca en az 1 saat önceden planlanabileceğini unutmayın",
"insights_no_data_found_for_filter": "Seçili filtre veya tarihler için veri bulunamadı.",
"acknowledge_booking_no_show_fee": "Bu etkinliğe katılmadığım takdirde kartımdan {{amount, currency}} tutarında katılmama ücreti alınmasını kabul ediyorum.",
"days": "gün",
"card_details": "Kart bilgileri",
"something_went_wrong_on_our_end": "Bizim tarafımızda bir şeyler yanlış gitti. Destek ekibimizle iletişime geçin, sizin için hemen düzelteceğiz.",
"please_provide_following_text_to_suppport": "Size daha iyi yardımcı olabilmemiz için lütfen destek ekibiyle iletişime geçtiğinizde aşağıdaki bilgileri sağlayın",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Rezervasyonlar",
"pbac_resource_insights": "Öngörüler",
"pbac_resource_role": "Roller",
"pbac_resource_routing_form": "Yönlendirme Formları",
"pbac_resource_workflow": "İş akışları",
"pbac_action_all": "Tüm Eylemler",
"pbac_action_create": "Oluştur",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Rezervasyonlar üzerindeki tüm işlemler",
"pbac_desc_view_team_insights": "Takım istatistiklerini görüntüle",
"pbac_desc_manage_team_insights": "Takım istatistiklerini yönet",
"pbac_desc_create_routing_forms": "Yönlendirme formları oluştur",
"pbac_desc_view_routing_forms": "Yönlendirme formlarını görüntüle",
"pbac_desc_update_routing_forms": "Yönlendirme formlarını güncelle",
"pbac_desc_delete_routing_forms": "Yönlendirme formlarını sil",
"read_permission_auto_enabled_tooltip": "Bir kaynağı oluştururken, güncellerken veya silerken okuma izni otomatik olarak etkinleştirilir",
"choose_restriction_schedule": "Kısıtlama takvimi ekle",
"restriction_schedule_description": "Ev sahiplerinin uygunluğunu yalnızca belirli bir program dahilindeki zaman dilimlerini gösterecek şekilde sınırlayın.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Meta veriler",
"stats": "İstatistikler",
"booking_status": "Rezervasyon durumu",
"unsupported_file_type": "{{type}} dosyaları resim olarak yüklenemez",
"only_image_files_allowed": "Yalnızca resim dosyalarına izin verilir",
"failed_to_validate_image_file": "Resim dosyası doğrulanamadı",
"invalid_image_file_format": "Geçersiz resim dosyası formatı",
"svg_contains_dangerous_content": "SVG potansiyel olarak tehlikeli içerik barındırıyor",
"unrecognized_image_format": "Tanınmayan resim formatı veya geçersiz dosya",
"invalid_base64_format": "Geçersiz base64 formatı",
"empty_image_data": "Boş resim verisi",
"visit": "Ziyaret et",
"location_custom_label_input_label": "Rezervasyon sayfasında özel etiket",
"meeting_link": "Toplantı bağlantısı",
@@ -3605,5 +3606,9 @@
"search_variables": "Değişkenlerde ara",
"no_variables_found": "Değişken bulunamadı",
"api_key_name_too_long": "İsim {{max}} karakteri geçmemelidir",
"auto_charge_for_last_minute_cancellation": "Son dakika iptalleri için otomatik olarak no-show ücreti tahsil et",
"before_scheduled_start_time": "Planlanan başlangıç zamanından önce",
"cancel_booking_acknowledge_no_show_fee": "Başlangıç saatine {{timeValue}} {{timeUnit}} kala rezervasyonu iptal etmem durumunda {{amount, currency}} tutarında no-show ücreti ödeyeceğimi kabul ediyorum",
"contact_organizer": "Herhangi bir sorunuz varsa, lütfen organizatörle iletişime geçin.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Вставки",
"impersonation": "Вхід під іншим іменем",
"impersonation_description": "Налаштування входу під іншим іменем",
"recent_impersonations": "Нещодавні видавання за інших",
"quick_impersonate": "Швидке видавання за іншого",
"no_recent_impersonations": "Немає нещодавніх видавань за інших",
"users": "Користувачі",
"user": "Користувач",
"profile_description": "Керуйте налаштуваннями свого профілю {{appName}}",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Тестуючи цей робочий процес, зважайте на те, що надсилання електронних листів і SMS-повідомлень можна запланувати щонайменше за 1 годину",
"insights_no_data_found_for_filter": "Не знайдено жодних даних для вибраного фільтра або вибраних дат.",
"acknowledge_booking_no_show_fee": "Я усвідомлюю, що в разі моєї відсутності на цьому заході з моєї картки буде стягнуто плату в розмірі {{amount, currency}}.",
"days": "днів",
"card_details": "Карткові реквізити",
"something_went_wrong_on_our_end": "Щось пішло не так з нашого боку. Зв'яжіться з нашою службою підтримки, і ми негайно виправимо це для вас.",
"please_provide_following_text_to_suppport": "Надайте нижчезазначений текст, звертаючись до служби підтримки, щоб нам було легше допомогти вам",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Бронювання",
"pbac_resource_insights": "Аналітика",
"pbac_resource_role": "Ролі",
"pbac_resource_routing_form": "Форми маршрутизації",
"pbac_resource_workflow": "Робочі процеси",
"pbac_action_all": "Усі дії",
"pbac_action_create": "Створення",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Усі дії з бронюваннями",
"pbac_desc_view_team_insights": "Перегляд аналітики команди",
"pbac_desc_manage_team_insights": "Керування аналітикою команди",
"pbac_desc_create_routing_forms": "Створювати форми маршрутизації",
"pbac_desc_view_routing_forms": "Переглядати форми маршрутизації",
"pbac_desc_update_routing_forms": "Оновлювати форми маршрутизації",
"pbac_desc_delete_routing_forms": "Видаляти форми маршрутизації",
"read_permission_auto_enabled_tooltip": "Дозвіл на читання автоматично вмикається при створенні, оновленні або видаленні ресурсу",
"choose_restriction_schedule": "Додати розклад обмежень",
"restriction_schedule_description": "Обмежте доступність організаторів, щоб відображати лише слоти, які відповідають визначеному розкладу.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Метадані",
"stats": "Статистика",
"booking_status": "Статус бронювання",
"unsupported_file_type": "Файли {{type}} не можна завантажувати як зображення",
"only_image_files_allowed": "Дозволені лише файли зображень",
"failed_to_validate_image_file": "Не вдалося перевірити файл зображення",
"invalid_image_file_format": "Недійсний формат файлу зображення",
"svg_contains_dangerous_content": "SVG містить потенційно небезпечний вміст",
"unrecognized_image_format": "Нерозпізнаний формат зображення або недійсний файл",
"invalid_base64_format": "Недійсний формат base64",
"empty_image_data": "Порожні дані зображення",
"visit": "Відвідати",
"location_custom_label_input_label": "Власна мітка на сторінці бронювання",
"meeting_link": "Посилання на зустріч",
@@ -3605,5 +3606,9 @@
"search_variables": "Пошук змінних",
"no_variables_found": "Змінних не знайдено",
"api_key_name_too_long": "Назва не повинна перевищувати {{max}} символів",
"auto_charge_for_last_minute_cancellation": "Автоматично стягувати плату за неявку при скасуванні в останню хвилину",
"before_scheduled_start_time": "До запланованого часу початку",
"cancel_booking_acknowledge_no_show_fee": "Я підтверджую, що при скасуванні бронювання за {{timeValue}} {{timeUnit}} до початку зустрічі з мене буде стягнуто плату за неявку в розмірі {{amount, currency}}",
"contact_organizer": "Якщо у вас виникли запитання, будь ласка, зв'яжіться з організатором.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+13 -8
View File
@@ -1645,6 +1645,9 @@
"embeds": "Nhúng",
"impersonation": "Mạo danh",
"impersonation_description": "Cài đặt để quản lý mạo danh người dùng",
"recent_impersonations": "Mạo danh gần đây",
"quick_impersonate": "Mạo danh nhanh",
"no_recent_impersonations": "Không có mạo danh gần đây",
"users": "Người dùng",
"user": "Người dùng",
"profile_description": "Quản lí cài đặt cho hồ sơ {{appName}} của bạn",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "Khi kiểm tra tiến độ công việc này, hãy để ý rằng Email và SMS chỉ có thể được đặt lịch trước ít nhất 1 giờ",
"insights_no_data_found_for_filter": "Không tìm thấy dữ liệu cho bộ lọc hay ngày đã chọn.",
"acknowledge_booking_no_show_fee": "Tôi công nhận là nếu tôi không tham dự sự kiện này thì một khoản phí vắng mặt là {{amount, currency}} sẽ được áp dụng cho thẻ của tôi.",
"days": "ngày",
"card_details": "Chi tiết thẻ",
"something_went_wrong_on_our_end": "Đã xảy ra lỗi ở phía chúng tôi. Hãy liên hệ với đội hỗ trợ của chúng tôi, và chúng tôi sẽ khắc phục ngay lập tức cho bạn.",
"please_provide_following_text_to_suppport": "Vui lòng cung cấp tin nhắn sau khi liên lạc bộ phận hỗ trợ để được trợ giúp tốt hơn",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "Lịch hẹn",
"pbac_resource_insights": "Thông tin chi tiết",
"pbac_resource_role": "Vai trò",
"pbac_resource_routing_form": "Biểu mẫu định tuyến",
"pbac_resource_workflow": "Tiến độ công việc",
"pbac_action_all": "Tất cả hành động",
"pbac_action_create": "Tạo",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "Tất cả các hành động trên đặt chỗ",
"pbac_desc_view_team_insights": "Xem thông tin chi tiết của nhóm",
"pbac_desc_manage_team_insights": "Quản lý thông tin chi tiết của nhóm",
"pbac_desc_create_routing_forms": "Tạo biểu mẫu định tuyến",
"pbac_desc_view_routing_forms": "Xem biểu mẫu định tuyến",
"pbac_desc_update_routing_forms": "Cập nhật biểu mẫu định tuyến",
"pbac_desc_delete_routing_forms": "Xóa biểu mẫu định tuyến",
"read_permission_auto_enabled_tooltip": "Quyền đọc được tự động kích hoạt khi tạo, cập nhật hoặc xóa tài nguyên",
"choose_restriction_schedule": "Thêm lịch hạn chế",
"restriction_schedule_description": "Giới hạn khả dụng của người chủ trì để chỉ hiển thị các khung giờ nằm trong lịch trình được chỉ định.",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "Metadata",
"stats": "Thống kê",
"booking_status": "Trạng thái đặt lịch",
"unsupported_file_type": "Không thể tải lên tệp {{type}} dưới dạng hình ảnh",
"only_image_files_allowed": "Chỉ cho phép tệp hình ảnh",
"failed_to_validate_image_file": "Không thể xác thực tệp hình ảnh",
"invalid_image_file_format": "Định dạng tệp hình ảnh không hợp lệ",
"svg_contains_dangerous_content": "SVG chứa nội dung có khả năng nguy hiểm",
"unrecognized_image_format": "Định dạng hình ảnh không được nhận dạng hoặc tệp không hợp lệ",
"invalid_base64_format": "Định dạng base64 không hợp lệ",
"empty_image_data": "Dữ liệu hình ảnh trống",
"visit": "Truy cập",
"location_custom_label_input_label": "Nhãn tùy chỉnh trên trang đặt lịch",
"meeting_link": "Liên kết cuộc họp",
@@ -3605,5 +3606,9 @@
"search_variables": "Tìm kiếm biến",
"no_variables_found": "Không tìm thấy biến nào",
"api_key_name_too_long": "Tên không được vượt quá {{max}} ký tự",
"auto_charge_for_last_minute_cancellation": "Tự động tính phí vắng mặt cho các trường hợp hủy phút chót",
"before_scheduled_start_time": "Trước thời gian bắt đầu đã lên lịch",
"cancel_booking_acknowledge_no_show_fee": "Tôi xác nhận rằng khi hủy lịch hẹn trong vòng {{timeValue}} {{timeUnit}} trước thời gian bắt đầu, tôi sẽ bị tính phí vắng mặt là {{amount, currency}}",
"contact_organizer": "Nếu bạn có bất kỳ câu hỏi nào, vui lòng liên hệ với người tổ chức.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1645,6 +1645,9 @@
"embeds": "嵌入项",
"impersonation": "模拟",
"impersonation_description": "用于管理用户模拟的设置",
"recent_impersonations": "最近的模拟",
"quick_impersonate": "快速模拟",
"no_recent_impersonations": "没有最近的模拟记录",
"users": "用户",
"user": "用户",
"profile_description": "管理您的 {{appName}} 个人资料的设置",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "测试此工作流程时请注意,电子邮件和短信必须提前至少 1 小时安排",
"insights_no_data_found_for_filter": "未找到所选筛选器或所选日期的数据。",
"acknowledge_booking_no_show_fee": "我同意,如果我不参加此活动,将从我的卡中收取 {{amount, currency}} 的失约费。",
"days": "天",
"card_details": "卡详细信息",
"something_went_wrong_on_our_end": "我们这边出了点问题。请联系支持团队,我们会立即为您解决。",
"please_provide_following_text_to_suppport": "联系支持时请提供以下文本,以便更好地帮助您",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "预约",
"pbac_resource_insights": "洞察",
"pbac_resource_role": "角色",
"pbac_resource_routing_form": "路由表单",
"pbac_resource_workflow": "工作流程",
"pbac_action_all": "所有操作",
"pbac_action_create": "创建",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "对预订的所有操作",
"pbac_desc_view_team_insights": "查看团队洞察",
"pbac_desc_manage_team_insights": "管理团队洞察",
"pbac_desc_create_routing_forms": "创建路由表单",
"pbac_desc_view_routing_forms": "查看路由表单",
"pbac_desc_update_routing_forms": "更新路由表单",
"pbac_desc_delete_routing_forms": "删除路由表单",
"read_permission_auto_enabled_tooltip": "在创建、更新或删除资源时,读取权限会自动启用",
"choose_restriction_schedule": "添加限制日程",
"restriction_schedule_description": "限制主持人的可用性,仅显示符合指定时间表的时段。",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "元数据",
"stats": "统计",
"booking_status": "预约状态",
"unsupported_file_type": "不支持上传 {{type}} 文件作为图片",
"only_image_files_allowed": "仅允许上传图片文件",
"failed_to_validate_image_file": "验证图片文件失败",
"invalid_image_file_format": "无效的图片文件格式",
"svg_contains_dangerous_content": "SVG 文件包含潜在危险内容",
"unrecognized_image_format": "无法识别的图片格式或无效文件",
"invalid_base64_format": "无效的 base64 格式",
"empty_image_data": "图片数据为空",
"visit": "访问",
"location_custom_label_input_label": "预约页面上的自定义标签",
"meeting_link": "会议链接",
@@ -3605,5 +3606,9 @@
"search_variables": "搜索变量",
"no_variables_found": "未找到变量",
"api_key_name_too_long": "名称不得超过{{max}}个字符",
"auto_charge_for_last_minute_cancellation": "对临时取消自动收取未到场费用",
"before_scheduled_start_time": "在预定开始时间之前",
"cancel_booking_acknowledge_no_show_fee": "我确认在距离开始时间 {{timeValue}} {{timeUnit}} 内取消预订,将会被收取未到场费用 {{amount, currency}}",
"contact_organizer": "如果您有任何疑问,请联系组织者。",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -1645,6 +1645,9 @@
"embeds": "嵌入",
"impersonation": "模擬",
"impersonation_description": "管理使用者模擬的設定",
"recent_impersonations": "最近的模擬",
"quick_impersonate": "快速模擬",
"no_recent_impersonations": "沒有最近的模擬",
"users": "使用者",
"user": "使用者",
"profile_description": "管理您的 {{appName}} 個人資料設定",
@@ -2311,6 +2314,7 @@
"testing_workflow_info_message": "測試此工作流程時,請注意電子郵件和簡訊只能提前至少 1 小時預定",
"insights_no_data_found_for_filter": "找不到所選篩選條件或日期的資料。",
"acknowledge_booking_no_show_fee": "我同意若我未出席此活動,將從我的卡片收取 {{amount, currency}} 的缺席費。",
"days": "天",
"card_details": "卡片詳細資料",
"something_went_wrong_on_our_end": "我們的系統出現了一些問題。請聯繫我們的支援團隊,我們會立即為您解決。",
"please_provide_following_text_to_suppport": "聯絡支援團隊時,請提供下列資訊,以便我們進一步協助您",
@@ -3393,6 +3397,7 @@
"pbac_resource_booking": "預約",
"pbac_resource_insights": "洞察",
"pbac_resource_role": "角色",
"pbac_resource_routing_form": "路由表單",
"pbac_resource_workflow": "工作流程",
"pbac_action_all": "所有操作",
"pbac_action_create": "建立",
@@ -3463,6 +3468,10 @@
"pbac_desc_manage_bookings": "對預訂的所有操作",
"pbac_desc_view_team_insights": "查看團隊洞察",
"pbac_desc_manage_team_insights": "管理團隊洞察",
"pbac_desc_create_routing_forms": "建立路由表單",
"pbac_desc_view_routing_forms": "檢視路由表單",
"pbac_desc_update_routing_forms": "更新路由表單",
"pbac_desc_delete_routing_forms": "刪除路由表單",
"read_permission_auto_enabled_tooltip": "在建立、更新或刪除資源時,閱讀權限會自動啟用",
"choose_restriction_schedule": "新增限制排程",
"restriction_schedule_description": "限制主持人的可用性,僅顯示符合指定排程的時段。",
@@ -3581,14 +3590,6 @@
"webhook_metadata": "中繼資料",
"stats": "統計",
"booking_status": "預約狀態",
"unsupported_file_type": "無法將 {{type}} 檔案上傳為圖片",
"only_image_files_allowed": "僅允許上傳圖片檔案",
"failed_to_validate_image_file": "驗證圖片檔案失敗",
"invalid_image_file_format": "圖片檔案格式無效",
"svg_contains_dangerous_content": "SVG 包含潛在危險內容",
"unrecognized_image_format": "無法識別的圖片格式或無效檔案",
"invalid_base64_format": "無效的 base64 格式",
"empty_image_data": "圖片資料為空",
"visit": "造訪",
"location_custom_label_input_label": "預約頁面的自訂標籤",
"meeting_link": "會議連結",
@@ -3605,5 +3606,9 @@
"search_variables": "搜尋變數",
"no_variables_found": "未找到變數",
"api_key_name_too_long": "名稱不得超過 {{max}} 個字元",
"auto_charge_for_last_minute_cancellation": "自動收取臨時取消的未出席費用",
"before_scheduled_start_time": "預定開始時間之前",
"cancel_booking_acknowledge_no_show_fee": "我確認在距開始時間 {{timeValue}} {{timeUnit}} 內取消預約,將會被收取未出席費用 {{amount, currency}}",
"contact_organizer": "如果您有任何問題,請聯繫主辦方。",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 請在此處新增您的字串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+4
View File
@@ -8746,6 +8746,8 @@
{
"name": "credentialId",
"in": "query",
"description": "The ID of the user's calendar credential used to access the calendar. Retrieve this from your connected calendar credentials via the `/v2/credentials` endpoint.",
"example": 135,
"required": true,
"schema": {
"type": "number"
@@ -8754,6 +8756,8 @@
{
"name": "externalId",
"in": "query",
"description": "The external calendar identifier from the calendar provider (e.g., email address for Google Calendar). This is returned when listing connected calendars.",
"example": "user@gmail.com",
"required": true,
"schema": {
"type": "string"
+3 -4
View File
@@ -3,11 +3,10 @@ title: "Railway"
icon: "square-r"
---
You can deploy Cal.com on Railway using the button below.
The team at Railway also have a [detailed blog post](https://blog.railway.app/p/calendso) on deploying Cal.com on their platform.
You can deploy Cal.com on Railway using the button below.
## One Click Deployment
<a href="https://railway.app/new/template/cal" target="_blank">
<a href="https://railway.com/new/template/calcom" target="_blank">
<img src="/images/deploy-railway.svg" noZoom alt="Deploy on Railway" />
</a>
</a>
+33 -3
View File
@@ -115,9 +115,39 @@ yarn workspace @calcom/api-v2 build
yarn workspace @calcom/api-v2 start
```
## Vercel
## One Click Deployments
As Cal.com is written in Next.js, Vercel is the perfect platform to host this on (Vercel built Next.js). You can simply follow the [instructions provided by Vercel to get started](https://vercel.com/docs/concepts/get-started). All you do is fork your own version of the repository, click new on Vercel and select the repository. It'll pretty much do the rest for you. The one thing you will need to do is set the environment variables (the .env file). As you can't create a .env file on Vercel, you can go into the settings and manually add each variable. Use the .env.example file for reference as to what you should add. You can learn more about [setting environment variables on Vercel here](https://vercel.com/docs/concepts/projects/environment-variables).
### Azure
<a href="https://portal.azure.com/#create/Microsoft.Template/uri/https://raw.githubusercontent.com/calcom/docker/main/azuredeploy.json" target="_blank">
<img src="https://aka.ms/deploytoazurebutton" noZoom alt="Deploy to Azure"/>
</a>
## Elestio
<a href="https://elest.io/open-source/cal.com" target="_blank">
<img src="/images/deploy-elestio.png" noZoom alt="Deploy on Elestio" />
</a>
### GCP
<a href="https://deploy.cloud.run/?git_repo=https://github.com/calcom/docker" target="_blank">
<img src="https://storage.googleapis.com/gweb-cloudblog-publish/images/run_on_google_cloud.max-300x300.png" noZoom alt="Deploy on Google Cloud" />
</a>
### Railway
<a href="https://railway.com/new/template/calcom" target="_blank">
<img src="/images/deploy-railway.svg" noZoom alt="Deploy on Railway" />
</a>
### Render
<a href="https://render.com/deploy?repo=https://github.com/calcom/docker" target="_blank">
<img src="/images/deploy-render.svg" alt="Deploy on Render" noZoom />
</a>
### Vercel
<a href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com&env=DATABASE_URL,NEXT_PUBLIC_WEBAPP_URL,NEXTAUTH_URL,NEXTAUTH_SECRET,CRON_API_KEY,CALENDSO_ENCRYPTION_KEY&envDescription=See%20all%20available%20env%20vars&envLink=https%3A%2F%2Fgithub.com%2Fcalcom%2Fcal.com%2Fblob%2Fmain%2F.env.example&project-name=cal&repo-name=cal.com&build-command=cd%20../..%20%26%26%20yarn%20build&root-directory=apps%2Fweb%2F" target="_blank">
<img src="https://vercel.com/button" noZoom alt="Deploy with Vercel" />
</a>
## Other environments
@@ -126,5 +156,5 @@ Cal.com effectively is just a Next.js application, so any possible solution you
That's it. Your new self hosted Cal.com instance should now be up and running.
<Card href="/self-hosting/deployments" title="Deployments" icon="rocket">
For more details on specific deployment instructions for AWS, Azure, etc.
For more details on specific deployment instructions for AWS, Azure, Railway, etc.
</Card>
+13 -8
View File
@@ -1646,6 +1646,9 @@ checksums:
embeds: b4eddec9f7233622c508db34d1ac1d45
impersonation: 5cc8e11e5a89d71adf5882da844a33f1
impersonation_description: fab0bda0e574a42f2208f0a7ea641f96
recent_impersonations: e36ff59adaecb6bc12717260914f1dea
quick_impersonate: 794eda9730f3ca5e682ae074dfdfc94d
no_recent_impersonations: 42b140bd547053431fb157710abb4ad7
users: fe42f97809c24d700d3912c8d02ed452
user: 61073457a5c3901084b557d065f876be
profile_description: fe677c83722fcbddcdc6a3b349cd1d8f
@@ -2308,6 +2311,7 @@ checksums:
testing_workflow_info_message: 63eb6b5f857e4fab7eae515a85e6bed5
insights_no_data_found_for_filter: af12cfd502181543842597325894bb30
acknowledge_booking_no_show_fee: 392224bac006a342a7fb63dad685689f
days: c95fe8aedde21a0b5653dbd0b3c58b48
card_details: 12bfee4d5e5b0e0f1c52b0cc40427ad0
something_went_wrong_on_our_end: fb27a37cc30453962862e03ff76d6501
please_provide_following_text_to_suppport: 12912573e35d64fe41da3c33b5c8d4eb
@@ -3390,6 +3394,7 @@ checksums:
pbac_resource_booking: b6c73c2c1406f1f5fc13ef027ddb9352
pbac_resource_insights: 1e88b2537ba6966f2a90b0def1ce1ddc
pbac_resource_role: 21834ee85d5936bc0b262d5d5ddd58e3
pbac_resource_routing_form: d247797af01c33bcf3545d9708f39286
pbac_resource_workflow: b0c9c8615a9ba7d9cb73e767290a7f72
pbac_action_all: 8955f3bda3a57897951eaffc3772ba2f
pbac_action_create: 757ccd28dd533ff3a933355273c1e32a
@@ -3460,6 +3465,10 @@ checksums:
pbac_desc_manage_bookings: 5f7404cdf3a463bb1764e53079b74ff0
pbac_desc_view_team_insights: 8a476dd09b83714320ddbc69a2ec7088
pbac_desc_manage_team_insights: d3eab216d5f3e34358e68b287adcc102
pbac_desc_create_routing_forms: 2317c8e6bfdc397c9068e5a3055bf4a7
pbac_desc_view_routing_forms: 1c7a2290049b782702f46e28d19f2ed5
pbac_desc_update_routing_forms: 09cfc7887518428c1bd6890beee18f5a
pbac_desc_delete_routing_forms: 4e841cd1d89980f764bf6027aad6eef6
read_permission_auto_enabled_tooltip: e6a994bc46d1208f07a303cd638efca2
choose_restriction_schedule: ddc5f4cb03d3502a37e84fc5e3224c4f
restriction_schedule_description: d861bc8515d77f509eefc8ff160e7593
@@ -3578,14 +3587,6 @@ checksums:
webhook_metadata: 695d4f7da261ba76e3be4de495491028
stats: 5cf8b052c89fa1b05397a956ebec69b2
booking_status: fe88452fca9d95db1fce15446f545aab
unsupported_file_type: 081413e5566bff167caff11f7a1584b6
only_image_files_allowed: f2ca528a5c825d23cb1795492012fd2f
failed_to_validate_image_file: fb0b149ef0e6c7ec40ec5fc92e9488d4
invalid_image_file_format: e379415061b2158591ff3ffa62476a75
svg_contains_dangerous_content: a3dd4954ee3d5b9c10da6347ff22daab
unrecognized_image_format: 920eee38f2ee187b325ed796eb349003
invalid_base64_format: 03a47e4d4eda2c943894bb0d69736ac4
empty_image_data: 5418faf33d0af19d1368b9d427da454a
visit: 654d6dc335d93b0d9ac26f296bbf53cc
location_custom_label_input_label: 944fd3bbb28cf884e36ba96ed2186c1c
meeting_link: 892205c2e48aad28d01f62b8ef621560
@@ -3602,4 +3603,8 @@ checksums:
search_variables: 109713c41acc0d58dcb26bd70f0fa3b3
no_variables_found: ff245d81f9b63e74656862119685bb55
api_key_name_too_long: 1175b1d59bbe58c2a1b1e86de785dce8
auto_charge_for_last_minute_cancellation: 8d438d61dc249f0ca003734e4a9dac18
before_scheduled_start_time: 35bbe398965e631eaf969ee89266ee84
cancel_booking_acknowledge_no_show_fee: 6e30c82004a60d5ec2daa40d93d3cce0
contact_organizer: d4d89322dbb7d3b447a45e09ffd48ba8
ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS: 18323f2f3fabb169a7cb50ff62433850
+2 -1
View File
@@ -137,7 +137,8 @@
"dayjs@^1": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
"dayjs@^1.8.29": "patch:dayjs@npm%3A1.11.4#./.yarn/patches/dayjs-npm-1.11.4-97921cd375.patch",
"@prisma/client@^5.4.2": "patch:@prisma/client@npm%3A5.4.2#./.yarn/patches/@prisma-client-npm-5.4.2-fca489b2dc.patch",
"@prisma/client@5.4.2": "patch:@prisma/client@npm%3A5.4.2#./.yarn/patches/@prisma-client-npm-5.4.2-fca489b2dc.patch"
"@prisma/client@5.4.2": "patch:@prisma/client@npm%3A5.4.2#./.yarn/patches/@prisma-client-npm-5.4.2-fca489b2dc.patch",
"import-in-the-middle": "1.13.1"
},
"lint-staged": {
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
@@ -2,68 +2,12 @@
import Link from "next/link";
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { App } from "@calcom/types/App";
import classNames from "@calcom/ui/classNames";
import { Icon } from "@calcom/ui/components/icon";
import { InstallAppButtonMap } from "./apps.browser.generated";
import type { InstallAppButtonProps } from "./types";
export const InstallAppButtonWithoutPlanCheck = (
props: {
type: App["type"];
options?: UseAddAppMutationOptions;
} & InstallAppButtonProps
) => {
const mutation = useAddAppMutation(null, props.options);
const key = deriveAppDictKeyFromType(props.type, InstallAppButtonMap);
const InstallAppButtonComponent = InstallAppButtonMap[key as keyof typeof InstallAppButtonMap];
if (!InstallAppButtonComponent)
return (
<>
{props.render({
useDefaultComponent: true,
disabled: props.disableInstall,
onClick: () => {
mutation.mutate({ type: props.type });
},
loading: mutation.data?.setupPending,
})}
</>
);
return (
<InstallAppButtonComponent
render={props.render}
onChanged={props.onChanged}
disableInstall={props.disableInstall}
/>
);
};
export const InstallAppButton = (
props: {
teamsPlanRequired?: App["teamsPlanRequired"];
type: App["type"];
wrapperClassName?: string;
disableInstall?: boolean;
} & InstallAppButtonProps
) => {
return (
<div className={props.wrapperClassName}>
<InstallAppButtonWithoutPlanCheck {...props} />
</div>
);
};
export { AppConfiguration } from "./_components/AppConfiguration";
export const AppDependencyComponent = ({
appName,
dependencyData,
+21
View File
@@ -0,0 +1,21 @@
"use client";
import type { App } from "@calcom/types/App";
import { InstallAppButtonWithoutPlanCheck } from "./InstallAppButtonWithoutPlanCheck";
import type { InstallAppButtonProps } from "./types";
export const InstallAppButton = (
props: {
teamsPlanRequired?: App["teamsPlanRequired"];
type: App["type"];
wrapperClassName?: string;
disableInstall?: boolean;
} & InstallAppButtonProps
) => {
return (
<div className={props.wrapperClassName}>
<InstallAppButtonWithoutPlanCheck {...props} />
</div>
);
};
@@ -0,0 +1,41 @@
"use client";
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import type { App } from "@calcom/types/App";
import { InstallAppButtonMap } from "./apps.browser.generated";
import type { InstallAppButtonProps } from "./types";
export const InstallAppButtonWithoutPlanCheck = (
props: {
type: App["type"];
options?: UseAddAppMutationOptions;
} & InstallAppButtonProps
) => {
const mutation = useAddAppMutation(null, props.options);
const key = deriveAppDictKeyFromType(props.type, InstallAppButtonMap);
const InstallAppButtonComponent = InstallAppButtonMap[key as keyof typeof InstallAppButtonMap];
if (!InstallAppButtonComponent)
return (
<>
{props.render({
useDefaultComponent: true,
disabled: props.disableInstall,
onClick: () => {
mutation.mutate({ type: props.type });
},
loading: mutation.data?.setupPending,
})}
</>
);
return (
<InstallAppButtonComponent
render={props.render}
onChanged={props.onChanged}
disableInstall={props.disableInstall}
/>
);
};
@@ -1,19 +0,0 @@
import dynamic from "next/dynamic";
export const ConfigAppMap = {
vital: dynamic(() => import("../vital/components/AppConfiguration")),
};
export const AppConfiguration = (props: { type: string } & { credentialIds: number[] }) => {
let appName = props.type.replace(/_/g, "");
let ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap];
/** So we can either call it by simple name (ex. `slack`, `giphy`) instead of
* `slackmessaging`, `giphyother` while maintaining retro-compatibility. */
if (!ConfigAppComponent) {
[appName] = props.type.split("_");
ConfigAppComponent = ConfigAppMap[appName as keyof typeof ConfigAppMap];
}
if (!ConfigAppComponent) return null;
return <ConfigAppComponent credentialIds={props.credentialIds} />;
};
@@ -1,12 +1,12 @@
import useApp from "@calcom/lib/hooks/useApp";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
import { InstallAppButton } from "../InstallAppButton";
import useAddAppMutation from "../_utils/useAddAppMutation";
import { InstallAppButton } from "../components";
/**
* Use this component to allow installing an app from anywhere on the app.
@@ -3,7 +3,6 @@ import type { OAuth2Client } from "googleapis-common";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import prisma from "@calcom/prisma";
@@ -17,17 +16,12 @@ export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userI
return;
}
// Handle base64 data
if (
avatarUrl.startsWith("data:image/png;base64,") ||
avatarUrl.startsWith("data:image/jpeg;base64,") ||
avatarUrl.startsWith("data:image/jpg;base64,")
) {
const validation = validateBase64Image(avatarUrl);
if (!validation.isValid) {
logger.error(`Invalid avatar image from Google OAuth: ${validation.error}`);
return;
}
const resizedAvatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(avatarUrl),
userId,
@@ -1310,8 +1310,8 @@ describe("Date Optimization Benchmarks", () => {
)}ms, Speedup: ${speedupRatio.toFixed(1)}x`
);
// Assert significant performance improvement (at least 5x faster)
expect(speedupRatio).toBeGreaterThan(5);
const minSpeedup = process.env.CI ? 1.5 : 5; // Lower threshold for CI
expect(speedupRatio).toBeGreaterThan(minSpeedup);
}
});
@@ -7,6 +7,7 @@ import { RefundPolicy } from "@calcom/lib/payment/types";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Select } from "@calcom/ui/components/form";
import { CheckboxField } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { RadioField } from "@calcom/ui/components/radio";
@@ -16,6 +17,7 @@ import {
} from "../../_utils/payments/currencyConversions";
import { paymentOptions } from "../lib/constants";
import { currencyOptions } from "../lib/currencyOptions";
import { autoChargeNoShowFeeTimeUnitEnum } from "../zod";
type Option = { value: string; label: string };
@@ -38,6 +40,9 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
const requirePayment = getAppData("enabled");
const getSelectedOption = () =>
options.find((opt) => opt.value === (getAppData("refundCountCalendarDays") === true ? 1 : 0));
const autoChargeNoShowFeeIfCancelled = getAppData("autoChargeNoShowFeeIfCancelled");
const autoChargeNoShowFeeTimeUnit = getAppData("autoChargeNoShowFeeTimeUnit");
const autoChargeNoShowFeeTimeValue = getAppData("autoChargeNoShowFeeTimeValue");
const { t } = useLocale();
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
@@ -72,6 +77,11 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
{ value: 0, label: t("business_days") },
{ value: 1, label: t("calendar_days") },
];
const autoChangeTimeUnitOptions = [
{ value: autoChargeNoShowFeeTimeUnitEnum.enum.minutes, label: t("minutes") },
{ value: autoChargeNoShowFeeTimeUnitEnum.enum.hours, label: t("hours") },
{ value: autoChargeNoShowFeeTimeUnitEnum.enum.days, label: t("days") },
];
return (
<>
{recurringEventDefined && (
@@ -151,11 +161,9 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
isDisabled={seatsEnabled || disabled}
/>
</div>
{seatsEnabled && paymentOption === "HOLD" && (
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
)}
{paymentOption !== "HOLD" && (
<div className="mt-4 w-full">
<label className="text-default mb-1 block text-sm font-medium">{t("refund_policy")}</label>
@@ -210,6 +218,54 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
</RadioGroup.Root>
</div>
)}
{paymentOption === "HOLD" && (
<div className="mt-4">
<div>
<CheckboxField
checked={autoChargeNoShowFeeIfCancelled}
onChange={(e) => setAppData("autoChargeNoShowFeeIfCancelled", e.target.checked)}
description={t("auto_charge_for_last_minute_cancellation")}
/>
</div>
{autoChargeNoShowFeeIfCancelled && (
<div>
<div className="mt-2 flex items-center">
<TextField
labelSrOnly
type="number"
className={classNames(
"border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
)}
placeholder="2"
disabled={disabled}
min={0}
defaultValue={autoChargeNoShowFeeTimeValue}
required={autoChargeNoShowFeeIfCancelled}
value={autoChargeNoShowFeeTimeValue ?? ""}
onChange={(e) =>
setAppData("autoChargeNoShowFeeTimeValue", parseInt(e.currentTarget.value))
}
/>
<Select
options={autoChangeTimeUnitOptions}
isSearchable={false}
isDisabled={disabled}
onChange={(option) => setAppData("autoChargeNoShowFeeTimeUnit", option?.value)}
value={
autoChangeTimeUnitOptions.find((opt) => opt.value === autoChargeNoShowFeeTimeUnit) ||
autoChangeTimeUnitOptions[0]
}
defaultValue={
autoChangeTimeUnitOptions.find((opt) => opt.value === autoChargeNoShowFeeTimeUnit) ||
autoChangeTimeUnitOptions[0]
}
/>
<span className="me-2 ms-2">{t("before_scheduled_start_time")}</span>
</div>
</div>
)}
</div>
)}
</>
)}
</>
+5
View File
@@ -14,6 +14,8 @@ const VALUES: [PaymentOption, ...PaymentOption[]] = [
];
export const paymentOptionEnum = z.enum(VALUES);
export const autoChargeNoShowFeeTimeUnitEnum = z.enum(["minutes", "hours", "days"]);
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
price: z.number(),
@@ -23,6 +25,9 @@ export const appDataSchema = eventTypeAppCardZod.merge(
refundPolicy: z.nativeEnum(RefundPolicy).optional(),
refundDaysCount: z.number().optional(),
refundCountCalendarDays: z.boolean().optional(),
autoChargeNoShowFeeIfCancelled: z.boolean().optional(),
autoChargeNoShowFeeTimeValue: z.number().optional(),
autoChargeNoShowFeeTimeUnit: autoChargeNoShowFeeTimeUnitEnum.optional(),
})
);
@@ -3,9 +3,9 @@
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
@@ -1,7 +1,7 @@
import { useCallback, useState } from "react";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
import { InstallAppButton } from "@calcom/app-store/components";
import { getLocationFromApp, type EventLocationType } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import AppListCard from "@calcom/features/apps/components/AppListCard";
@@ -0,0 +1,325 @@
import { default as cloneDeep } from "lodash/cloneDeep";
import type { Logger } from "tslog";
import dayjs from "@calcom/dayjs";
import {
allowDisablingHostConfirmationEmails,
allowDisablingAttendeeConfirmationEmails,
} from "@calcom/ee/workflows/lib/allowDisablingStandardEmails";
import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types";
import {
sendRoundRobinRescheduledEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendRoundRobinCancelledEmailsAndSMS,
sendRescheduledEmailsAndSMS,
sendScheduledEmailsAndSMS,
sendOrganizerRequestEmail,
sendAttendeeRequestEmailAndSMS,
} from "@calcom/emails";
import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils";
import type { EventNameObjectType } from "@calcom/lib/event";
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import type { DestinationCalendar, Prisma, User } from "@calcom/prisma/client";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar";
export const BookingActionMap = {
confirmed: "BOOKING_CONFIRMED",
rescheduled: "BOOKING_RESCHEDULED",
requested: "BOOKING_REQUESTED",
} as const;
type EmailAndSmsPayload = {
evt: CalendarEvent;
eventType: {
metadata?: EventTypeMetadata;
schedulingType: SchedulingType | null;
};
};
type RescheduleEmailAndSmsPayload = EmailAndSmsPayload & {
rescheduleReason?: string;
additionalInformation: AdditionalInformation;
additionalNotes: string | null | undefined;
iCalUID: string;
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
destinationCalendar: DestinationCalendar | null;
isFixed?: boolean;
})[];
changedOrganizer?: boolean;
isRescheduledByBooker: boolean;
originalRescheduledBooking: NonNullable<BookingType>;
};
type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & {
workflows: WorkflowType[];
eventNameObject: EventNameObjectType;
additionalInformation: AdditionalInformation;
additionalNotes: string | null | undefined;
customInputs: Prisma.JsonObject | null | undefined;
};
type RequestedEmailAndSmsPayload = EmailAndSmsPayload & {
attendees?: Person[];
additionalNotes?: string | null;
};
type RescheduledSideEffectsPayload = {
action: typeof BookingActionMap.rescheduled;
data: RescheduleEmailAndSmsPayload;
};
type ConfirmedSideEffectsPayload = {
action: typeof BookingActionMap.confirmed;
data: ConfirmedEmailAndSmsPayload;
};
type RequestedSideEffectsPayload = {
action: typeof BookingActionMap.requested;
data: RequestedEmailAndSmsPayload;
};
export type EmailsAndSmsSideEffectsPayload =
| RescheduledSideEffectsPayload
| RequestedSideEffectsPayload
| ConfirmedSideEffectsPayload;
export interface IBookingEmailSmsHandler {
logger: Logger<unknown>;
}
export class BookingEmailSmsHandler {
private readonly log: Logger<unknown>;
constructor(dependencies: IBookingEmailSmsHandler) {
this.log = dependencies.logger.getSubLogger({ prefix: ["BookingEmailSmsHandler"] });
}
public async send(payload: EmailsAndSmsSideEffectsPayload) {
const { action, data } = payload;
if (action === BookingActionMap.rescheduled) {
if (data.eventType.schedulingType === "ROUND_ROBIN") return this._handleRoundRobinRescheduled(data);
return this._handleRescheduled(data);
}
if (action === BookingActionMap.confirmed) return this._handleConfirmed(data);
if (action === BookingActionMap.requested) return this._handleRequested(data);
this.log.warn("Unknown email/SMS action requested.", { action });
}
/**
* Handles notifications for a RESCHEDULED booking.
*/
private async _handleRescheduled(data: RescheduleEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
rescheduleReason,
additionalNotes,
additionalInformation,
} = data;
await sendRescheduledEmailsAndSMS(
{
...evt,
additionalInformation,
additionalNotes,
cancellationReason: `$RCH$${rescheduleReason || ""}`,
},
metadata
);
}
/**
* Handles notifications for a RESCHEDULED RR booking.
*/
private async _handleRoundRobinRescheduled(data: RescheduleEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
originalRescheduledBooking,
rescheduleReason,
additionalNotes,
changedOrganizer,
additionalInformation,
users,
isRescheduledByBooker,
iCalUID,
} = data;
const copyEvent = cloneDeep(evt);
const copyEventAdditionalInfo = {
...copyEvent,
additionalInformation,
additionalNotes,
cancellationReason: `$RCH$${rescheduleReason || ""}`,
};
const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo);
this.log.debug("Emails: Sending rescheduled emails for booking confirmation");
const originalBookingMemberEmails: Person[] = [];
for (const user of originalRescheduledBooking.attendees) {
const translate = await getTranslation(user.locale ?? "en", "common");
originalBookingMemberEmails.push({
name: user.name,
email: user.email,
timeZone: user.timeZone,
phoneNumber: user.phoneNumber,
language: { translate, locale: user.locale ?? "en" },
});
}
if (originalRescheduledBooking.user) {
const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common");
const originalOrganizer = originalRescheduledBooking.user;
originalBookingMemberEmails.push({
...originalRescheduledBooking.user,
username: originalRescheduledBooking.user.username ?? undefined,
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
name: originalRescheduledBooking.user.name || "",
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
});
if (changedOrganizer) {
cancelledRRHostEvt.title = originalRescheduledBooking.title;
cancelledRRHostEvt.startTime =
dayjs(originalRescheduledBooking?.startTime).utc().format() || copyEventAdditionalInfo.startTime;
cancelledRRHostEvt.endTime =
dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime;
cancelledRRHostEvt.organizer = {
email: originalOrganizer.email,
name: originalOrganizer.name || "",
timeZone: originalOrganizer.timeZone,
language: { translate, locale: originalOrganizer.locale || "en" },
};
}
}
const newBookingMemberEmails: Person[] = [
...(copyEvent.team?.members || []),
copyEvent.organizer,
...copyEvent.attendees,
];
const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) =>
originalMember.email === newMember.email;
const newBookedMembers = newBookingMemberEmails.filter(
(member) => !originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
);
const cancelledMembers = originalBookingMemberEmails.filter(
(member) => !newBookingMemberEmails.some((nm) => matchOriginalMemberWithNewMember(member, nm))
);
const rescheduledMembers = newBookingMemberEmails.filter((member) =>
originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member))
);
const reassignedTo = users.find(
(user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email)
);
try {
await Promise.all([
sendRoundRobinRescheduledEmailsAndSMS(
{ ...copyEventAdditionalInfo, iCalUID },
rescheduledMembers,
metadata
),
sendRoundRobinScheduledEmailsAndSMS({
calEvent: copyEventAdditionalInfo,
members: newBookedMembers,
eventTypeMetadata: metadata,
}),
sendRoundRobinCancelledEmailsAndSMS(
cancelledRRHostEvt,
cancelledMembers,
metadata,
reassignedTo
? {
name: reassignedTo.name,
email: reassignedTo.email,
...(isRescheduledByBooker && { reason: "Booker Rescheduled" }),
}
: undefined
),
]);
} catch (err) {
this.log.error("Failed to send rescheduled round robin event related emails", err);
}
}
/**
* Handles notifications for a newly CONFIRMED booking.
*/
private async _handleConfirmed(data: ConfirmedEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
workflows,
eventNameObject,
additionalInformation,
additionalNotes,
customInputs,
} = data;
let isHostConfirmationEmailsDisabled = metadata?.disableStandardEmails?.confirmation?.host || false;
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
let isAttendeeConfirmationEmailDisabled =
metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
try {
await sendScheduledEmailsAndSMS(
{ ...evt, additionalInformation, additionalNotes, customInputs },
eventNameObject,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled,
metadata
);
} catch (err) {
this.log.error("Failed to send scheduled event related emails", err);
}
}
/**
* Handles notifications when a booking REQUEST is made (requires confirmation).
*/
private async _handleRequested(data: RequestedEmailAndSmsPayload) {
const {
evt,
eventType: { metadata },
attendees,
additionalNotes,
} = data;
if (!attendees?.length) {
this.log.error("Requested action called without attendee details.");
return;
}
this.log.debug(
"Action: BOOKING_REQUESTED. Sending request emails.",
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
);
const eventWithNotes = { ...evt, additionalNotes };
try {
await Promise.all([
sendOrganizerRequestEmail(eventWithNotes, metadata),
sendAttendeeRequestEmailAndSMS(eventWithNotes, attendees[0], metadata),
]);
} catch (err) {
this.log.error("Failed to send requested event related emails", err);
}
}
}
@@ -22,6 +22,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
timeFormat: true,
name: true,
destinationCalendar: true,
locale: true,
},
},
location: true,
@@ -46,6 +47,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
hideBranding: true,
},
},
teamId: true,
team: {
select: {
id: true,
@@ -23,6 +23,7 @@ import { HttpError } from "@calcom/lib/http-error";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { processNoShowFeeOnCancellation } from "@calcom/lib/payment/processNoShowFeeOnCancellation";
import { processPaymentRefund } from "@calcom/lib/payment/processPaymentRefund";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -194,7 +195,6 @@ async function handler(input: CancelBookingInput) {
const teamMembersPromises = [];
const attendeesListPromises = [];
const hostsPresent = !!bookingToDelete.eventType?.hosts;
const hostEmails = new Set(bookingToDelete.eventType?.hosts?.map((host) => host.user.email) ?? []);
for (let index = 0; index < bookingToDelete.attendees.length; index++) {
@@ -455,11 +455,25 @@ async function handler(input: CancelBookingInput) {
});
updatedBookings.push(updatedBooking);
if (!!bookingToDelete.payment.length) {
await processPaymentRefund({
booking: bookingToDelete,
teamId,
});
if (bookingToDelete.payment.some((payment) => payment.paymentOption === "ON_BOOKING")) {
try {
await processPaymentRefund({
booking: bookingToDelete,
teamId,
});
} catch (error) {
log.error(`Error processing payment refund for booking ${bookingToDelete.uid}:`, error);
}
} else if (bookingToDelete.payment.some((payment) => payment.paymentOption === "HOLD")) {
try {
await processNoShowFeeOnCancellation({
booking: bookingToDelete,
payments: bookingToDelete.payment,
cancelledByUserId: userId,
});
} catch (error) {
log.error(`Error processing no-show fee for booking ${bookingToDelete.uid}:`, error);
}
}
}
@@ -509,4 +509,365 @@ describe("Cancel Booking", () => {
})
).rejects.toThrow("Cannot cancel a booking that has already ended");
});
test("Should not charge cancellation fee when organizer cancels booking", async () => {
const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const uidOfBookingToBeCancelled = "cancellation-fee-organizer-test";
const idOfBookingToBeCancelled = 4050;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [{ id: 101 }],
metadata: {
apps: {
stripe: {
enabled: true,
paymentOption: "HOLD",
price: 1000,
currency: "usd",
cancellationFeeEnabled: true,
cancellationFeeTimeValue: 2,
cancellationFeeTimeUnit: "hours",
},
},
},
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
attendees: [
{
email: booker.email,
timeZone: "Asia/Kolkata",
},
],
},
],
payment: [
{
amount: 1000,
bookingId: idOfBookingToBeCancelled,
currency: "usd",
data: {},
externalId: "ext_id_cancellation",
fee: 0,
refunded: false,
success: false,
uid: uidOfBookingToBeCancelled,
paymentOption: "HOLD",
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-cancellation`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_CANCELLATION",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
},
});
expect(result.success).toBe(true);
});
test("Should not charge cancellation fee when team admin cancels booking", async () => {
const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Team Admin",
email: "admin@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const teamMember = getOrganizer({
name: "Team Member",
email: "member@example.com",
id: 102,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const uidOfBookingToBeCancelled = "cancellation-fee-team-admin-test";
const idOfBookingToBeCancelled = 4060;
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 2,
slotInterval: 30,
length: 30,
teamId: 1,
users: [{ id: 101 }, { id: 102 }],
hosts: [
{ userId: 101, isFixed: false },
{ userId: 102, isFixed: false },
],
metadata: {
apps: {
stripe: {
enabled: true,
paymentOption: "HOLD",
price: 1000,
currency: "usd",
cancellationFeeEnabled: true,
cancellationFeeTimeValue: 1,
cancellationFeeTimeUnit: "hours",
},
},
},
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 2,
userId: 102,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:30:00.000Z`,
attendees: [
{
email: booker.email,
timeZone: "Asia/Kolkata",
},
],
},
],
payment: [
{
amount: 1000,
bookingId: idOfBookingToBeCancelled,
currency: "usd",
data: {},
externalId: "ext_id_team_cancellation",
fee: 0,
refunded: false,
success: false,
uid: uidOfBookingToBeCancelled,
paymentOption: "HOLD",
},
],
teams: [
{
id: 1,
name: "Test Team",
slug: "test-team",
},
],
users: [organizer, teamMember],
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-team-cancellation`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_TEAM_CANCELLATION",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
},
});
expect(result.success).toBe(true);
});
test("Should charge cancellation fee when attendee cancels within time threshold", async () => {
const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
id: 999,
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const uidOfBookingToBeCancelled = "cancellation-fee-attendee-test";
const idOfBookingToBeCancelled = 4070;
const now = new Date();
const startTime = new Date(now.getTime() + 30 * 60 * 1000);
const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [{ id: 101 }],
metadata: {
apps: {
stripe: {
enabled: true,
paymentOption: "HOLD",
price: 1000,
currency: "usd",
cancellationFeeEnabled: true,
cancellationFeeTimeValue: 1,
cancellationFeeTimeUnit: "hours",
},
},
},
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
attendees: [
{
email: booker.email,
timeZone: "Asia/Kolkata",
},
],
},
],
payment: [
{
amount: 1000,
bookingId: idOfBookingToBeCancelled,
currency: "usd",
data: {},
externalId: "ext_id_attendee_cancellation",
fee: 0,
refunded: false,
success: false,
uid: uidOfBookingToBeCancelled,
paymentOption: "HOLD",
appId: "stripe",
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
videoMeetingData: {
id: "MOCK_ID",
password: "MOCK_PASS",
url: `http://mock-dailyvideo.example.com/meeting-attendee-cancellation`,
},
});
mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_ATTENDEE_CANCELLATION",
},
});
const result = await handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: booker.email,
cancellationReason: "Attendee cancelled within time threshold",
},
userId: 999,
});
expect(result.success).toBe(true);
});
});
@@ -1,6 +1,5 @@
import type { DestinationCalendar, User } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import short, { uuid } from "short-uuid";
import { v5 as uuidv5 } from "uuid";
@@ -14,15 +13,6 @@ import {
import { getAppFromSlug } from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
import {
sendAttendeeRequestEmailAndSMS,
sendOrganizerRequestEmail,
sendRescheduledEmailsAndSMS,
sendRoundRobinCancelledEmailsAndSMS,
sendRoundRobinRescheduledEmailsAndSMS,
sendRoundRobinScheduledEmailsAndSMS,
sendScheduledEmailsAndSMS,
} from "@calcom/emails";
import getICalUID from "@calcom/emails/lib/getICalUID";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types";
@@ -32,10 +22,6 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import type { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getFullName } from "@calcom/features/form-builder/utils";
import { UsersRepository } from "@calcom/features/users/users.repository";
@@ -101,12 +87,12 @@ import type {
AppsStatus,
CalendarEvent,
CalEventResponses,
Person,
} from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload";
import { BookingActionMap, BookingEmailSmsHandler } from "./BookingEmailSmsHandler";
import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials";
import getBookingDataSchema from "./getBookingDataSchema";
@@ -503,6 +489,7 @@ async function handler(
});
const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug);
const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: loggerWithEventDetails });
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
@@ -996,7 +983,7 @@ async function handler(
// Filter out host groups that have no hosts in them
const nonEmptyHostGroups = Object.fromEntries(
Object.entries(hostGroups).filter(([groupId, hosts]) => hosts.length > 0)
Object.entries(hostGroups).filter(([_groupId, hosts]) => hosts.length > 0)
);
// If there are RR hosts, we need to find a lucky user
if (
@@ -1660,7 +1647,7 @@ async function handler(
let videoCallUrl;
//this is the actual rescheduling logic
// this is the actual rescheduling logic
if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
log.silly("Rescheduling booking", originalRescheduledBooking.uid);
// cancel workflow reminders from previous rescheduled booking
@@ -1845,133 +1832,22 @@ async function handler(
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
const copyEventAdditionalInfo = {
...copyEvent,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
};
const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo);
loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation");
/*
handle emails for round robin
- if booked rr host is the same, then rescheduling email
- if new rr host is booked, then cancellation email to old host and confirmation email to new host
*/
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
const originalBookingMemberEmails: Person[] = [];
for (const user of originalRescheduledBooking.attendees) {
const translate = await getTranslation(user.locale ?? "en", "common");
originalBookingMemberEmails.push({
name: user.name,
email: user.email,
timeZone: user.timeZone,
phoneNumber: user.phoneNumber,
language: { translate, locale: user.locale ?? "en" },
});
}
if (originalRescheduledBooking.user) {
const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common");
const originalOrganizer = originalRescheduledBooking.user;
originalBookingMemberEmails.push({
...originalRescheduledBooking.user,
username: originalRescheduledBooking.user.username ?? undefined,
timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat),
name: originalRescheduledBooking.user.name || "",
language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" },
});
if (changedOrganizer) {
cancelledRRHostEvt.title = originalRescheduledBooking.title;
cancelledRRHostEvt.startTime =
dayjs(originalRescheduledBooking?.startTime).utc().format() ||
copyEventAdditionalInfo.startTime;
cancelledRRHostEvt.endTime =
dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime;
cancelledRRHostEvt.organizer = {
email: originalOrganizer.email,
name: originalOrganizer.name || "",
timeZone: originalOrganizer.timeZone,
language: { translate, locale: originalOrganizer.locale || "en" },
};
}
}
const newBookingMemberEmails: Person[] =
copyEvent.team?.members
.map((member) => member)
.concat(copyEvent.organizer)
.concat(copyEvent.attendees) || [];
const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) => {
return originalMember.email === newMember.email;
};
// scheduled Emails
const newBookedMembers = newBookingMemberEmails.filter(
(member) =>
!originalBookingMemberEmails.find((originalMember) =>
matchOriginalMemberWithNewMember(originalMember, member)
)
);
// cancelled Emails
const cancelledMembers = originalBookingMemberEmails.filter(
(member) =>
!newBookingMemberEmails.find((newMember) => matchOriginalMemberWithNewMember(member, newMember))
);
// rescheduled Emails
const rescheduledMembers = newBookingMemberEmails.filter((member) =>
originalBookingMemberEmails.find((orignalMember) =>
matchOriginalMemberWithNewMember(orignalMember, member)
)
);
if (!isDryRun) {
sendRoundRobinRescheduledEmailsAndSMS(
{ ...copyEventAdditionalInfo, iCalUID },
rescheduledMembers,
eventType.metadata
);
sendRoundRobinScheduledEmailsAndSMS({
calEvent: copyEventAdditionalInfo,
members: newBookedMembers,
eventTypeMetadata: eventType.metadata,
});
const reassignedTo = users.find(
(user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email)
);
sendRoundRobinCancelledEmailsAndSMS(
cancelledRRHostEvt,
cancelledMembers,
eventType.metadata,
!!reassignedTo
? {
name: reassignedTo.name,
email: reassignedTo.email,
...(reqBody.rescheduledBy === bookerEmail && { reason: "Booker Rescheduled" }),
}
: undefined
);
}
} else {
if (!isDryRun) {
// send normal rescheduled emails (non round robin event, where organizers stay the same)
await sendRescheduledEmailsAndSMS(
{
...copyEvent,
additionalInformation: metadata,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
},
eventType?.metadata
);
}
}
if (!noEmail && isConfirmedByDefault && !isDryRun) {
await emailsAndSmsHandler.send({
action: BookingActionMap.rescheduled,
data: {
evt,
eventType,
additionalInformation: metadata,
additionalNotes,
iCalUID,
originalRescheduledBooking,
rescheduleReason,
isRescheduledByBooker: reqBody.rescheduledBy === bookerEmail,
users,
changedOrganizer,
},
});
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
// Create a booking
@@ -2076,43 +1952,23 @@ async function handler(
});
}
}
if (noEmail !== true) {
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
isHostConfirmationEmailsDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isAttendeeConfirmationEmailDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
loggerWithEventDetails.debug(
"Emails: Sending scheduled emails for booking confirmation",
safeStringify({
calEvent: getPiiFreeCalendarEvent(evt),
})
);
if (!noEmail) {
if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) {
await sendScheduledEmailsAndSMS(
{
...evt,
await emailsAndSmsHandler.send({
action: BookingActionMap.confirmed,
data: {
eventType: {
metadata: eventType.metadata,
schedulingType: eventType.schedulingType,
},
eventNameObject,
workflows,
evt,
additionalInformation,
additionalNotes,
customInputs,
},
eventNameObject,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled,
eventType.metadata
);
});
}
}
}
@@ -2142,8 +1998,10 @@ async function handler(
})
);
if (!isDryRun) {
await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata);
await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata);
await emailsAndSmsHandler.send({
action: BookingActionMap.requested,
data: { evt, attendees: attendeesList, eventType, additionalNotes },
});
}
}
@@ -2956,5 +2956,154 @@ describe("handleNewBooking", () => {
},
timeout
);
test(
"should set correct booking reference when rescheduling with phone location change",
async () => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "test@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_RESCHEDULED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
users: [
{
id: 101,
},
],
locations: [
{
type: "phone", // ← Original generic phone location
},
],
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
getMockBookingReference({
type: "google_calendar",
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-google-meet.example.com",
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
credentialId: 1,
}),
],
attendees: [
getMockBookingAttendee({
id: 1,
name: booker.name,
email: booker.email,
}),
],
// Different location from the one in the new booking
location: "+15552234567",
},
],
organizer,
usersApartFromOrganizer: [],
apps: [TestData.apps["google-calendar"]],
})
);
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
create: { uid: "NEW_EVENT_ID" },
update: { uid: "UPDATED_EVENT_ID" },
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T06:00:00.000Z`,
end: `${plus1DateString}T06:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "+15551234567", value: "+15551234567" },
},
},
});
const createdBooking = await handleNewBooking({
bookingData: mockBookingData,
});
logger.silly("Created booking", { createdBooking });
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: 1,
// Only recurring event can have recurringEventId
recurringEventId: null,
location: "+15551234567",
status: BookingStatus.ACCEPTED,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
},
});
// ✅ THE CRITICAL TEST: Ensure BookingReference has proper values
const newBooking = await prismaMock.booking.findFirst({
where: {
uid: createdBooking.uid,
},
include: {
references: true,
},
});
expect(newBooking?.references).toHaveLength(1);
const reference = newBooking!.references[0];
// Valid reference should have uid
expect(reference.uid).toBe("UPDATED_EVENT_ID");
},
timeout
);
});
});
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from "uuid";
import { RETELL_AI_TEST_MODE, RETELL_AI_TEST_EVENT_TYPE_MAP } from "@calcom/lib/constants";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -81,6 +82,13 @@ export class AgentService {
});
}
let eventTypeId = data.eventTypeId;
if (RETELL_AI_TEST_MODE && RETELL_AI_TEST_EVENT_TYPE_MAP) {
const mappedId = RETELL_AI_TEST_EVENT_TYPE_MAP[String(data.eventTypeId)];
eventTypeId = mappedId ? Number(mappedId) : data.eventTypeId;
}
try {
const agent = await this.getAgent(agentId);
const llmId = getLlmId(agent);
@@ -99,8 +107,8 @@ export class AgentService {
const existing = llmDetails?.general_tools ?? [];
const hasCheck = existing.some((t) => t.name === `check_availability_${data.eventTypeId}`);
const hasBook = existing.some((t) => t.name === `book_appointment_${data.eventTypeId}`);
const hasCheck = existing.some((t) => t.name === `check_availability_${eventTypeId}`);
const hasBook = existing.some((t) => t.name === `book_appointment_${eventTypeId}`);
// If both already exist and end_call also exists, nothing to do
const hasEndCallAlready = existing.some((t) => t.type === "end_call");
if (hasCheck && hasBook && hasEndCallAlready) {
@@ -113,27 +121,29 @@ export class AgentService {
)?.cal_api_key;
const apiKey =
reusableKey ??
(await this.createApiKey({
userId: data.userId,
teamId: data.teamId || undefined,
}));
RETELL_AI_TEST_MODE && process.env.RETELL_AI_TEST_CAL_API_KEY
? process.env.RETELL_AI_TEST_CAL_API_KEY
: reusableKey ??
(await this.createApiKey({
userId: data.userId,
teamId: data.teamId || undefined,
}));
const newEventTools: NonNullable<AIPhoneServiceTools<AIPhoneServiceProviderType.RETELL_AI>> = [];
if (!hasCheck) {
newEventTools.push({
name: `check_availability_${data.eventTypeId}`,
name: `check_availability_${eventTypeId}`,
type: "check_availability_cal",
event_type_id: data.eventTypeId,
event_type_id: eventTypeId,
cal_api_key: apiKey,
timezone: data.timeZone,
});
}
if (!hasBook) {
newEventTools.push({
name: `book_appointment_${data.eventTypeId}`,
name: `book_appointment_${eventTypeId}`,
type: "book_appointment_cal",
event_type_id: data.eventTypeId,
event_type_id: eventTypeId,
cal_api_key: apiKey,
timezone: data.timeZone,
});
@@ -180,6 +190,15 @@ export class AgentService {
return;
}
let mappedEventTypeIds = eventTypeIds;
if (RETELL_AI_TEST_MODE && RETELL_AI_TEST_EVENT_TYPE_MAP) {
mappedEventTypeIds = eventTypeIds.map((id) => {
const mappedId = RETELL_AI_TEST_EVENT_TYPE_MAP[String(id)];
return mappedId ? Number(mappedId) : id;
});
}
try {
const agent = await this.getAgent(agentId);
const llmId = getLlmId(agent);
@@ -199,7 +218,7 @@ export class AgentService {
const existing = llmDetails?.general_tools ?? [];
const toolNamesToRemove = eventTypeIds.flatMap((eventTypeId) => [
const toolNamesToRemove = mappedEventTypeIds.flatMap((eventTypeId) => [
`check_availability_${eventTypeId}`,
`book_appointment_${eventTypeId}`,
]);
@@ -212,6 +231,7 @@ export class AgentService {
agentId,
llmId,
removedEventTypes: eventTypeIds,
mappedEventTypes: RETELL_AI_TEST_MODE ? mappedEventTypeIds : undefined,
toolsRemoved: existing.length - filteredTools.length,
});
}
@@ -240,6 +260,15 @@ export class AgentService {
});
}
let mappedActiveEventTypeIds = activeEventTypeIds;
if (RETELL_AI_TEST_MODE && RETELL_AI_TEST_EVENT_TYPE_MAP) {
mappedActiveEventTypeIds = activeEventTypeIds.map((id) => {
const mappedId = RETELL_AI_TEST_EVENT_TYPE_MAP[String(id)];
return mappedId ? Number(mappedId) : id;
});
}
try {
const agent = await this.getAgent(agentId);
const llmId = getLlmId(agent);
@@ -269,7 +298,7 @@ export class AgentService {
if (!eventTypeIdMatch) return false;
const eventTypeId = parseInt(eventTypeIdMatch[1]);
return !activeEventTypeIds.includes(eventTypeId);
return !mappedActiveEventTypeIds.includes(eventTypeId);
});
if (toolsToRemove.length > 0) {
@@ -303,6 +332,7 @@ export class AgentService {
this.logger.error("Failed to cleanup unused tools for agent", {
agentId,
activeEventTypeIds,
mappedActiveEventTypeIds: RETELL_AI_TEST_MODE ? mappedActiveEventTypeIds : undefined,
error,
});
throw new HttpError({
@@ -1,6 +1,6 @@
import type { FunctionComponent, SVGProps } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
+26 -4
View File
@@ -447,12 +447,34 @@ export default class EventManager {
}
const referencesToCreate = results.map((result) => {
// For update operations, check updatedEvent first, then fall back to createdEvent
const updatedEvent = Array.isArray(result.updatedEvent) ? result.updatedEvent[0] : result.updatedEvent;
const createdEvent = result.createdEvent;
let event = updatedEvent;
if (!event) {
log.warn(
"updateLocation: No updatedEvent when doing updateLocation. Falling back to createdEvent but this is probably not what we want",
safeStringify({ bookingId: booking.id })
);
event = createdEvent;
}
const uid = event?.id?.toString() ?? "";
const meetingId = event?.id?.toString();
if (!uid) {
log.error(
"updateLocation: No uid for booking reference. The corresponding record in third party if created is orphan now",
safeStringify({ result })
);
}
return {
type: result.type,
uid: result.createdEvent?.id?.toString() ?? "",
meetingId: result.createdEvent?.id?.toString(),
meetingPassword: result.createdEvent?.password,
meetingUrl: result.createdEvent?.url,
uid,
meetingId,
meetingPassword: event?.password,
meetingUrl: event?.url,
externalCalendarId: result.externalId,
...(result.credentialId && result.credentialId > 0 ? { credentialId: result.credentialId } : {}),
};
+12 -3
View File
@@ -68,9 +68,6 @@ export const MAX_EVENT_DURATION_MINUTES = 1440;
/** Minimum duration allowed for an event in minutes */
export const MIN_EVENT_DURATION_MINUTES = 1;
/** Maximum file size allowed for banner uploads in bytes (5MB) */
export const MAX_BANNER_SIZE = 5 * 1024 * 1024;
export const HOSTED_CAL_FEATURES = process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED;
export const PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS =
@@ -243,3 +240,15 @@ export const CAL_AI_PHONE_NUMBER_MONTHLY_PRICE = (() => {
const parsed = _rawCalAiPrice && _rawCalAiPrice.trim() !== "" ? Number(_rawCalAiPrice) : NaN;
return Number.isFinite(parsed) ? parsed : 5;
})();
// Retell AI test mode configuration
export const RETELL_AI_TEST_MODE = process.env.RETELL_AI_TEST_MODE === "true";
export const RETELL_AI_TEST_EVENT_TYPE_MAP = (() => {
if (!process.env.RETELL_AI_TEST_EVENT_TYPE_MAP) return null;
try {
return JSON.parse(process.env.RETELL_AI_TEST_EVENT_TYPE_MAP);
} catch (e) {
console.warn("Failed to parse RETELL_AI_TEST_EVENT_TYPE_MAP", e);
return null;
}
})();
-71
View File
@@ -1,71 +0,0 @@
/**
* Shared image validation constants and utilities
* Used by both server-side and client-side validation modules
*/
/**
* Magic numbers (file signatures) for different file types
*/
export const FILE_SIGNATURES = {
PNG: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
JPEG_FF_D8_FF: [0xff, 0xd8, 0xff],
GIF87a: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
GIF89a: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
WEBP: [0x52, 0x49, 0x46, 0x46],
WEBP_SIGNATURE: [0x57, 0x45, 0x42, 0x50],
BMP: [0x42, 0x4d],
ICO: [0x00, 0x00, 0x01, 0x00],
SVG: [0x3c, 0x3f, 0x78, 0x6d, 0x6c],
SVG_DIRECT: [0x3c, 0x73, 0x76, 0x67],
PDF: [0x25, 0x50, 0x44, 0x46],
HTML: [0x3c, 0x21, 0x44, 0x4f, 0x43, 0x54, 0x59, 0x50, 0x45],
HTML_TAG: [0x3c, 0x68, 0x74, 0x6d, 0x6c],
SCRIPT_TAG: [0x3c, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74],
ZIP: [0x50, 0x4b, 0x03, 0x04],
EXECUTABLE: [0x4d, 0x5a],
} as const;
/**
* Check if bytes match a signature
*/
export function matchesSignature(data: Uint8Array, signature: readonly number[]): boolean {
if (data.length < signature.length) return false;
return signature.every((byte, index) => data[index] === byte);
}
/**
* SVG content validation patterns
*/
export const DANGEROUS_SVG_PATTERNS = [
"<script",
"javascript:",
"onload=",
"onclick=",
"onmouseover=",
"onmouseout=",
"onfocus=",
"onblur=",
] as const;
/**
* Validate SVG content for dangerous patterns
*/
export function containsDangerousSVGContent(content: string): boolean {
return DANGEROUS_SVG_PATTERNS.some((pattern) => content.includes(pattern));
}
/**
* Base64 regex pattern that matches valid base64 strings
* - Matches complete base64 groups of 4 characters
* - Handles proper padding with = characters
* - Supports both padded and unpadded forms correctly
*/
const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=)?$/;
/**
* Validate base64 string format using strict regex
*/
export function isValidBase64(str: string): boolean {
return BASE64_REGEX.test(str);
}
@@ -0,0 +1,453 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// eslint-disable-next-line no-restricted-imports
import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { handleNoShowFee } from "./handleNoShowFee";
vi.mock("@calcom/app-store/payment.services.generated", () => ({
PaymentServiceMap: {
stripepayment: Promise.resolve({
PaymentService: vi.fn().mockImplementation(() => ({
chargeCard: vi.fn(),
})),
}),
},
}));
vi.mock("@calcom/emails", () => ({
sendNoShowFeeChargedEmail: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/lib/server/repository/credential", () => ({
CredentialRepository: {
findPaymentCredentialByAppIdAndUserIdOrTeamId: vi.fn(),
findPaymentCredentialByAppIdAndTeamId: vi.fn(),
},
}));
vi.mock("@calcom/lib/server/repository/membership", () => ({
MembershipRepository: {
findUniqueByUserIdAndTeamId: vi.fn(),
},
}));
vi.mock("@calcom/lib/server/repository/team", () => ({
TeamRepository: vi.fn().mockImplementation(() => ({
findParentOrganizationByTeamId: vi.fn(),
})),
}));
vi.mock("@calcom/prisma", () => ({
default: {},
}));
describe("handleNoShowFee", () => {
let mockPaymentService: { chargeCard: ReturnType<typeof vi.fn> };
beforeEach(async () => {
vi.clearAllMocks();
mockPaymentService = {
chargeCard: vi.fn(),
};
const paymentServiceModule = await PaymentServiceMap.stripepayment;
vi.mocked(paymentServiceModule.PaymentService).mockImplementation(() => mockPaymentService);
});
const mockBooking = {
id: 1,
uid: "booking-123",
title: "Test Meeting",
startTime: new Date("2024-09-01T10:00:00Z"),
endTime: new Date("2024-09-01T11:00:00Z"),
userPrimaryEmail: "organizer@example.com",
userId: 1,
user: {
email: "organizer@example.com",
name: "John Organizer",
locale: "en",
timeZone: "UTC",
},
eventType: {
title: "Test Event Type",
hideOrganizerEmail: false,
teamId: null,
metadata: {},
},
attendees: [
{
name: "Jane Attendee",
email: "attendee@example.com",
timeZone: "UTC",
locale: "en",
},
],
};
const mockPayment = {
id: 1,
amount: 5000,
currency: "USD",
paymentOption: "HOLD",
appId: "stripepayment",
};
const mockCredential = {
id: 1,
type: "stripepayment_payment",
key: { test: "key" },
userId: 1,
teamId: null,
appId: "stripepayment",
invalid: false,
createdAt: new Date(),
updatedAt: new Date(),
app: {
keys: { test: "key" },
slug: "stripepayment",
createdAt: new Date(),
updatedAt: new Date(),
dirName: "stripepayment",
categories: ["payment"],
enabled: true,
},
};
describe("successful scenarios", () => {
it("should successfully process no-show fee for individual user", async () => {
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
vi.mocked(sendNoShowFeeChargedEmail).mockResolvedValue(undefined);
const result = await handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(mockPaymentService.chargeCard).toHaveBeenCalledWith(mockPayment, mockBooking.id);
expect(sendNoShowFeeChargedEmail).toHaveBeenCalled();
});
it("should successfully process no-show fee for team event", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 1,
teamId: 1,
role: "MEMBER",
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
accepted: true,
customRoleId: null,
});
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: teamBooking,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
userId: 1,
teamId: 1,
});
});
it("should find credential from parent organization when team credential not found", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 1,
teamId: 1,
role: "MEMBER",
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
accepted: true,
customRoleId: null,
});
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockCredential);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).mockResolvedValue(mockCredential);
const mockTeamRepository = {
findParentOrganizationByTeamId: vi.fn().mockResolvedValue({ id: 2 }),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepository);
const result = await handleNoShowFee({
booking: teamBooking,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(mockTeamRepository.findParentOrganizationByTeamId).toHaveBeenCalledWith(1);
expect(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).toHaveBeenCalledWith({
appId: "stripepayment",
teamId: 2,
});
});
});
describe("error scenarios", () => {
it("should throw error when userId is missing", async () => {
const bookingWithoutUser = {
...mockBooking,
userId: null,
};
await expect(
handleNoShowFee({
booking: bookingWithoutUser,
payment: mockPayment,
})
).rejects.toThrow("User ID is required");
});
it("should throw error when user is not a member of the team", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue(null);
await expect(
handleNoShowFee({
booking: teamBooking,
payment: mockPayment,
})
).rejects.toThrow("User is not a member of the team");
});
it("should throw error when no payment credential is found", async () => {
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockReset();
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).mockReset();
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(null);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).mockResolvedValue(null);
const bookingWithoutCredential = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: null,
},
};
await expect(
handleNoShowFee({
booking: bookingWithoutCredential,
payment: mockPayment,
})
).rejects.toThrow("No payment credential found");
});
it("should throw error when payment app is not implemented", async () => {
const paymentWithUnknownApp = {
...mockPayment,
appId: "unknown-app",
};
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockImplementation(
async () => ({
...mockCredential,
app: {
...mockCredential.app,
dirName: "unknown-app",
},
})
);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: paymentWithUnknownApp,
})
).rejects.toThrow("Payment app not implemented");
});
it("should throw error when payment service is not found", async () => {
const originalStripepayment = PaymentServiceMap.stripepayment;
// @ts-expect-error - Mocking for test
PaymentServiceMap.stripepayment = Promise.resolve({});
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
try {
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow("Payment service not found");
} finally {
// @ts-expect-error - Restoring for test
PaymentServiceMap.stripepayment = originalStripepayment;
}
});
it("should throw error when payment processing fails", async () => {
mockPaymentService.chargeCard.mockResolvedValue(null);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow("Payment processing failed");
});
it("should handle ChargeCardFailure error with proper message", async () => {
mockPaymentService.chargeCard.mockRejectedValue(
new ErrorWithCode(ErrorCode.ChargeCardFailure, "Card declined")
);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
vi.mocked(getTranslation).mockResolvedValue((key: string) => `Translated: ${key}`);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow("Translated: Card declined");
});
it("should handle generic payment errors", async () => {
mockPaymentService.chargeCard.mockRejectedValue(new Error("Generic payment error"));
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
vi.mocked(getTranslation).mockResolvedValue((key: string) => `Translated: ${key}`);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow(/Translated: Error processing paymentId 1 with error/);
});
});
describe("edge cases", () => {
it("should handle booking without event type", async () => {
const bookingWithoutEventType = {
...mockBooking,
eventType: null,
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: bookingWithoutEventType,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
});
it("should handle booking without user details", async () => {
const bookingWithoutUserDetails = {
...mockBooking,
user: null,
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: bookingWithoutUserDetails,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
});
it("should handle attendees without locale", async () => {
const bookingWithAttendeesWithoutLocale = {
...mockBooking,
attendees: [
{
name: "Jane Attendee",
email: "attendee@example.com",
timeZone: "UTC",
locale: null,
},
],
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: bookingWithAttendeesWithoutLocale,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(getTranslation).toHaveBeenCalledWith("en", "common");
});
});
});
+173
View File
@@ -0,0 +1,173 @@
import type { Prisma } from "@prisma/client";
// eslint-disable-next-line
import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
import dayjs from "@calcom/dayjs";
import { sendNoShowFeeChargedEmail } from "@calcom/emails";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import prisma from "@calcom/prisma";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
export const handleNoShowFee = async ({
booking,
payment,
}: {
booking: {
id: number;
uid: string;
title: string;
startTime: Date;
endTime: Date;
userPrimaryEmail: string | null;
userId: number | null;
user?: {
email: string;
name?: string | null;
locale: string | null;
timeZone: string;
} | null;
eventType: {
title: string;
hideOrganizerEmail: boolean;
teamId: number | null;
metadata?: Prisma.JsonValue;
} | null;
attendees: {
name: string;
email: string;
timeZone: string;
locale: string | null;
}[];
};
payment: {
id: number;
amount: number;
currency: string;
paymentOption: string | null;
appId: string | null;
};
}) => {
const log = logger.getSubLogger({ prefix: [`[handleNoShowFee] bookingUid ${booking.uid}`] });
const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common");
const userId = booking.userId;
const teamId = booking.eventType?.teamId;
const appId = payment.appId;
const eventTypeMetdata = eventTypeMetaDataSchemaWithTypedApps.parse(booking.eventType?.metadata ?? {});
if (!userId) {
log.error("User ID is required");
throw new Error("User ID is required");
}
const bookingAttendee = booking.attendees[0];
const attendee = {
name: bookingAttendee.name,
email: bookingAttendee.email,
timeZone: bookingAttendee.timeZone,
language: {
translate: await getTranslation(bookingAttendee.locale ?? "en", "common"),
locale: bookingAttendee.locale ?? "en",
},
};
const evt: CalendarEvent = {
type: (booking?.eventType?.title as string) || booking?.title,
title: booking.title,
startTime: dayjs(booking.startTime).format(),
endTime: dayjs(booking.endTime).format(),
organizer: {
email: booking?.userPrimaryEmail ?? booking.user?.email ?? "",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "",
language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
},
attendees: [attendee],
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
paymentInfo: {
amount: payment.amount,
currency: payment.currency,
paymentOption: payment.paymentOption,
},
};
if (teamId) {
const userIsInTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({
userId,
teamId,
});
if (!userIsInTeam) {
log.error(`User ${userId} is not a member of team ${teamId}`);
throw new Error("User is not a member of the team");
}
}
let paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId({
appId,
userId,
teamId,
});
if (!paymentCredential && teamId) {
const teamRepository = new TeamRepository(prisma);
// See if the team event belongs to an org
const org = await teamRepository.findParentOrganizationByTeamId(teamId);
if (org) {
paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndTeamId({
appId,
teamId: org.id,
});
}
}
if (!paymentCredential) {
log.error(`No payment credential found for user ${userId} or team ${teamId}`);
throw new Error("No payment credential found");
}
const key = paymentCredential?.app?.dirName;
const paymentAppImportFn = PaymentServiceMap[key as keyof typeof PaymentServiceMap];
if (!paymentAppImportFn) {
log.error(`Payment app ${key} not implemented`);
throw new Error("Payment app not implemented");
}
const paymentApp = await paymentAppImportFn;
if (!paymentApp?.PaymentService) {
log.error(`Payment service not found for app ${key}`);
throw new Error("Payment service not found");
}
const PaymentService = paymentApp.PaymentService;
const paymentInstance = new PaymentService(paymentCredential) as IAbstractPaymentService;
try {
const paymentData = await paymentInstance.chargeCard(payment, booking.id);
if (!paymentData) {
log.error(`Error processing payment with paymentId ${payment.id}`);
throw new Error("Payment processing failed");
}
await sendNoShowFeeChargedEmail(attendee, evt, eventTypeMetdata);
return paymentData;
} catch (err) {
let errorMessage = `Error processing paymentId ${payment.id} with error ${err}`;
if (err instanceof ErrorWithCode && err.code === ErrorCode.ChargeCardFailure) {
errorMessage = err.message;
}
log.error(errorMessage);
throw new Error(tOrganizer(errorMessage));
}
};
@@ -0,0 +1,509 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { Payment } from "@calcom/prisma/client";
import { handleNoShowFee } from "./handleNoShowFee";
import { processNoShowFeeOnCancellation } from "./processNoShowFeeOnCancellation";
import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
vi.mock("@calcom/lib/server/repository/membership", () => ({
MembershipRepository: {
findUniqueByUserIdAndTeamId: vi.fn(),
},
}));
vi.mock("./handleNoShowFee", () => ({
handleNoShowFee: vi.fn(),
}));
vi.mock("./shouldChargeNoShowCancellationFee", () => ({
shouldChargeNoShowCancellationFee: vi.fn(),
}));
describe("processNoShowFeeOnCancellation", () => {
const mockBooking = {
id: 1,
uid: "booking-123",
title: "Test Meeting",
startTime: new Date("2024-09-01T10:00:00Z"),
endTime: new Date("2024-09-01T11:00:00Z"),
userPrimaryEmail: "organizer@example.com",
userId: 1,
user: {
email: "organizer@example.com",
name: "John Organizer",
locale: "en",
timeZone: "UTC",
},
eventType: {
title: "Test Event Type",
hideOrganizerEmail: false,
teamId: null,
metadata: {},
},
attendees: [
{
name: "Jane Attendee",
email: "attendee@example.com",
timeZone: "UTC",
locale: "en",
},
],
};
const mockHoldPayment: Payment = {
id: 1,
uid: "payment-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: false,
refunded: false,
data: {},
externalId: "ext_123",
paymentOption: "HOLD",
};
const mockSuccessfulPayment: Payment = {
...mockHoldPayment,
id: 2,
success: true,
paymentOption: "ON_BOOKING",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("successful scenarios", () => {
it("should successfully process no-show fee when conditions are met", async () => {
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: mockBooking,
eventTypeMetadata: {},
payment: mockHoldPayment,
});
expect(handleNoShowFee).toHaveBeenCalledWith({
booking: mockBooking,
payment: mockHoldPayment,
});
});
it("should process no-show fee when cancelled by attendee (no cancelledByUserId)", async () => {
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
});
expect(handleNoShowFee).toHaveBeenCalledWith({
booking: mockBooking,
payment: mockHoldPayment,
});
});
});
describe("skip scenarios - organizer cancellation", () => {
it("should skip no-show fee when cancelled by organizer", async () => {
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 1,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
});
describe("skip scenarios - team admin cancellation", () => {
it("should skip no-show fee when cancelled by team admin", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 999,
teamId: 1,
role: "ADMIN",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
customRoleId: null,
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
userId: 999,
teamId: 1,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should skip no-show fee when cancelled by team owner", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 999,
teamId: 1,
role: "OWNER",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
customRoleId: null,
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should process no-show fee when cancelled by team member (not admin/owner)", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 999,
teamId: 1,
role: "MEMBER",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
customRoleId: null,
});
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
userId: 999,
teamId: 1,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalled();
expect(handleNoShowFee).toHaveBeenCalled();
});
it("should process no-show fee when cancelled by non-team member", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue(null);
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalled();
expect(handleNoShowFee).toHaveBeenCalled();
});
});
describe("skip scenarios - payment conditions", () => {
it("should skip no-show fee when no HOLD payment found", async () => {
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockSuccessfulPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should skip no-show fee when no payments provided", async () => {
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should use first HOLD payment when multiple exist", async () => {
const secondHoldPayment: Payment = {
...mockHoldPayment,
id: 3,
uid: "payment-456",
};
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockSuccessfulPayment, mockHoldPayment, secondHoldPayment],
cancelledByUserId: 999,
});
expect(handleNoShowFee).toHaveBeenCalledWith({
booking: mockBooking,
payment: mockHoldPayment,
});
});
});
describe("skip scenarios - time threshold", () => {
it("should skip no-show fee when outside time threshold", async () => {
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(false);
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: mockBooking,
eventTypeMetadata: {},
payment: mockHoldPayment,
});
expect(handleNoShowFee).not.toHaveBeenCalled();
});
});
describe("error scenarios", () => {
it("should throw error when handleNoShowFee fails", async () => {
const error = new Error("Payment processing failed");
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockRejectedValue(error);
await expect(
processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
})
).rejects.toThrow("Failed to charge no-show fee with error Error: Payment processing failed");
});
});
describe("edge cases", () => {
it("should handle booking without event type", async () => {
const bookingWithoutEventType = {
...mockBooking,
eventType: null,
};
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: bookingWithoutEventType,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: bookingWithoutEventType,
eventTypeMetadata: {},
payment: mockHoldPayment,
});
expect(handleNoShowFee).toHaveBeenCalled();
});
it("should handle booking with complex event type metadata", async () => {
const bookingWithMetadata = {
...mockBooking,
eventType: {
...mockBooking.eventType,
metadata: {
apps: {
stripe: {
enabled: true,
price: 1000,
currency: "usd",
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "HOLD",
autoChargeNoShowFeeTimeValue: 2,
autoChargeNoShowFeeTimeUnit: "hours",
},
},
},
},
};
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: bookingWithMetadata,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: bookingWithMetadata,
eventTypeMetadata: bookingWithMetadata.eventType.metadata,
payment: mockHoldPayment,
});
});
it("should handle successful HOLD payment (should still be processed)", async () => {
const successfulHoldPayment: Payment = {
...mockHoldPayment,
success: true,
};
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [successfulHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,77 @@
import logger from "@calcom/lib/logger";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { Payment } from "@calcom/prisma/client";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
import { handleNoShowFee } from "./handleNoShowFee";
import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
export const processNoShowFeeOnCancellation = async ({
booking,
payments,
cancelledByUserId,
}: {
booking: Parameters<typeof handleNoShowFee>[0]["booking"];
payments: Payment[];
cancelledByUserId?: number;
}) => {
const log = logger.getSubLogger({ prefix: ["processNoShowFeeOnCancellation"] });
// Skip no-show fee if the booking was cancelled by the organizer or team/org admin
if (cancelledByUserId && booking.userId === cancelledByUserId) {
log.info(
`Booking ${booking.uid} was cancelled by the organizer (${cancelledByUserId}), skipping no-show fee`
);
return;
}
// Skip no-show fee if the booking was cancelled by a team/org admin
if (cancelledByUserId && booking.eventType?.teamId) {
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
userId: cancelledByUserId,
teamId: booking.eventType.teamId,
});
if (membership && (membership.role === "ADMIN" || membership.role === "OWNER")) {
log.info(
`Booking ${booking.uid} was cancelled by team admin/owner (${cancelledByUserId}), skipping no-show fee`
);
return;
}
}
const paymentToCharge = payments.find(
(payment) => payment.paymentOption === "HOLD" && payment.success === false
);
if (!paymentToCharge) {
log.info(`No payment found to charge for booking ${booking.uid}`);
return;
}
// Parse the event type metadata
const eventTypeMetadata = eventTypeMetaDataSchemaWithTypedApps.parse(booking.eventType?.metadata ?? {});
// Determine if we need to charge the no-show fee
const shouldChargeNoShowFee = shouldChargeNoShowCancellationFee({
booking,
eventTypeMetadata,
payment: paymentToCharge,
});
if (!shouldChargeNoShowFee) {
log.info(`Date is not valid for no-show fee to charge for booking ${booking.uid}`);
return;
}
// Process charging the no show fee
try {
await handleNoShowFee({
booking,
payment: paymentToCharge,
});
} catch (error) {
log.error(`Error charging no-show fee for booking ${booking.uid}`, error);
throw new Error(`Failed to charge no-show fee with error ${error}`);
}
};
@@ -0,0 +1,187 @@
import { describe, expect, it, vi, beforeAll } from "vitest";
import type { z } from "zod";
import type { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
type EventTypeMetadata = z.infer<typeof eventTypeMetaDataSchemaWithTypedApps>;
beforeAll(() => {
vi.setSystemTime(new Date("2024-09-01T10:00:00Z"));
});
describe("shouldChargeCancellationFee", () => {
const mockEventTypeMetadata = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "HOLD",
autoChargeNoShowFeeTimeValue: 2,
autoChargeNoShowFeeTimeUnit: "hours",
},
},
};
const mockPayment = {
appId: "stripe",
};
it("should return false if cancellation fee is not enabled", () => {
const eventTypeMetadata = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: false,
paymentOption: "HOLD",
},
},
};
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: eventTypeMetadata as EventTypeMetadata,
booking: { startTime: new Date("2024-09-01T12:00:00Z") },
payment: mockPayment,
});
expect(result).toBe(false);
});
it("should return false if payment option is not HOLD", () => {
const eventTypeMetadata = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "ON_BOOKING",
},
},
};
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: eventTypeMetadata as EventTypeMetadata,
booking: { startTime: new Date("2024-09-01T12:00:00Z") },
payment: mockPayment,
});
expect(result).toBe(false);
});
it("should return true if cancelled within time threshold (hours)", () => {
const startTime = new Date("2024-09-01T11:30:00Z");
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
booking: { startTime },
payment: mockPayment,
});
expect(result).toBe(true);
});
it("should return false if cancelled outside time threshold (hours)", () => {
const startTime = new Date("2024-09-01T13:00:00Z");
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
booking: { startTime },
payment: mockPayment,
});
expect(result).toBe(false);
});
it("should handle minutes time unit correctly", () => {
const mockEventTypeMetadata = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "HOLD",
autoChargeNoShowFeeTimeValue: 30,
autoChargeNoShowFeeTimeUnit: "minutes",
},
},
};
const startTime = new Date("2024-09-01T10:15:00Z");
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
booking: { startTime },
payment: mockPayment,
});
expect(result).toBe(true);
});
it("should handle days time unit correctly", () => {
const mockEventTypeMetadata = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "HOLD",
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "days",
},
},
};
const startTime = new Date("2024-09-02T09:00:00Z");
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
booking: { startTime },
payment: mockPayment,
});
expect(result).toBe(true);
});
it("should handle invalid metadata gracefully", () => {
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: null as EventTypeMetadata,
booking: { startTime: new Date("2024-09-01T12:00:00Z") },
payment: mockPayment,
});
expect(result).toBe(false);
});
it("should return false when timeValue is missing", () => {
const mockEventTypeMetadata = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "HOLD",
autoChargeNoShowFeeTimeUnit: "days",
},
},
};
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
booking: { startTime: new Date("2024-09-01T12:00:00Z") },
payment: mockPayment,
});
expect(result).toBe(false);
});
it("should return false when timeUnit is missing", () => {
const mockEventTypeMetadata = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "HOLD",
autoChargeNoShowFeeTimeValue: 24,
},
},
};
const result = shouldChargeNoShowCancellationFee({
eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
booking: { startTime: new Date("2024-09-01T12:00:00Z") },
payment: mockPayment,
});
expect(result).toBe(false);
});
});
@@ -0,0 +1,58 @@
import type { z } from "zod";
import type { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
export const shouldChargeNoShowCancellationFee = ({
eventTypeMetadata,
booking,
payment,
}: {
eventTypeMetadata: z.infer<typeof eventTypeMetaDataSchemaWithTypedApps>;
booking: {
startTime: Date;
};
payment: {
appId?: string | null;
};
}) => {
const paymentAppId = payment?.appId;
if (typeof paymentAppId !== "string") {
return false;
}
const cancellationFeeEnabled =
eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]
?.autoChargeNoShowFeeIfCancelled;
const paymentOption =
eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]?.paymentOption;
const timeValue =
eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]
?.autoChargeNoShowFeeTimeValue;
const timeUnit =
eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]
?.autoChargeNoShowFeeTimeUnit;
if (!cancellationFeeEnabled || paymentOption !== "HOLD" || !booking?.startTime) {
return false;
}
if (!timeValue || !timeUnit) {
return false;
}
const multiplier: { [key: string]: number } = {
minutes: 1,
hours: 60,
days: 1440,
};
const timeInMinutes = timeValue * multiplier[timeUnit];
const now = new Date();
const startTime = new Date(booking.startTime);
const threshold = new Date(startTime);
threshold.setMinutes(threshold.getMinutes() - timeInMinutes);
return now >= threshold;
};
+37
View File
@@ -0,0 +1,37 @@
import { localStorage } from "@calcom/lib/webstorage";
export interface RecentImpersonation {
username: string;
timestamp: number;
}
const STORAGE_KEY = "cal-recent-impersonations";
const MAX_RECENT = 5;
export function getRecentImpersonations(): RecentImpersonation[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
} catch {
return [];
}
}
export function addRecentImpersonation(usernameRaw: string): void {
try {
const recent = getRecentImpersonations();
const username = usernameRaw.trim().toLowerCase();
if (!username) return;
const filtered = recent.filter((item) => item.username !== username);
const updated = [{ username, timestamp: Date.now() }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch {}
}
export function clearRecentImpersonations(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
}
-11
View File
@@ -3,14 +3,8 @@ import { v4 as uuidv4 } from "uuid";
import { prisma } from "@calcom/prisma";
import { convertSvgToPng } from "./imageUtils";
import { validateBase64Image } from "./imageValidation";
export const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => {
const validation = validateBase64Image(data);
if (!validation.isValid) {
throw new Error(`Invalid image data: ${validation.error}`);
}
const objectKey = uuidv4();
const processedData = await convertSvgToPng(data);
@@ -46,11 +40,6 @@ export const uploadLogo = async ({
logo: string;
isBanner?: boolean;
}): Promise<string> => {
const validation = validateBase64Image(data);
if (!validation.isValid) {
throw new Error(`Invalid image data: ${validation.error}`);
}
const objectKey = uuidv4();
const processedData = await convertSvgToPng(data);
-320
View File
@@ -1,320 +0,0 @@
import { describe, it, expect } from "vitest";
import { FILE_SIGNATURES } from "@calcom/lib/imageValidationConstants";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
describe("validateBase64Image", () => {
const createBase64Data = (bytes: number[], mimeType = "image/png") => {
const buffer = Buffer.from(bytes);
return `data:${mimeType};base64,${buffer.toString("base64")}`;
};
describe("Valid image formats", () => {
it("should validate PNG images", () => {
const pngBytes = [...FILE_SIGNATURES.PNG, ...Array(10).fill(0)];
const base64Data = createBase64Data(pngBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("PNG");
expect(result.error).toBeUndefined();
});
it("should validate JPEG images", () => {
const jpegBytes = [...FILE_SIGNATURES.JPEG_FF_D8_FF, 0xe0, ...Array(10).fill(0)];
const base64Data = createBase64Data(jpegBytes, "image/jpeg");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("JPEG");
});
it("should validate GIF87a images", () => {
const gifBytes = [...FILE_SIGNATURES.GIF87a, ...Array(10).fill(0)];
const base64Data = createBase64Data(gifBytes, "image/gif");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("GIF");
});
it("should validate GIF89a images", () => {
const gifBytes = [...FILE_SIGNATURES.GIF89a, ...Array(10).fill(0)];
const base64Data = createBase64Data(gifBytes, "image/gif");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("GIF");
});
it("should validate WEBP images", () => {
const webpBytes = [
...FILE_SIGNATURES.WEBP,
0x00,
0x00,
0x00,
0x00,
...FILE_SIGNATURES.WEBP_SIGNATURE,
...Array(10).fill(0),
];
const base64Data = createBase64Data(webpBytes, "image/webp");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("WEBP");
});
it("should validate BMP images", () => {
const bmpBytes = [...FILE_SIGNATURES.BMP, ...Array(10).fill(0)];
const base64Data = createBase64Data(bmpBytes, "image/bmp");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("BMP");
});
it("should validate ICO images", () => {
const icoBytes = [...FILE_SIGNATURES.ICO, ...Array(10).fill(0)];
const base64Data = createBase64Data(icoBytes, "image/x-icon");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("ICO");
});
it("should validate safe SVG images", () => {
const svgContent =
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("SVG");
});
it("should validate SVG with direct <svg> tag", () => {
const svgContent = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("SVG");
});
});
describe("Dangerous file types", () => {
it("should reject PDF files", () => {
const pdfBytes = [...FILE_SIGNATURES.PDF, 0x2d, 0x31, 0x2e, 0x34];
const base64Data = createBase64Data(pdfBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("PDF");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "PDF" });
});
it("should reject HTML files with DOCTYPE", () => {
const htmlBytes = [...FILE_SIGNATURES.HTML];
const base64Data = createBase64Data(htmlBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("HTML");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "HTML" });
});
it("should reject HTML files with <html> tag", () => {
const htmlBytes = [...FILE_SIGNATURES.HTML_TAG, 0x3e];
const base64Data = createBase64Data(htmlBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("HTML");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "HTML" });
});
it("should reject script files", () => {
const scriptBytes = [...FILE_SIGNATURES.SCRIPT_TAG];
const base64Data = createBase64Data(scriptBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Script");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "Script" });
});
it("should reject ZIP files", () => {
const zipBytes = [...FILE_SIGNATURES.ZIP];
const base64Data = createBase64Data(zipBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("ZIP");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "ZIP" });
});
it("should reject executable files", () => {
const exeBytes = [...FILE_SIGNATURES.EXECUTABLE];
const base64Data = createBase64Data(exeBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Executable");
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "Executable" });
});
it("should reject SVG with script content", () => {
const svgContent = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("xss")</script></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("SVG");
expect(result.error).toBe("svg_contains_dangerous_content");
});
it("should reject SVG with javascript: URLs", () => {
const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)">link</a></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("SVG");
expect(result.error).toBe("svg_contains_dangerous_content");
});
it("should reject SVG with onload handlers", () => {
const svgContent = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>';
const svgBytes = Array.from(Buffer.from(svgContent, "utf8"));
const base64Data = createBase64Data(svgBytes, "image/svg+xml");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("SVG");
expect(result.error).toBe("svg_contains_dangerous_content");
});
});
describe("Error format validation", () => {
it("should return errorKey and errorParams for unsupported file types", () => {
const pdfBytes = [...FILE_SIGNATURES.PDF, 0x2d, 0x31, 0x2e, 0x34];
const base64Data = createBase64Data(pdfBytes);
const result = validateBase64Image(base64Data);
expect(result).toHaveProperty("isValid");
expect(result).toHaveProperty("errorKey");
expect(result).toHaveProperty("errorParams");
expect(result).toHaveProperty("detectedFormat");
expect(result.isValid).toBe(false);
expect(result.errorKey).toBe("unsupported_file_type");
expect(result.errorParams).toEqual({ type: "PDF" });
});
it("should return error field for non-interpolated errors", () => {
const base64Data = "data:image/png;base64,";
const result = validateBase64Image(base64Data);
expect(result).toHaveProperty("isValid");
expect(result).toHaveProperty("error");
expect(result.isValid).toBe(false);
expect(result.error).toBe("empty_image_data");
expect(result.errorKey).toBeUndefined();
expect(result.errorParams).toBeUndefined();
});
});
describe("Edge cases", () => {
it("should handle empty image data", () => {
const base64Data = "data:image/png;base64,";
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.error).toBe("empty_image_data");
});
it("should handle malformed base64", () => {
const base64Data = "data:image/png;base64,invalid-base64!@#$";
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.error).toBe("invalid_base64_format");
});
it("should handle unrecognized file format", () => {
const unknownBytes = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
const base64Data = createBase64Data(unknownBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Unknown");
expect(result.error).toBe("unrecognized_image_format");
});
it("should handle files too short for magic number detection", () => {
const shortBytes = [0x89];
const base64Data = createBase64Data(shortBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Unknown");
expect(result.error).toBe("unrecognized_image_format");
});
it("should handle WEBP files without proper WEBP signature", () => {
const fakeWebpBytes = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x46, 0x41, 0x4b, 0x45];
const base64Data = createBase64Data(fakeWebpBytes);
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(false);
expect(result.detectedFormat).toBe("Unknown");
expect(result.error).toBe("unrecognized_image_format");
});
it("should handle base64 data without proper data URL prefix", () => {
const pngBytes = [...FILE_SIGNATURES.PNG];
const buffer = Buffer.from(pngBytes);
const base64Data = buffer.toString("base64");
const result = validateBase64Image(base64Data);
expect(result.isValid).toBe(true);
expect(result.detectedFormat).toBe("PNG");
});
});
});
-132
View File
@@ -1,132 +0,0 @@
/**
* Server-side image validation utility with magic number checking to prevent XSS attacks
* This prevents malicious files (like PDFs with embedded JavaScript) from being uploaded as images
*/
import {
FILE_SIGNATURES,
matchesSignature,
containsDangerousSVGContent,
isValidBase64,
} from "../imageValidationConstants";
export interface ImageValidationResult {
isValid: boolean;
error?: string;
errorKey?: string;
errorParams?: Record<string, string | number>;
detectedFormat?: string;
}
/**
* Validate base64 image data for magic numbers and content
*/
export function validateBase64Image(base64Data: string): ImageValidationResult {
try {
const base64Content = base64Data.replace(/^data:image\/[^;]+;base64,/, "");
if (!isValidBase64(base64Content)) {
return { isValid: false, error: "invalid_base64_format" };
}
const buffer = Buffer.from(base64Content, "base64");
const uint8Array = new Uint8Array(buffer);
if (uint8Array.length === 0) {
return { isValid: false, error: "empty_image_data" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.PDF)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "PDF" },
detectedFormat: "PDF",
};
}
if (
matchesSignature(uint8Array, FILE_SIGNATURES.HTML) ||
matchesSignature(uint8Array, FILE_SIGNATURES.HTML_TAG)
) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "HTML" },
detectedFormat: "HTML",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.SCRIPT_TAG)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "Script" },
detectedFormat: "Script",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.ZIP)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "ZIP" },
detectedFormat: "ZIP",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.EXECUTABLE)) {
return {
isValid: false,
errorKey: "unsupported_file_type",
errorParams: { type: "Executable" },
detectedFormat: "Executable",
};
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.PNG)) {
return { isValid: true, detectedFormat: "PNG" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.JPEG_FF_D8_FF)) {
return { isValid: true, detectedFormat: "JPEG" };
}
if (
matchesSignature(uint8Array, FILE_SIGNATURES.GIF87a) ||
matchesSignature(uint8Array, FILE_SIGNATURES.GIF89a)
) {
return { isValid: true, detectedFormat: "GIF" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.BMP)) {
return { isValid: true, detectedFormat: "BMP" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.ICO)) {
return { isValid: true, detectedFormat: "ICO" };
}
if (matchesSignature(uint8Array, FILE_SIGNATURES.WEBP) && uint8Array.length >= 12) {
if (matchesSignature(uint8Array.slice(8), FILE_SIGNATURES.WEBP_SIGNATURE)) {
return { isValid: true, detectedFormat: "WEBP" };
}
}
if (
matchesSignature(uint8Array, FILE_SIGNATURES.SVG) ||
matchesSignature(uint8Array, FILE_SIGNATURES.SVG_DIRECT)
) {
const textContent = buffer.toString("utf8");
if (containsDangerousSVGContent(textContent)) {
return { isValid: false, error: "svg_contains_dangerous_content", detectedFormat: "SVG" };
}
return { isValid: true, detectedFormat: "SVG" };
}
return { isValid: false, error: "unrecognized_image_format", detectedFormat: "Unknown" };
} catch (error) {
return { isValid: false, error: "failed_to_validate_image_file" };
}
}
+46
View File
@@ -945,4 +945,50 @@ export class BookingRepository {
},
});
}
async getBookingForPaymentProcessing(bookingId: number) {
return await this.prismaClient.booking.findUnique({
where: {
id: bookingId,
},
select: {
id: true,
uid: true,
title: true,
startTime: true,
endTime: true,
userPrimaryEmail: true,
status: true,
eventTypeId: true,
userId: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
},
},
eventType: {
select: {
title: true,
hideOrganizerEmail: true,
teamId: true,
metadata: true,
},
},
payment: {
select: {
id: true,
amount: true,
currency: true,
paymentOption: true,
appId: true,
success: true,
data: true,
},
},
},
});
}
}
+6
View File
@@ -1,5 +1,11 @@
## 1.1.2
## 1.7.1
### Patch Changes
- [#23490](https://github.com/calcom/cal.com/pull/23490) [`de98578`](https://github.com/calcom/cal.com/commit/de98578fdd751887800216f24274578272d5c91d) Thanks [@Ryukemeister](https://github.com/Ryukemeister)! - This PR fixes the prop `disableToasts` not working
## 1.7.0
### Minor Changes
@@ -96,14 +96,18 @@ const EventType = forwardRef<
const { data: user, isLoading: isUserLoading } = useMe();
const handleDeleteSuccess = () => {
showToast(t("event_type_deleted_successfully"), "success");
if (!disableToasts) {
showToast(t("event_type_deleted_successfully"), "success");
}
isTeamEventTypeDeleted.current = true;
setSlugExistsChildrenDialogOpen([]);
onDeleteSuccess?.();
};
const handleDeleteError = (err: Error) => {
showToast(err.message, "error");
if (!disableToasts) {
showToast(err.message, "error");
}
onDeleteError?.(err.message);
};
@@ -137,7 +141,9 @@ const EventType = forwardRef<
// Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval
form.reset(currentValues);
toast({ description: t("event_type_updated_successfully", { eventTypeTitle: eventType.title }) });
if (!disableToasts) {
toast({ description: t("event_type_updated_successfully", { eventTypeTitle: eventType.title }) });
}
onSuccess?.(currentValues);
callbacksRef.current?.onSuccess?.();
},
@@ -148,7 +154,9 @@ const EventType = forwardRef<
const currentValues = form.getValues();
const message = err?.message;
const description = message ? t(message) : t(err.message);
toast({ description });
if (!disableToasts) {
toast({ description });
}
onError?.(currentValues, err);
const errorObj = new Error(description);
@@ -386,6 +394,7 @@ export const EventTypePlatformWrapper = forwardRef<
allowDelete = true,
customClassNames,
isDryRun,
disableToasts,
onFormStateChange,
} = props;
const { data: eventTypeQueryData } = useAtomsEventTypeById(id);
@@ -421,6 +430,7 @@ export const EventTypePlatformWrapper = forwardRef<
isDryRun={isDryRun}
onFormStateChange={onFormStateChange}
ref={ref}
disableToasts={disableToasts}
/>
);
});
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.7.0",
"version": "1.7.1",
"scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
@@ -87,7 +87,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
export type UserFromSession = Awaited<ReturnType<typeof getUserFromSession>>;
const getSession = async (ctx: TRPCContextInner) => {
export const getSession = async (ctx: TRPCContextInner) => {
const { req } = ctx;
const { getServerSession } = await import("@calcom/features/auth/lib/getServerSession");
return req ? await getServerSession({ req }) : null;
@@ -135,16 +135,6 @@ export const getUserSession = async (ctx: TRPCContextInner) => {
return { user, session: sessionWithUpId };
};
const sessionMiddleware = middleware(async ({ ctx, next }) => {
const middlewareStart = performance.now();
const { user, session } = await getUserSession(ctx);
const middlewareEnd = performance.now();
logger.debug("Perf:t.sessionMiddleware", middlewareEnd - middlewareStart);
return next({
ctx: { user, session },
});
});
export const isAuthed = middleware(async ({ ctx, next }) => {
const middlewareStart = performance.now();
@@ -180,5 +170,3 @@ export const isOrgAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
}
return next({ ctx: { user: user } });
});
export default sessionMiddleware;
@@ -3,7 +3,6 @@ import { router } from "../../trpc";
import { ZUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerificationRequired.schema";
import { ZMarkHostAsNoShowInputSchema } from "./markHostAsNoShow.schema";
import { event } from "./procedures/event";
import { session } from "./procedures/session";
import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema";
import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
import { ZSubmitRatingInputSchema } from "./submitRating.schema";
@@ -14,7 +13,6 @@ const namespaced = (s: string) => `${NAMESPACE}.${s}`;
// things that unauthenticated users can query about themselves
export const publicViewerRouter = router({
session,
countryCode: publicProcedure.query(async (opts) => {
const { default: handler } = await import("./countryCode.handler");
return handler(opts);
@@ -1,12 +1,10 @@
import { getUserSession } from "../../../middlewares/sessionMiddleware";
import { getSession } from "../../../middlewares/sessionMiddleware";
import publicProcedure from "../../../procedures/publicProcedure";
import { ZEventInputSchema } from "../event.schema";
const NAMESPACE = "publicViewer";
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
export const event = publicProcedure.input(ZEventInputSchema).query(async (opts) => {
const { user } = await getUserSession(opts.ctx);
const session = await getSession(opts.ctx);
const userId = session?.user?.id;
const { default: handler } = await import("../event.handler");
return handler({ input: opts.input, userId: user?.id });
return handler({ input: opts.input, userId });
});
@@ -1,11 +0,0 @@
import sessionMiddleware from "../../../middlewares/sessionMiddleware";
import publicProcedure from "../../../procedures/publicProcedure";
const NAMESPACE = "publicViewer";
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
export const session = publicProcedure.use(sessionMiddleware).query(async (opts) => {
const { default: handler } = await import("../session.handler");
return handler(opts);
});

Some files were not shown because too many files have changed in this diff Show More