Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6758194b | ||
|
|
5528d60897 | ||
|
|
bb1c55e07f | ||
|
|
c899921591 | ||
|
|
ea34545d72 | ||
|
|
9d3540511b | ||
|
|
e8cf92361a | ||
|
|
e8525b963b | ||
|
|
24dabd0ddc | ||
|
|
5203168b2e | ||
|
|
c3e40f84a9 | ||
|
|
8e2ece7526 | ||
|
|
b6eca6e083 | ||
|
|
4cb264f1f7 | ||
|
|
e248fda36b | ||
|
|
1c97e549eb | ||
|
|
77600f8168 | ||
|
|
6cad606ba1 | ||
|
|
7cae7a7bd4 | ||
|
|
93f65807e7 | ||
|
|
0a63820699 | ||
|
|
05ad437b27 | ||
|
|
26a57a71ae | ||
|
|
34a10205a3 | ||
|
|
fbf8ff6db7 | ||
|
|
bef6d18fdf | ||
|
|
2e96e2ef3f | ||
|
|
2ca6769a70 | ||
|
|
1eb514dfcf | ||
|
|
d6c004ba22 | ||
|
+17 |
afb9605f0a | ||
|
|
a2549bd7b6 | ||
|
|
c2a8f799ff | ||
|
|
c68ff54e77 | ||
|
|
74fe20802d | ||
|
|
d59cda3286 | ||
|
|
1cae2b6f3d | ||
|
|
9d9fa8e575 | ||
|
|
f04c634c48 | ||
|
|
c3e7981917 | ||
|
|
d6594dab0f | ||
|
|
5fcfbb2d90 | ||
|
|
8a99c3d2a5 | ||
|
|
336c2d3643 | ||
|
|
cfe3ef4ab0 | ||
|
|
4ec8241a86 | ||
|
|
e6313aafac | ||
|
|
f87f030bb5 | ||
|
|
f87254cdc3 | ||
|
|
1f74837189 | ||
|
|
c7064906c9 | ||
|
|
86c94a3cd5 | ||
|
|
2056ebe186 | ||
|
|
53424ca9ae | ||
|
|
8197ba18d4 | ||
|
|
d341ea5111 | ||
|
|
42dcd4a2a9 | ||
|
|
c63225fdbf | ||
|
|
41774e4932 | ||
|
|
af7849f951 | ||
|
|
5353610d9a | ||
|
+14 |
156f3e5de2 | ||
|
|
05f51459a6 | ||
|
|
f0db97bbb1 | ||
|
|
05c33bd94b | ||
|
|
7d4cc59cd2 | ||
|
|
a94465ff04 | ||
|
|
4d54ac3ca2 | ||
|
|
15da690270 | ||
|
|
2462e8e597 | ||
|
|
2f628a17df | ||
|
|
1ee3783db3 | ||
|
|
b3d8d7e944 | ||
|
|
a4b7f1af65 | ||
|
|
f7445bf8a1 | ||
|
|
9cad7086d7 | ||
|
|
3345ec61ee | ||
|
|
7797421ce9 | ||
|
|
075a274207 | ||
|
|
a46a31040a | ||
|
|
7a28e1d6f4 | ||
|
|
51fd7fdb3e | ||
|
|
d01963303a | ||
|
|
6877d0f693 | ||
|
|
39040fca9a | ||
|
|
6fa4befc9b | ||
|
|
8c9871c6ce | ||
|
+8 |
a9af2fb255 | ||
|
|
8c150264f4 | ||
|
|
cfc3644d51 | ||
|
|
bdb4a7353b | ||
|
|
30c0e6d1d7 | ||
|
|
390967990a | ||
|
|
cc56602474 | ||
|
|
43a044d59e | ||
|
|
5b8ed9c3ef | ||
|
|
c3d6df2dcf | ||
|
|
9d02cb493c | ||
|
|
26ed478e50 | ||
|
|
09e338970d | ||
|
|
f60399ca8b | ||
|
|
5cf7e32852 | ||
|
|
24be8a63f2 | ||
|
|
75509c97fd | ||
|
|
9b26c7aee6 | ||
|
|
f18e80cfcf | ||
|
|
0a4b1ca014 | ||
|
|
9fa6aced33 | ||
|
|
9093909075 | ||
|
|
832fce91e0 | ||
|
|
6da41fce63 |
+11
-4
@@ -1,20 +1,21 @@
|
||||
# ********** INDEX **********
|
||||
#
|
||||
# - LICENSE
|
||||
# - LICENSE (DEPRECATED)
|
||||
# - DATABASE
|
||||
# - SHARED
|
||||
# - NEXTAUTH
|
||||
# - E-MAIL SETTINGS
|
||||
|
||||
# - LICENSE *************************************************************************************************
|
||||
# https://github.com/calendso/calendso/blob/main/LICENSE
|
||||
# - LICENSE (DEPRECATED) ************************************************************************************
|
||||
# https://github.com/calcom/cal.com/blob/main/LICENSE
|
||||
#
|
||||
# Summary of terms:
|
||||
# - The codebase has to stay open source, whether it was modified or not
|
||||
# - You can not repackage or sell the codebase
|
||||
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
|
||||
#
|
||||
# To enable enterprise-only features, fill your license key in here.
|
||||
# To enable enterprise-only features, as an admin, go to /auth/setup to select your license and follow
|
||||
# instructions. This environment variable is deprecated although still supported for backward compatibility.
|
||||
# @see https://console.cal.com
|
||||
CALCOM_LICENSE_KEY=
|
||||
# ***********************************************************************************************************
|
||||
@@ -157,3 +158,9 @@ NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc."
|
||||
# Set this to true in to disable new signups
|
||||
# NEXT_PUBLIC_DISABLE_SIGNUP=true
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
|
||||
# Content Security Policy
|
||||
CSP_POLICY=
|
||||
|
||||
# Vercel Edge Config
|
||||
EDGE_CONFIG=
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Cron - mark stale for inactive issues
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# Runs every day (see https://crontab.guru)
|
||||
- cron: "0 0 * * *"
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v7
|
||||
with:
|
||||
days-before-stale: 60
|
||||
include-only-assigned: true
|
||||
days-before-close: -1
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
name: "Next.js Bundle Analysis"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
# change this if your nextjs app does not live at the root of the repo
|
||||
working-directory: ./apps/web/
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NEXT_PUBLIC_WEBAPP_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_WEBSITE_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_FREE_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE: ${{ secrets.NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE }}
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN: 1
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PRO_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PRO_PLAN_PRODUCT_ID }}
|
||||
STRIPE_PREMIUM_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_PREMIUM_PLAN_PRODUCT_ID }}
|
||||
STRIPE_FREE_PLAN_PRODUCT_ID: ${{ secrets.CI_STRIPE_FREE_PLAN_PRODUCT_ID }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
NEXTAUTH_URL: http://localhost:3000/api/auth
|
||||
NEXT_PUBLIC_IS_E2E: 1
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.x
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install dependencies
|
||||
uses: bahmutov/npm-install@v1
|
||||
|
||||
- name: Restore next build
|
||||
uses: actions/cache@v2
|
||||
id: restore-build-cache
|
||||
env:
|
||||
cache-name: cache-next-build
|
||||
with:
|
||||
path: apps/web/.next/cache
|
||||
# change this if you prefer a more strict cache
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}
|
||||
|
||||
- name: Build next.js app
|
||||
# change this if your site requires a custom build command
|
||||
run: cd ../.. && yarn db-seed && yarn build
|
||||
|
||||
# Here's the first place where next-bundle-analysis' own script is used
|
||||
# This step pulls the raw bundle stats for the current bundle
|
||||
- name: Analyze bundle
|
||||
run: npx -p nextjs-bundle-analysis report
|
||||
|
||||
- name: Upload bundle
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: bundle
|
||||
path: apps/web/.next/analyze/__bundle_analysis.json
|
||||
|
||||
- name: Download base branch bundle stats
|
||||
uses: dawidd6/action-download-artifact@v2
|
||||
if: success() && github.event.number
|
||||
with:
|
||||
workflow: nextjs-bundle-analysis.yml
|
||||
branch: ${{ github.event.pull_request.base.ref }}
|
||||
path: apps/web/.next/analyze/base
|
||||
|
||||
# And here's the second place - this runs after we have both the current and
|
||||
# base branch bundle stats, and will compare them to determine what changed.
|
||||
# There are two configurable arguments that come from package.json:
|
||||
#
|
||||
# - budget: optional, set a budget (bytes) against which size changes are measured
|
||||
# it's set to 350kb here by default, as informed by the following piece:
|
||||
# https://infrequently.org/2021/03/the-performance-inequality-gap/
|
||||
#
|
||||
# - red-status-percentage: sets the percent size increase where you get a red
|
||||
# status indicator, defaults to 20%
|
||||
#
|
||||
# Either of these arguments can be changed or removed by editing the `nextBundleAnalysis`
|
||||
# entry in your package.json file.
|
||||
- name: Compare with base branch bundle
|
||||
if: success() && github.event.number
|
||||
run: ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare
|
||||
|
||||
- name: Get comment body
|
||||
id: get-comment-body
|
||||
if: success() && github.event.number
|
||||
run: |
|
||||
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||
body="${body//'%'/'%25'}"
|
||||
body="${body//$'\n'/'%0A'}"
|
||||
body="${body//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$body
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v1
|
||||
if: success() && github.event.number
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body-includes: "<!-- __NEXTJS_BUNDLE -->"
|
||||
|
||||
- name: Create Comment
|
||||
uses: peter-evans/create-or-update-comment@v1.4.4
|
||||
if: success() && github.event.number && steps.fc.outputs.comment-id == 0
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body: ${{ steps.get-comment-body.outputs.body }}
|
||||
|
||||
- name: Update Comment
|
||||
uses: peter-evans/create-or-update-comment@v1.4.4
|
||||
if: success() && github.event.number && steps.fc.outputs.comment-id != 0
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
body: ${{ steps.get-comment-body.outputs.body }}
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
@@ -0,0 +1,32 @@
|
||||
name: TODO to Issue
|
||||
on:
|
||||
pull_request_target: # So we can test on forks
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
MANUAL_COMMIT_REF:
|
||||
description: "The SHA of the commit to get the diff for"
|
||||
required: true
|
||||
MANUAL_BASE_REF:
|
||||
description: "By default, the commit entered above is compared to the one directly before it; to go back further, enter an earlier SHA here"
|
||||
required: false
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
todos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
- name: "Add TODOs to issues"
|
||||
uses: "alstr/todo-to-issue-action@v4"
|
||||
env:
|
||||
MANUAL_COMMIT_REF: ${{ inputs.MANUAL_COMMIT_REF }}
|
||||
MANUAL_BASE_REF: ${{ inputs.MANUAL_BASE_REF }}
|
||||
with:
|
||||
IDENTIFIERS: '[{"name": "TODO", "labels": ["🙋🏻♂️help wanted"]}, {"name": "FIXME", "labels": ["🐛 bug"]}]'
|
||||
@@ -77,6 +77,9 @@ lint-results
|
||||
#Storybook
|
||||
apps/storybook/build-storybook.log
|
||||
|
||||
# Infisical
|
||||
.infisical.json
|
||||
|
||||
# Snaplet
|
||||
.snaplet/snapshots
|
||||
.snaplet/structure.d.ts
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"],
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-postgresql:hobby-dev"
|
||||
"plan": "heroku-postgresql:mini"
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
@@ -43,15 +43,18 @@
|
||||
},
|
||||
"SENDGRID_API_KEY": {
|
||||
"description": "Sendgrid api key. Used for email reminders in workflows",
|
||||
"value": ""
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"SENDGRID_SYNC_API_KEY": {
|
||||
"description": "Sendgrid internal sync service",
|
||||
"value": ""
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"SENDGRID_EMAIL": {
|
||||
"description": "Sendgrid email. Used for email reminders in workflows",
|
||||
"value": ""
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
"EMAIL_FROM": {
|
||||
"description": "Configures the global From: header whilst sending emails",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { create } from "@storybook/theming";
|
||||
|
||||
export default create({
|
||||
base: "light",
|
||||
colorPrimary: "#111827",
|
||||
colorSecondary: "#111827",
|
||||
appContentBg: "#F9FAFC",
|
||||
brandTitle: "Cal.com UI",
|
||||
brandUrl: "https://cal.com",
|
||||
brandImage: "https://cal.com/logo.svg",
|
||||
brandTarget: "_self",
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { addons } from "@storybook/addons";
|
||||
|
||||
import yourTheme from "./YourTheme";
|
||||
|
||||
addons.setConfig({
|
||||
theme: yourTheme,
|
||||
});
|
||||
@@ -65,7 +65,6 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
<div className={`p-4 ${highlight ? "bg-yellow-100" : ""}`}>
|
||||
<div className="flex gap-x-3">
|
||||
{logo ? <img className="h-10 w-10" src={logo} alt={`${title} logo`} /> : null}
|
||||
|
||||
<div className="flex grow flex-col gap-y-1 truncate">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h3 className="truncate text-sm font-semibold text-gray-900">{title}</h3>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
HorizontalTabs,
|
||||
InputLeading,
|
||||
Label,
|
||||
showToast,
|
||||
Switch,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui";
|
||||
|
||||
import { Area, getCroppedImg } from "@lib/cropImage";
|
||||
import { useFileReader } from "@lib/hooks/useFileReader";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Slider from "@components/Slider";
|
||||
|
||||
|
||||
@@ -10,15 +10,15 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
const { t } = useLocale();
|
||||
|
||||
return locations.length ? (
|
||||
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-2 break-words text-sm text-gray-600">
|
||||
{locations.map((location) => {
|
||||
<div className="dark:text-darkgray-600 mr-6 flex w-full flex-col space-y-4 break-words text-sm text-gray-600">
|
||||
{locations.map((location, index) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
// It's possible that the location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={location.type} className="flex flex-row items-center text-sm font-medium">
|
||||
<div key={`${location.type}-${index}`} className="flex flex-row items-center text-sm font-medium">
|
||||
{eventLocationType.iconUrl === "/link.svg" ? (
|
||||
<FiLink className="dark:text-darkgray-600 ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 " />
|
||||
) : (
|
||||
@@ -26,7 +26,7 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"ml-[2px] h-4 w-4 opacity-70 ltr:mr-[10px] rtl:ml-[10px] dark:opacity-100 ",
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert" : ""
|
||||
!eventLocationType.iconUrl?.includes("api") ? "dark:invert-[.65]" : ""
|
||||
)}
|
||||
alt={`${eventLocationType.label} icon`}
|
||||
/>
|
||||
|
||||
@@ -17,13 +17,12 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
MeetingTimeInTimezones,
|
||||
showToast,
|
||||
TextArea,
|
||||
Tooltip,
|
||||
ActionType,
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
} from "@calcom/ui";
|
||||
import { FiCheck, FiClock, FiMapPin, FiRefreshCcw, FiSend, FiSlash, FiX } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -53,7 +52,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.status === BookingStatus.REJECTED) {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
@@ -245,24 +244,23 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
{/* NOTE: Should refactor this dialog component as is being rendered multiple times */}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("rejection_reason_title")} />
|
||||
|
||||
<p className="-mt-4 text-sm text-gray-500">{t("rejection_reason_description")}</p>
|
||||
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||
{t("rejection_reason")}
|
||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||
</p>
|
||||
<TextArea
|
||||
name={t("rejection_reason")}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
className="mb-5 sm:mb-6"
|
||||
/>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
|
||||
<Button
|
||||
disabled={mutation.isLoading}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { ITimezoneOption, TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import { timeZone } from "../../lib/clock";
|
||||
|
||||
type Props = {
|
||||
@@ -11,7 +13,8 @@ type Props = {
|
||||
|
||||
const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const { t } = useLocale();
|
||||
const query = useMeQuery();
|
||||
const userTheme = useTheme(query?.data?.theme).resolvedTheme;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTimeZone(timeZone());
|
||||
@@ -23,18 +26,60 @@ const TimeOptions: FC<Props> = ({ onSelectTimeZone }) => {
|
||||
}
|
||||
}, [selectedTimeZone, onSelectTimeZone]);
|
||||
|
||||
return selectedTimeZone !== "" ? (
|
||||
<div className="dark:border-darkgray-300 dark:bg-darkgray-200 rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
|
||||
<div className="mb-4 flex">
|
||||
<div className="text-sm font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="focus:border-brand mt-1 mb-2 block w-full rounded-md border-gray-300 text-sm focus:ring-black"
|
||||
/>
|
||||
</div>
|
||||
const customStyles = {
|
||||
option: (_provided: object, state: { isSelected: boolean; isFocused: boolean }) => ({
|
||||
padding: "10px 12px !important",
|
||||
color: state.isSelected || state.isFocused ? "#101010" : "#374151",
|
||||
backgroundColor: state.isSelected ? "#E5E7EB !important" : "white",
|
||||
...(userTheme === "dark" && {
|
||||
color: state.isSelected || state.isFocused ? "white" : "#80868B",
|
||||
}),
|
||||
}),
|
||||
control: () => ({
|
||||
display: "flex",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent !important",
|
||||
minWidth: "5rem",
|
||||
height: "24px",
|
||||
minHeight: "24px !important",
|
||||
boxShadow: "none !important",
|
||||
}),
|
||||
singleValue: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||
...provided,
|
||||
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||
...(userTheme === "dark" && {
|
||||
color: "#a5a5a5 !important",
|
||||
}),
|
||||
}),
|
||||
menu: (provided: object) => ({
|
||||
...provided,
|
||||
minWidth: "20rem",
|
||||
margin: "8px 0 0 -20px",
|
||||
}),
|
||||
valueContainer: (provided: object) => ({
|
||||
...provided,
|
||||
padding: "0 0 0 6px",
|
||||
}),
|
||||
dropdownIndicator: (provided: object, state: { selectProps: { menuIsOpen: boolean } }) => ({
|
||||
...provided,
|
||||
transform: state.selectProps.menuIsOpen ? "rotate(180deg)" : "",
|
||||
color: state.selectProps.menuIsOpen ? "#111827" : "#4B5563",
|
||||
marginLeft: 4,
|
||||
padding: 0,
|
||||
...(userTheme === "dark" && {
|
||||
color: "#80868B",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
return !!selectedTimeZone ? (
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="flex h-6 text-sm font-medium"
|
||||
styles={customStyles}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { EventType } from "@prisma/client";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useRouter } from "next/router";
|
||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { z } from "zod";
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
import DatePicker from "@calcom/features/calendars/DatePicker";
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
@@ -30,7 +29,7 @@ import { detectBrowserTimeFormat, setIs24hClockInLocalStorage, TimeFormat } from
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { FiChevronDown, FiChevronUp, FiCreditCard, FiGlobe, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||
import { FiCreditCard, FiGlobe, FiRefreshCcw } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone as localStorageTimeZone } from "@lib/clock";
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
@@ -53,6 +52,7 @@ const useSlots = ({
|
||||
usernameList,
|
||||
timeZone,
|
||||
duration,
|
||||
enabled = true,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
@@ -61,6 +61,7 @@ const useSlots = ({
|
||||
usernameList: string[];
|
||||
timeZone?: string;
|
||||
duration?: string;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const { data, isLoading, isPaused } = trpc.viewer.public.slots.getSchedule.useQuery(
|
||||
{
|
||||
@@ -73,19 +74,14 @@ const useSlots = ({
|
||||
duration,
|
||||
},
|
||||
{
|
||||
enabled: !!startTime && !!endTime,
|
||||
enabled: !!startTime && !!endTime && enabled,
|
||||
refetchInterval: 3000,
|
||||
trpc: { context: { skipBatch: true } },
|
||||
}
|
||||
);
|
||||
const [cachedSlots, setCachedSlots] = useState<NonNullable<typeof data>["slots"]>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.slots) {
|
||||
setCachedSlots((c) => ({ ...c, ...data?.slots }));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// The very first time isPaused is set if auto-fetch is disabled, so isPaused should also be considered a loading state.
|
||||
return { slots: cachedSlots, isLoading: isLoading || isPaused };
|
||||
return { slots: data?.slots || {}, isLoading: isLoading || isPaused };
|
||||
};
|
||||
|
||||
const SlotPicker = ({
|
||||
@@ -147,16 +143,7 @@ const SlotPicker = ({
|
||||
}, [router.isReady, month, date, duration, timeZone]);
|
||||
|
||||
const { i18n, isLocaleReady } = useLocale();
|
||||
const { slots: _1 } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
startTime: selectedDate?.startOf("day"),
|
||||
endTime: selectedDate?.endOf("day"),
|
||||
timeZone,
|
||||
duration,
|
||||
});
|
||||
const { slots: _2, isLoading } = useSlots({
|
||||
const { slots: monthSlots, isLoading } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
@@ -168,8 +155,25 @@ const SlotPicker = ({
|
||||
timeZone,
|
||||
duration,
|
||||
});
|
||||
const { slots: selectedDateSlots, isLoading: _isLoadingSelectedDateSlots } = useSlots({
|
||||
eventTypeId: eventType.id,
|
||||
eventTypeSlug: eventType.slug,
|
||||
usernameList: users,
|
||||
startTime: selectedDate?.startOf("day"),
|
||||
endTime: selectedDate?.endOf("day"),
|
||||
timeZone,
|
||||
duration,
|
||||
/** Prevent refetching is we already have this data from month slots */
|
||||
enabled: !!selectedDate,
|
||||
});
|
||||
|
||||
const slots = useMemo(() => ({ ..._2, ..._1 }), [_1, _2]);
|
||||
/** Hide skeleton if we have the slot loaded in the month query */
|
||||
const isLoadingSelectedDateSlots = (() => {
|
||||
if (!selectedDate) return _isLoadingSelectedDateSlots;
|
||||
if (!!selectedDateSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||
if (!!monthSlots[selectedDate.format("YYYY-MM-DD")]) return false;
|
||||
return false;
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -179,7 +183,7 @@ const SlotPicker = ({
|
||||
"mt-8 px-4 pb-4 sm:mt-0 md:min-w-[300px] md:px-5 lg:min-w-[455px]",
|
||||
selectedDate ? "sm:dark:border-darkgray-200 border-gray-200 sm:border-r sm:p-4 sm:pr-6" : "sm:p-4"
|
||||
)}
|
||||
includedDates={Object.keys(slots).filter((k) => slots[k].length > 0)}
|
||||
includedDates={Object.keys(monthSlots).filter((k) => monthSlots[k].length > 0)}
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
selected={selectedDate}
|
||||
onChange={(newDate) => {
|
||||
@@ -194,8 +198,12 @@ const SlotPicker = ({
|
||||
|
||||
<div ref={slotPickerRef}>
|
||||
<AvailableTimes
|
||||
isLoading={isLoading}
|
||||
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
|
||||
isLoading={isLoadingSelectedDateSlots}
|
||||
slots={
|
||||
selectedDate &&
|
||||
(selectedDateSlots[selectedDate.format("YYYY-MM-DD")] ||
|
||||
monthSlots[selectedDate.format("YYYY-MM-DD")])
|
||||
}
|
||||
date={selectedDate}
|
||||
timeFormat={timeFormat}
|
||||
onTimeFormatChange={onTimeFormatChange}
|
||||
@@ -212,41 +220,22 @@ const SlotPicker = ({
|
||||
|
||||
function TimezoneDropdown({
|
||||
onChangeTimeZone,
|
||||
timeZone,
|
||||
}: {
|
||||
onChangeTimeZone: (newTimeZone: string) => void;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
|
||||
const handleSelectTimeZone = (newTimeZone: string) => {
|
||||
onChangeTimeZone(newTimeZone);
|
||||
localStorageTimeZone(newTimeZone);
|
||||
setIsTimeOptionsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen}>
|
||||
<Popover.Trigger className="min-w-32 dark:text-darkgray-600 radix-state-open:bg-gray-200 dark:radix-state-open:bg-darkgray-200 group relative mb-2 -ml-2 !mt-2 inline-block self-start rounded-md px-2 py-2 text-left text-gray-600">
|
||||
<p className="flex items-center text-sm font-medium">
|
||||
<FiGlobe className="min-h-4 min-w-4 ml-[2px] -mt-[2px] inline-block ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
{timeZone}
|
||||
{isTimeOptionsOpen ? (
|
||||
<FiChevronUp className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||
) : (
|
||||
<FiChevronDown className="min-h-4 min-w-4 ml-1 inline-block" />
|
||||
)}
|
||||
</p>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
hideWhenDetached
|
||||
align="start"
|
||||
className="animate-fade-in-up absolute left-0 top-2 w-80 max-w-[calc(100vw_-_1.5rem)]">
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<>
|
||||
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
|
||||
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
|
||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,7 +300,7 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
() => <TimezoneDropdown timeZone={timeZone} onChangeTimeZone={setTimeZone} />,
|
||||
[timeZone]
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
const rawSlug = profile.slug ? profile.slug.split("/") : [];
|
||||
if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug.
|
||||
@@ -362,10 +351,10 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
isBackgroundTransparent
|
||||
? ""
|
||||
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||
"border-bookinglightest overflow-hidden md:rounded-md md:border",
|
||||
"border-bookinglightest md:rounded-md md:border",
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="overflow-hidden md:flex">
|
||||
<div className="md:flex">
|
||||
{showEventTypeDetails && (
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -399,14 +388,14 @@ const AvailabilityPage = ({ profile, eventType, ...restProps }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{stripeAppData.price > 0 && (
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
currency={paymentAppData.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSession } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useReducer, useState } from "react";
|
||||
import { useFieldArray, useForm, useWatch, Controller } from "react-hook-form";
|
||||
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import CustomBranding from "@calcom/lib/CustomBranding";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -122,7 +122,7 @@ const BookingPage = ({
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
// Define duration now that we support multiple duration eventTypes
|
||||
let duration = eventType.length;
|
||||
if (
|
||||
@@ -148,6 +148,7 @@ const BookingPage = ({
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { uid, paymentUid } = responseData;
|
||||
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
@@ -166,7 +167,7 @@ const BookingPage = ({
|
||||
isSuccessBookingPage: true,
|
||||
email: bookingForm.getValues("email"),
|
||||
eventTypeSlug: eventType.slug,
|
||||
formerTime: booking?.startTime.toString(),
|
||||
...(rescheduleUid && booking?.startTime && { formerTime: booking.startTime.toString() }),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -557,14 +558,14 @@ const BookingPage = ({
|
||||
{showEventTypeDetails && (
|
||||
<div className="sm:dark:border-darkgray-300 dark:text-darkgray-600 flex flex-col px-6 pt-6 pb-0 text-gray-600 sm:w-1/2 sm:border-r sm:pb-6">
|
||||
<BookingDescription isBookingPage profile={profile} eventType={eventType}>
|
||||
{stripeAppData.price > 0 && (
|
||||
{paymentAppData.price > 0 && (
|
||||
<p className="text-bookinglight -ml-2 px-2 text-sm ">
|
||||
<FiCreditCard className="ml-[2px] -mt-1 inline-block h-4 w-4 ltr:mr-[10px] rtl:ml-[10px]" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={stripeAppData.price / 100.0}
|
||||
value={paymentAppData.price / 100.0}
|
||||
style="currency"
|
||||
currency={stripeAppData.currency.toUpperCase()}
|
||||
currency={paymentAppData?.currency?.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
|
||||
@@ -99,100 +99,105 @@ const format = (date: Date, hour12: boolean) =>
|
||||
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
|
||||
);
|
||||
|
||||
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
const EventTypeScheduleDetails = () => {
|
||||
const { data: loggedInUser } = useMeQuery();
|
||||
const timeFormat = loggedInUser?.timeFormat;
|
||||
const { t, i18n } = useLocale();
|
||||
const { watch } = useFormContext<FormValues>();
|
||||
|
||||
const EventTypeSchedule = () => {
|
||||
const me = useMeQuery();
|
||||
const timeFormat = me?.data?.timeFormat;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="min-w-4 mb-2">
|
||||
<label htmlFor="availability" className="mt-0 flex text-sm font-medium text-gray-700">
|
||||
{t("availability")}
|
||||
</label>
|
||||
</div>
|
||||
<Controller
|
||||
name="schedule"
|
||||
render={({ field }) => (
|
||||
<AvailabilitySelect
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 rounded border p-4 py-6 pt-2 md:p-8">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32",
|
||||
!isAvailable && "text-gray-500 opacity-50"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<hr />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<Button
|
||||
href={`/availability/${schedule?.schedule.id}`}
|
||||
color="minimal"
|
||||
EndIcon={FiExternalLink}
|
||||
target="_blank"
|
||||
className="justify-center border sm:border-0"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery({ scheduleId });
|
||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
||||
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
|
||||
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
|
||||
);
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
schedule?.schedule.availability.filter((item) => item.days.includes((dayNum + 1) % 7)) || [];
|
||||
|
||||
if (!isTeamEvent) {
|
||||
return <EventTypeSchedule />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 rounded border px-6 pb-4">
|
||||
<ol className="table border-collapse text-sm">
|
||||
{weekdayNames(i18n.language, 1, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li key={day} className="my-6 flex border-transparent last:mb-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32",
|
||||
!isAvailable && "text-gray-500 opacity-50"
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isLoading ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div key={i} className="flex items-center leading-4">
|
||||
<span className="w-16 sm:w-28 sm:text-left">
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className="ltr:ml-4 rtl:mr-4">-</span>
|
||||
<div className="ml-6">{format(dayRange.endTime, timeFormat === 12)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-6 text-gray-500 opacity-50 sm:ml-0">{t("unavailable")}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<hr />
|
||||
<div className="flex flex-col justify-center gap-2 sm:flex-row sm:justify-between">
|
||||
<span className="flex items-center justify-center text-sm text-gray-600 sm:justify-start">
|
||||
<FiGlobe className="ltr:mr-2 rtl:ml-2" />
|
||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<Button
|
||||
href={`/availability/${schedule?.schedule.id}`}
|
||||
color="minimal"
|
||||
EndIcon={FiExternalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EventTypeSchedule = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="availability" className="mb-2 block text-sm font-medium leading-none text-gray-700">
|
||||
{t("availability")}
|
||||
</label>
|
||||
<Controller
|
||||
name="schedule"
|
||||
render={({ field }) => (
|
||||
<AvailabilitySelect
|
||||
value={field.value}
|
||||
onBlur={field.onBlur}
|
||||
name={field.name}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<EventTypeScheduleDetails />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UseCommonScheduleSettingsToggle = () => {
|
||||
const { t } = useLocale();
|
||||
const { resetField, setValue } = useFormContext<FormValues>();
|
||||
return (
|
||||
<Controller
|
||||
name="metadata.config.useHostSchedulesForTeamEvent"
|
||||
@@ -201,6 +206,11 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
checked={!value}
|
||||
onCheckedChange={(checked) => {
|
||||
onChange(!checked);
|
||||
if (checked) {
|
||||
resetField("schedule");
|
||||
} else {
|
||||
setValue("schedule", null);
|
||||
}
|
||||
}}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
@@ -210,3 +220,7 @@ export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
||||
return isTeamEvent ? <UseCommonScheduleSettingsToggle /> : <EventTypeSchedule />;
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
const removeCustom = (index: number) => {
|
||||
formMethods.getValues("customInputs").splice(index, 1);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
|
||||
import getStripeAppData from "@calcom/lib/getStripeAppData";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
|
||||
import RecurringEventController from "./RecurringEventController";
|
||||
|
||||
export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const stripeAppData = getStripeAppData(eventType);
|
||||
const paymentAppData = getPaymentAppData(eventType);
|
||||
|
||||
const requirePayment = stripeAppData.price > 0;
|
||||
const requirePayment = paymentAppData.price > 0;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
|
||||
@@ -196,6 +196,12 @@ export const EventSetupTab = (
|
||||
if (!eventLocationType) {
|
||||
return null;
|
||||
}
|
||||
// We dont want to translate the string link - it doesnt exist in common.json and it gets prefixed/suffixed with __ or //
|
||||
const eventLabel =
|
||||
eventLocationType.defaultValueVariable === "link"
|
||||
? eventLocationType.label
|
||||
: t(location[eventLocationType.defaultValueVariable] || eventLocationType.label);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${location.type}${index}`}
|
||||
@@ -207,9 +213,7 @@ export const EventSetupTab = (
|
||||
className="h-4 w-4"
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">
|
||||
{t(location[eventLocationType.defaultValueVariable] || eventLocationType.label)}
|
||||
</span>
|
||||
<span className="truncate text-sm ltr:ml-1 rtl:mr-1">{eventLabel}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
|
||||
@@ -25,6 +25,16 @@ const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
|
||||
email,
|
||||
});
|
||||
|
||||
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
|
||||
if (a.label < b.label) {
|
||||
return -1;
|
||||
}
|
||||
if (a.label > b.label) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const FixedHosts = ({
|
||||
control,
|
||||
labelText,
|
||||
@@ -103,7 +113,6 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
];
|
||||
|
||||
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{team && (
|
||||
@@ -128,17 +137,16 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
|
||||
{schedulingType === SchedulingType.COLLECTIVE && (
|
||||
<FixedHosts
|
||||
options={teamMembersOptions}
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("team")}
|
||||
control={formMethods.control}
|
||||
/>
|
||||
)}
|
||||
|
||||
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||
<>
|
||||
<FixedHosts
|
||||
options={teamMembersOptions}
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
isOptionDisabled={(option) =>
|
||||
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
|
||||
}
|
||||
@@ -173,7 +181,7 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||
)
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={teamMembers.map(mapUserToValue)}
|
||||
options={teamMembersOptions.sort(sortByLabel)}
|
||||
isOptionDisabled={(option) =>
|
||||
!!formMethods
|
||||
.getValues("hostsFixed")
|
||||
|
||||
@@ -100,7 +100,7 @@ export const EventTeamWebhooksTab = ({
|
||||
<>
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="mt-4 mb-8 rounded-md border">
|
||||
<div className="mb-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
return (
|
||||
<WebhookListItem
|
||||
|
||||
@@ -286,6 +286,7 @@ function EventTypeSingleLayout({
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
StartIcon={FiTrash}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
@@ -338,7 +339,7 @@ function EventTypeSingleLayout({
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-4 rounded-md border-gray-200 bg-white sm:mx-0 xl:mt-0",
|
||||
disableBorder ? "border-0 xl:-mt-4 " : "p-2 md:border md:p-6"
|
||||
disableBorder ? "border-0 " : "p-2 md:border md:p-6"
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FormEvent, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
@@ -34,6 +35,7 @@ const UserProfile = (props: IUserProfileProps) => {
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const createEventType = trpc.viewer.eventTypes.create.useMutation();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: async (_data, context) => {
|
||||
@@ -64,6 +66,8 @@ const UserProfile = (props: IUserProfileProps) => {
|
||||
const onSubmit = handleSubmit((data: { bio: string }) => {
|
||||
const { bio } = data;
|
||||
|
||||
telemetry.event(telemetryEventTypes.onboardingFinished);
|
||||
|
||||
mutation.mutate({
|
||||
bio,
|
||||
completedOnboarding: true,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, TimezoneSelect } from "@calcom/ui";
|
||||
|
||||
@@ -20,7 +21,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
const { user, nextStep } = props;
|
||||
const { t } = useLocale();
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState(dayjs.tz.guess());
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -31,6 +32,11 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.event(telemetryEventTypes.onboardingStarted);
|
||||
}, [telemetry]);
|
||||
|
||||
const defaultOptions = { required: true, maxLength: 255 };
|
||||
|
||||
const utils = trpc.useContext();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge, Button } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import DisableTwoFactorModal from "./DisableTwoFactorModal";
|
||||
import EnableTwoFactorModal from "./EnableTwoFactorModal";
|
||||
|
||||
|
||||
+30
-10
@@ -1,17 +1,39 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import classNames from "classnames";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { isPasswordValid } from "@calcom/lib/auth";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmailField, Label, PasswordField, TextField } from "@calcom/ui";
|
||||
import { EmailField, EmptyScreen, Label, PasswordField, TextField } from "@calcom/ui";
|
||||
import { FiUserCheck } from "@calcom/ui/components/icon";
|
||||
|
||||
const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
const router = useRouter();
|
||||
export const AdminUserContainer = (props: React.ComponentProps<typeof AdminUser> & { userCount: number }) => {
|
||||
const { t } = useLocale();
|
||||
if (props.userCount > 0)
|
||||
return (
|
||||
<form
|
||||
id="wizard-step-1"
|
||||
name="wizard-step-1"
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSuccess();
|
||||
}}>
|
||||
<EmptyScreen
|
||||
Icon={FiUserCheck}
|
||||
headline={t("admin_user_created")}
|
||||
description={t("admin_user_created_description")}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
return <AdminUser {...props} />;
|
||||
};
|
||||
|
||||
export const AdminUser = (props: { onSubmit: () => void; onError: () => void; onSuccess: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -45,11 +67,11 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
});
|
||||
|
||||
const onError = () => {
|
||||
props.setIsLoading(false);
|
||||
props.onError();
|
||||
};
|
||||
|
||||
const onSubmit = formMethods.handleSubmit(async (data: z.infer<typeof formSchema>) => {
|
||||
props.setIsLoading(true);
|
||||
props.onSubmit();
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@@ -69,9 +91,9 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
email: data.email_address.toLowerCase(),
|
||||
password: data.password,
|
||||
});
|
||||
router.replace(`/auth/setup?step=2&category=calendar`);
|
||||
props.onSuccess();
|
||||
} else {
|
||||
router.replace("/auth/setup");
|
||||
props.onError();
|
||||
}
|
||||
}, onError);
|
||||
|
||||
@@ -186,5 +208,3 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupFormStep1;
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import classNames from "classnames";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const ENTERPRISE_BOOKING_FEE = "$99"; // TODO: get this from a new API endpoint
|
||||
|
||||
const ChooseLicense = (
|
||||
props: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
} & Omit<JSX.IntrinsicElements["form"], "onSubmit" | "onChange">
|
||||
) => {
|
||||
const { value: initialValue = "FREE", onChange, onSubmit, ...rest } = props;
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<form
|
||||
{...rest}
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(value);
|
||||
}}>
|
||||
<RadioGroup.Root
|
||||
defaultValue={initialValue}
|
||||
value={value}
|
||||
aria-label={t("choose_a_license")}
|
||||
className="grid grid-rows-2 gap-4 md:grid-cols-2 md:grid-rows-1"
|
||||
onValueChange={(value) => {
|
||||
onChange(value);
|
||||
setValue(value);
|
||||
}}>
|
||||
<RadioGroup.Item value="FREE">
|
||||
<div
|
||||
className={classNames(
|
||||
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
|
||||
value === "FREE" && "ring-2 ring-black"
|
||||
)}>
|
||||
<h2 className="font-cal text-xl text-black">{t("agplv3_license")}</h2>
|
||||
<p className="font-medium text-green-800">{t("free_license_fee")}</p>
|
||||
<p className="text-gray-500">{t("forever_open_and_free")}</p>
|
||||
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
|
||||
<li>{t("required_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("cannot_repackage_and_resell")}</li>
|
||||
<li>{t("no_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
<RadioGroup.Item value="EE">
|
||||
<div
|
||||
className={classNames(
|
||||
"cursor-pointer space-y-2 rounded-md border bg-white p-4 hover:border-black",
|
||||
value === "EE" && "ring-2 ring-black"
|
||||
)}>
|
||||
<h2 className="font-cal text-xl text-black">{t("ee_enterprise_license")}</h2>
|
||||
<p className="font-medium text-green-800">
|
||||
{t("enterprise_booking_fee", { enterprise_booking_fee: ENTERPRISE_BOOKING_FEE })}
|
||||
</p>
|
||||
<p className="text-gray-500">{t("enterprise_license_includes")}</p>
|
||||
<ul className="ml-4 list-disc text-left text-xs text-gray-500">
|
||||
<li>{t("no_need_to_keep_your_code_open_source")}</li>
|
||||
<li>{t("repackage_rebrand_resell")}</li>
|
||||
<li>{t("a_vast_suite_of_enterprise_features")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</RadioGroup.Item>
|
||||
</RadioGroup.Root>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChooseLicense;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { noop } from "lodash";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Controller, FormProvider, useForm, useFormState } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterInputs, RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
import { Button, TextField } from "@calcom/ui";
|
||||
import { FiCheck, FiExternalLink, FiLoader } from "@calcom/ui/components/icon";
|
||||
|
||||
type EnterpriseLicenseFormValues = {
|
||||
licenseKey: string;
|
||||
};
|
||||
|
||||
const makeSchemaLicenseKey = (args: { callback: (valid: boolean) => void; onSuccessValidate: () => void }) =>
|
||||
z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.uuid({
|
||||
message: "License key must follow UUID format: 8-4-4-4-12",
|
||||
})
|
||||
.superRefine(async (data, ctx) => {
|
||||
const parse = z.string().uuid().safeParse(data);
|
||||
if (parse.success) {
|
||||
args.callback(true);
|
||||
const response = await fetch(`${CONSOLE_URL}/api/license?key=${data}`);
|
||||
args.callback(false);
|
||||
const json = await response.json();
|
||||
if (!json.valid) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `License key ${json.message.toLowerCase()}`,
|
||||
});
|
||||
} else {
|
||||
args.onSuccessValidate();
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const EnterpriseLicense = (
|
||||
props: {
|
||||
licenseKey?: string;
|
||||
initialValue?: Partial<EnterpriseLicenseFormValues>;
|
||||
onSuccessValidate: () => void;
|
||||
onSubmit: (value: EnterpriseLicenseFormValues) => void;
|
||||
onSuccess?: (
|
||||
data: RouterOutputs["viewer"]["deploymentSetup"]["update"],
|
||||
variables: RouterInputs["viewer"]["deploymentSetup"]["update"]
|
||||
) => void;
|
||||
} & Omit<JSX.IntrinsicElements["form"], "onSubmit">
|
||||
) => {
|
||||
const { onSubmit, onSuccess = noop, onSuccessValidate = noop, ...rest } = props;
|
||||
const { t } = useLocale();
|
||||
const [checkLicenseLoading, setCheckLicenseLoading] = useState(false);
|
||||
const mutation = trpc.viewer.deploymentSetup.update.useMutation({
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const schemaLicenseKey = useCallback(
|
||||
() =>
|
||||
makeSchemaLicenseKey({
|
||||
callback: setCheckLicenseLoading,
|
||||
onSuccessValidate,
|
||||
}),
|
||||
[setCheckLicenseLoading, onSuccessValidate]
|
||||
);
|
||||
|
||||
const formMethods = useForm<EnterpriseLicenseFormValues>({
|
||||
defaultValues: {
|
||||
licenseKey: props.licenseKey || "",
|
||||
},
|
||||
resolver: zodResolver(schemaLicenseKey()),
|
||||
});
|
||||
|
||||
const handleSubmit = formMethods.handleSubmit((values) => {
|
||||
onSubmit(values);
|
||||
setCheckLicenseLoading(false);
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
const { isDirty, errors } = useFormState(formMethods);
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<form {...rest} className="space-y-4 rounded-md bg-white px-8 py-10" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<Button
|
||||
className="w-full justify-center text-lg"
|
||||
EndIcon={FiExternalLink}
|
||||
href="https://console.cal.com"
|
||||
target="_blank">
|
||||
{t("purchase_license")}
|
||||
</Button>
|
||||
<div className="relative flex justify-center">
|
||||
<hr className="my-8 w-full border-[1.5px] border-gray-200" />
|
||||
<span className="absolute mt-[22px] bg-white px-3.5 text-sm">OR</span>
|
||||
</div>
|
||||
{t("already_have_key")}
|
||||
<Controller
|
||||
name="licenseKey"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onBlur, onChange, value } }) => (
|
||||
<TextField
|
||||
{...formMethods.register("licenseKey")}
|
||||
className={classNames(
|
||||
"mb-0 group-hover:border-gray-400",
|
||||
(checkLicenseLoading || (errors.licenseKey === undefined && isDirty)) && "border-r-0"
|
||||
)}
|
||||
placeholder="xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx"
|
||||
labelSrOnly={true}
|
||||
value={value}
|
||||
addOnFilled={false}
|
||||
addOnClassname={classNames(
|
||||
"hover:border-gray-300",
|
||||
errors.licenseKey === undefined && isDirty && "group-hover:border-gray-400"
|
||||
)}
|
||||
addOnSuffix={
|
||||
checkLicenseLoading ? (
|
||||
<FiLoader className="h-5 w-5 animate-spin" />
|
||||
) : errors.licenseKey === undefined && isDirty ? (
|
||||
<FiCheck className="h-5 w-5 text-green-700" />
|
||||
) : undefined
|
||||
}
|
||||
color={errors.licenseKey ? "warn" : ""}
|
||||
onBlur={onBlur}
|
||||
onChange={async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("licenseKey", e.target.value);
|
||||
await formMethods.trigger("licenseKey");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnterpriseLicense;
|
||||
+11
-5
@@ -1,20 +1,26 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { FiCheck } from "@calcom/ui/components/icon";
|
||||
|
||||
const StepDone = () => {
|
||||
const StepDone = (props: {
|
||||
currentStep: number;
|
||||
nextStepPath: string;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<form
|
||||
id="wizard-step-1"
|
||||
name="wizard-step-1"
|
||||
className="space-y-4"
|
||||
id={`wizard-step-${props.currentStep}`}
|
||||
name={`wizard-step-${props.currentStep}`}
|
||||
className="flex justify-center space-y-4"
|
||||
onSubmit={(e) => {
|
||||
props.setIsLoading(true);
|
||||
e.preventDefault();
|
||||
router.replace(`/auth/setup?step=2&category=calendar`);
|
||||
router.replace(props.nextStepPath);
|
||||
}}>
|
||||
<div className="min-h-36 my-6 flex flex-col items-center justify-center">
|
||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-600 dark:bg-white">
|
||||
@@ -3,10 +3,9 @@ import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
|
||||
|
||||
type TeamType = TeamPageProps["team"];
|
||||
@@ -20,7 +19,7 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={`/${member.username}`}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full w-[90%] flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
||||
<div className="sm:min-w-80 sm:max-w-80 dark:bg-darkgray-200 dark:hover:bg-darkgray-300 group flex min-h-full flex-col space-y-2 rounded-md bg-white p-4 hover:cursor-pointer hover:bg-gray-50 ">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={member.name || ""}
|
||||
|
||||
@@ -21,7 +21,7 @@ const EditableHeading = function EditableHeading({
|
||||
<label className="min-w-8 relative inline-block">
|
||||
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
||||
{!isEditing && isReady && (
|
||||
<FiEdit2 className="ml-1 inline h-4 w-4 align-top text-gray-700 group-hover:text-gray-500" />
|
||||
<FiEdit2 className=" ml-1 -mt-px inline h-3 w-3 text-gray-500 group-hover:text-gray-500" />
|
||||
)}
|
||||
<input
|
||||
{...passThroughProps}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
@@ -6,6 +5,8 @@ import { User } from "@calcom/prisma/client";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
|
||||
import useRouterQuery from "@lib/hooks/useRouterQuery";
|
||||
|
||||
import { PremiumTextfield } from "./PremiumTextfield";
|
||||
import { UsernameTextfield } from "./UsernameTextfield";
|
||||
|
||||
@@ -21,7 +22,7 @@ export const UsernameAvailabilityField = ({
|
||||
onErrorMutation,
|
||||
user,
|
||||
}: UsernameAvailabilityFieldProps) => {
|
||||
const [currentUsername, setCurrentUsername] = useState(user.username ?? "");
|
||||
const { username: currentUsername, setQuery: setCurrentUsername } = useRouterQuery("username");
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
username: currentUsername,
|
||||
|
||||
@@ -88,7 +88,7 @@ const withQuery = <
|
||||
TInput = inferProcedureInput<TQuery>,
|
||||
TOutput = inferProcedureOutput<TQuery>
|
||||
>(
|
||||
queryProcedure: DecorateProcedure<TQuery, any>,
|
||||
queryProcedure: DecorateProcedure<TQuery, any, any>,
|
||||
input?: TInput,
|
||||
params?: UseTRPCQueryOptions<any, TInput, TOutput, TOutput, TError>
|
||||
) => {
|
||||
|
||||
@@ -13,18 +13,20 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import usePublicPage from "@lib/hooks/usePublicPage";
|
||||
import { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children: React.ReactNode }>(
|
||||
({ children }) => <>{children}</>
|
||||
);
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<NextAppProps, "Component"> & {
|
||||
export type AppProps = Omit<NextAppProps<WithNonceProps & Record<string, unknown>>, "Component"> & {
|
||||
Component: NextAppProps["Component"] & {
|
||||
requiresLicense?: boolean;
|
||||
isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean);
|
||||
getLayout?: (page: React.ReactElement, router: NextRouter) => ReactNode;
|
||||
};
|
||||
|
||||
/** Will be defined only is there was an error */
|
||||
err?: Error;
|
||||
};
|
||||
@@ -77,6 +79,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<ThemeProvider
|
||||
nonce={props.pageProps.nonce}
|
||||
enableColorScheme={false}
|
||||
storageKey={storageKey}
|
||||
forcedTheme={forcedTheme}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import crypto from "crypto";
|
||||
import { IncomingMessage, OutgoingMessage } from "http";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
function getCspPolicy(nonce: string) {
|
||||
//TODO: Do we need to explicitly define it in turbo.json
|
||||
const CSP_POLICY = process.env.CSP_POLICY;
|
||||
|
||||
// Note: "non-strict" policy only allows inline styles otherwise it's the same as "strict"
|
||||
// We can remove 'unsafe-inline' from style-src when we add nonces to all style tags
|
||||
// Maybe see how @next-safe/middleware does it if it's supported.
|
||||
const useNonStrictPolicy = CSP_POLICY === "non-strict";
|
||||
|
||||
return `
|
||||
default-src 'self' ${IS_PRODUCTION ? "" : "data:"};
|
||||
script-src ${
|
||||
IS_PRODUCTION
|
||||
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
|
||||
"'nonce-" + nonce + "' 'strict-dynamic' 'self' 'unsafe-inline' https:"
|
||||
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
|
||||
"'unsafe-inline' 'unsafe-eval' https: http:"
|
||||
};
|
||||
object-src 'none';
|
||||
base-uri 'none';
|
||||
child-src app.cal.com;
|
||||
style-src 'self' ${
|
||||
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
|
||||
} app.cal.com;
|
||||
font-src 'self';
|
||||
img-src 'self' https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:
|
||||
`;
|
||||
}
|
||||
|
||||
// Taken from @next-safe/middleware
|
||||
const isPagePathRequest = (url: URL) => {
|
||||
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
|
||||
const isFile = /\..*$/;
|
||||
const { pathname } = url;
|
||||
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
|
||||
};
|
||||
|
||||
export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
|
||||
if (!req) {
|
||||
return { nonce: undefined };
|
||||
}
|
||||
const existingNonce = req.headers["x-nonce"];
|
||||
if (existingNonce) {
|
||||
const existingNoneParsed = z.string().safeParse(existingNonce);
|
||||
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
|
||||
}
|
||||
if (!req.url) {
|
||||
return { nonce: undefined };
|
||||
}
|
||||
const CSP_POLICY = process.env.CSP_POLICY;
|
||||
const cspEnabledForInstance = CSP_POLICY;
|
||||
const nonce = crypto.randomBytes(16).toString("base64");
|
||||
|
||||
const parsedUrl = new URL(req.url, "http://base_url");
|
||||
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
|
||||
if (!cspEnabledForPage) {
|
||||
return {
|
||||
nonce: undefined,
|
||||
};
|
||||
}
|
||||
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
|
||||
// It is generated for all page requests but only used by pages that need CSP
|
||||
req.headers["x-nonce"] = nonce;
|
||||
|
||||
if (res) {
|
||||
res.setHeader(
|
||||
req.headers["x-csp-enforce"] === "true"
|
||||
? "Content-Security-Policy"
|
||||
: "Content-Security-Policy-Report-Only",
|
||||
getCspPolicy(nonce)
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
return { nonce };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
|
||||
/** @deprecated use the one from `@calcom/lib/hooks/useLocale` */
|
||||
export const useLocale = () => {
|
||||
const { i18n, t } = useTranslation("common");
|
||||
|
||||
return {
|
||||
i18n,
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { GetServerSideProps, GetServerSidePropsContext } from "next";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
export type WithNonceProps = {
|
||||
nonce?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Make any getServerSideProps fn return the nonce so that it can be used by Components in the page to add any script tag.
|
||||
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
|
||||
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
|
||||
*/
|
||||
export default function withNonce(getServerSideProps: GetServerSideProps) {
|
||||
return async (context: GetServerSidePropsContext) => {
|
||||
const ssrResponse = await getServerSideProps(context);
|
||||
const { nonce } = csp(context.req, context.res);
|
||||
|
||||
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
|
||||
const nonceProps = nonce
|
||||
? {
|
||||
nonce,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!("props" in ssrResponse)) {
|
||||
return ssrResponse;
|
||||
}
|
||||
|
||||
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
|
||||
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
|
||||
|
||||
return {
|
||||
...ssrResponse,
|
||||
props: {
|
||||
...ssrResponse.props,
|
||||
...nonceProps,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
+52
-1
@@ -1,3 +1,4 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
import { collectEvents } from "next-collect/server";
|
||||
import { NextMiddleware, NextResponse, userAgent } from "next/server";
|
||||
|
||||
@@ -8,6 +9,28 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
|
||||
const middleware: NextMiddleware = async (req) => {
|
||||
const url = req.nextUrl;
|
||||
|
||||
if (!url.pathname.startsWith("/api")) {
|
||||
//
|
||||
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||
// by the application the user is automatically redirected to /auth/login.
|
||||
//
|
||||
// - For this reason our matchers are sufficient for an app-wide maintenance page.
|
||||
//
|
||||
try {
|
||||
// Check whether the maintenance page should be shown
|
||||
const isInMaintenanceMode = await get<boolean>("isInMaintenanceMode");
|
||||
// If is in maintenance mode, point the url pathname to the maintenance page
|
||||
if (isInMaintenanceMode) {
|
||||
req.nextUrl.pathname = `/maintenance`;
|
||||
return NextResponse.rewrite(req.nextUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
// show the default page if EDGE_CONFIG env var is missing,
|
||||
// but log the error to the console
|
||||
// console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (["/api/collect-events", "/api/auth"].some((p) => url.pathname.startsWith(p))) {
|
||||
const callbackUrl = url.searchParams.get("callbackUrl");
|
||||
const { isBot } = userAgent(req);
|
||||
@@ -36,11 +59,39 @@ const middleware: NextMiddleware = async (req) => {
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/trpc/")) {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-cal-timezone", req.headers.get("x-vercel-ip-timezone") ?? "");
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/auth/login")) {
|
||||
const moreHeaders = new Headers(req.headers);
|
||||
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
|
||||
moreHeaders.set("x-csp-enforce", "true");
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
headers: moreHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
};
|
||||
|
||||
export const config = {
|
||||
matcher: ["/api/collect-events/:path*", "/api/auth/:path*", "/apps/routing_forms/:path*", "/:path*/embed"],
|
||||
matcher: [
|
||||
"/api/collect-events/:path*",
|
||||
"/api/auth/:path*",
|
||||
"/apps/routing_forms/:path*",
|
||||
"/:path*/embed",
|
||||
"/api/trpc/:path*",
|
||||
"/auth/login",
|
||||
],
|
||||
};
|
||||
|
||||
export default collectEvents({
|
||||
|
||||
+32
-4
@@ -35,6 +35,12 @@ if (!process.env.NEXT_PUBLIC_WEBSITE_URL) {
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
}
|
||||
|
||||
if (process.env.CSP_POLICY === "strict" && process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
"Strict CSP policy(for style-src) is not yet supported in production. You can experiment with it in Dev Mode"
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.EMAIL_FROM) {
|
||||
console.warn(
|
||||
"\x1b[33mwarn",
|
||||
@@ -76,7 +82,6 @@ if (process.env.ANALYZE === "true") {
|
||||
|
||||
plugins.push(withTM);
|
||||
plugins.push(withAxiom);
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
i18n,
|
||||
@@ -98,7 +103,7 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
webpack: (config) => {
|
||||
webpack: (config, { webpack, buildId }) => {
|
||||
config.plugins.push(
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
@@ -120,6 +125,8 @@ const nextConfig = {
|
||||
})
|
||||
);
|
||||
|
||||
config.plugins.push(new webpack.DefinePlugin({ "process.env.BUILD_ID": JSON.stringify(buildId) }));
|
||||
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback, // if you miss it, all the other options in fallback, specified
|
||||
// by next.js will be dropped. Doesn't make much sense, but how it is
|
||||
@@ -179,8 +186,7 @@ const nextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
// prettier-ignore
|
||||
source: "/:path*((?<!\/embed$)(?<!\/embed\/preview\.html$))",
|
||||
source: "/auth/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
@@ -188,6 +194,28 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/signup",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.5.8",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -61,6 +61,7 @@
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^4.3.9",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@vercel/edge-config": "^0.1.1",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
"@vercel/og": "^0.0.21",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
@@ -165,5 +166,11 @@
|
||||
"ts-jest": "^28.0.8",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
"budgetPercentIncreaseRed": 20,
|
||||
"minimumChangeThreshold": 500,
|
||||
"showDetails": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { COMPANY_NAME, DEVELOPER_DOCS, DOCS_URL, JOIN_SLACK, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
import { FiFileText, FiCheck, FiBookOpen, FiChevronRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { ssgInit } from "@server/lib/ssg";
|
||||
|
||||
export default function Custom404() {
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
{eventTypes.map((type, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
|
||||
className="dark:bg-darkgray-100 dark:border-darkgray-300 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50">
|
||||
<FiArrowRight className="absolute right-3 top-3 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
<Link
|
||||
href={getUsernameSlugLink({ users: props.users, slug: type.slug })}
|
||||
@@ -156,7 +156,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
<div
|
||||
className={classNames(
|
||||
"rounded-md ",
|
||||
!isEventListEmpty && "border border-gray-200 dark:border-gray-700 dark:hover:border-gray-600"
|
||||
!isEventListEmpty && "dark:border-darkgray-300 border border-gray-200"
|
||||
)}
|
||||
data-testid="event-types">
|
||||
{user.away ? (
|
||||
@@ -175,7 +175,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||
<div
|
||||
key={type.id}
|
||||
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
|
||||
className="dark:bg-darkgray-100 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:hover:border-gray-600">
|
||||
className="dark:bg-darkgray-100 dark:hover:bg-darkgray-200 dark:border-darkgray-300 group relative border-b border-gray-200 bg-white first:rounded-t-md last:rounded-b-md last:border-b-0 hover:bg-gray-50">
|
||||
<FiArrowRight className="absolute right-4 top-4 h-4 w-4 text-black opacity-0 transition-opacity group-hover:opacity-100 dark:text-white" />
|
||||
{/* Don't prefetch till the time we drop the amount of javascript in [user][type] page which is impacting score for [user] page */}
|
||||
<Link
|
||||
@@ -291,6 +291,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
const isDynamicGroup = users.length > 1;
|
||||
|
||||
if (isDynamicGroup) {
|
||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||
users.sort((a, b) => {
|
||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
const dynamicNames = isDynamicGroup
|
||||
? users.map((user) => {
|
||||
return user.name || "";
|
||||
|
||||
@@ -3,13 +3,18 @@ import { GetStaticPaths, GetStaticPropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { privacyFilteredLocations, LocationObject } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import {
|
||||
EventTypeMetaDataSchema,
|
||||
teamMetadataSchema,
|
||||
userMetadata as userMetadataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -215,6 +220,7 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
||||
darkBrandColor: true,
|
||||
defaultScheduleId: true,
|
||||
allowDynamicBooking: true,
|
||||
metadata: true,
|
||||
away: true,
|
||||
schedules: {
|
||||
select: {
|
||||
@@ -233,7 +239,32 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||
let sortedUsers: typeof users = [];
|
||||
if (users.length > 1) {
|
||||
sortedUsers = users.sort((a, b) => {
|
||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
let locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
|
||||
|
||||
// Get the prefered location type from the first user
|
||||
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
||||
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
||||
|
||||
if (preferedLocationType?.appSlug) {
|
||||
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
||||
const appType = foundApp?.appData?.location?.type;
|
||||
if (appType) {
|
||||
// Replace the location with the prefered location type
|
||||
// This will still be default to daily if the app is not found
|
||||
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
||||
}
|
||||
}
|
||||
|
||||
const eventTypeObject = Object.assign({}, eventType, {
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata || {}),
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { LocationObject, privacyFilteredLocations } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import {
|
||||
getDefaultEvent,
|
||||
@@ -11,7 +12,11 @@ import {
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { bookEventTypeSelect } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import {
|
||||
customInputSchema,
|
||||
EventTypeMetaDataSchema,
|
||||
userMetadata as userMetadataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { asStringOrNull, asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import getBooking, { GetBookingType } from "@lib/getBooking";
|
||||
@@ -85,6 +90,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
away: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -117,8 +123,38 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
const getLocations = () => {
|
||||
let locations = eventTypeRaw.locations || [];
|
||||
if (!isDynamicGroupBooking) return locations;
|
||||
|
||||
let sortedUsers: typeof users = [];
|
||||
// sort and be in the same order as usernameList so first user is the first user in the list
|
||||
if (users.length > 1) {
|
||||
sortedUsers = users.sort((a, b) => {
|
||||
const aIndex = (a.username && usernameList.indexOf(a.username)) || 0;
|
||||
const bIndex = (b.username && usernameList.indexOf(b.username)) || 0;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
// Get the prefered location type from the first user
|
||||
const firstUsersMetadata = userMetadataSchema.parse(sortedUsers[0].metadata || {});
|
||||
const preferedLocationType = firstUsersMetadata?.defaultConferencingApp;
|
||||
|
||||
if (preferedLocationType?.appSlug) {
|
||||
const foundApp = getAppFromSlug(preferedLocationType.appSlug);
|
||||
const appType = foundApp?.appData?.location?.type;
|
||||
if (appType) {
|
||||
// Replace the location with the prefered location type
|
||||
// This will still be default to daily if the app is not found
|
||||
locations = [{ type: appType, link: preferedLocationType.appLink }] as LocationObject[];
|
||||
}
|
||||
}
|
||||
return locations;
|
||||
};
|
||||
|
||||
const eventTypeObject = [eventType].map((e) => {
|
||||
let locations = getLocations();
|
||||
locations = privacyFilteredLocations(locations as LocationObject[]);
|
||||
return {
|
||||
...e,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* This page is empty for the user, it is used only to take advantage of the
|
||||
* caching system that NextJS uses SSG pages.
|
||||
* TODO: Redirect to user profile on browser
|
||||
*/
|
||||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getCachedResults } from "@calcom/core";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const CalendarCache = () => <div />;
|
||||
|
||||
const paramsSchema = z.object({ user: z.string(), month: z.string() });
|
||||
export const getStaticProps: GetStaticProps<
|
||||
{ results: Awaited<ReturnType<typeof getCachedResults>> },
|
||||
{ user: string }
|
||||
> = async (context) => {
|
||||
const { user: username, month } = paramsSchema.parse(context.params);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
credentials: true,
|
||||
selectedCalendars: true,
|
||||
},
|
||||
});
|
||||
const startDate = (
|
||||
dayjs(month, "YYYY-MM").isSame(dayjs(), "month") ? dayjs() : dayjs(month, "YYYY-MM")
|
||||
).startOf("day");
|
||||
const endDate = startDate.endOf("month");
|
||||
try {
|
||||
const results = user?.credentials
|
||||
? await getCachedResults(
|
||||
user?.credentials,
|
||||
startDate.format(),
|
||||
endDate.format(),
|
||||
user?.selectedCalendars
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
props: { results, date: new Date().toISOString() },
|
||||
revalidate: 1,
|
||||
};
|
||||
} catch (error) {
|
||||
let message = "Unknown error while fetching calendarƒ";
|
||||
if (error instanceof Error) message = error.message;
|
||||
console.error(error, message);
|
||||
return {
|
||||
props: { results: [], date: new Date().toISOString(), message },
|
||||
revalidate: 1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return {
|
||||
paths: [],
|
||||
fallback: "blocking",
|
||||
};
|
||||
};
|
||||
|
||||
export default CalendarCache;
|
||||
+17
-86
@@ -1,18 +1,10 @@
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import Head from "next/head";
|
||||
import Script from "next/script";
|
||||
import superjson from "superjson";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { httpBatchLink } from "@calcom/trpc/client/links/httpBatchLink";
|
||||
import { httpLink } from "@calcom/trpc/client/links/httpLink";
|
||||
import { loggerLink } from "@calcom/trpc/client/links/loggerLink";
|
||||
import { splitLink } from "@calcom/trpc/client/links/splitLink";
|
||||
import { withTRPC } from "@calcom/trpc/next";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/react";
|
||||
import { Maybe } from "@calcom/trpc/server";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import AppProviders, { AppProps } from "@lib/app-providers";
|
||||
import { seoConfig } from "@lib/config/next-seo.config";
|
||||
@@ -29,13 +21,27 @@ function MyApp(props: AppProps) {
|
||||
} else if (router.pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
|
||||
const providerProps = {
|
||||
...props,
|
||||
pageProps: {
|
||||
...props.pageProps,
|
||||
nonce,
|
||||
},
|
||||
};
|
||||
// Use the layout defined at the page level, if available
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
return (
|
||||
<AppProviders {...props}>
|
||||
<AppProviders {...providerProps}>
|
||||
<DefaultSeo {...seoConfig.defaultNextSeo} />
|
||||
<I18nLanguageHandler />
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
@@ -56,79 +62,4 @@ function MyApp(props: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default withTRPC<AppRouter>({
|
||||
config() {
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? "/api/trpc"
|
||||
: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}/api/trpc`
|
||||
: `http://${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
||||
|
||||
/**
|
||||
* If you want to use SSR, you need to use the server's full URL
|
||||
* @link https://trpc.io/docs/ssr
|
||||
*/
|
||||
return {
|
||||
/**
|
||||
* @link https://trpc.io/docs/links
|
||||
*/
|
||||
links: [
|
||||
// adds pretty logs to your console in development and logs errors in production
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
splitLink({
|
||||
// check for context property `skipBatch`
|
||||
condition: (op) => {
|
||||
return op.context.skipBatch === true;
|
||||
},
|
||||
// when condition is true, use normal request
|
||||
true: httpLink({ url }),
|
||||
// when condition is false, use batching
|
||||
false: httpBatchLink({
|
||||
url,
|
||||
/** @link https://github.com/trpc/trpc/issues/2008 */
|
||||
// maxBatchSize: 7
|
||||
}),
|
||||
}),
|
||||
],
|
||||
/**
|
||||
* @link https://react-query.tanstack.com/reference/QueryClient
|
||||
*/
|
||||
queryClientConfig: {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/**
|
||||
* 1s should be enough to just keep identical query waterfalls low
|
||||
* @example if one page components uses a query that is also used further down the tree
|
||||
*/
|
||||
staleTime: 1000,
|
||||
/**
|
||||
* Retry `useQuery()` calls depending on this function
|
||||
*/
|
||||
retry(failureCount, _err) {
|
||||
const err = _err as never as Maybe<TRPCClientErrorLike<AppRouter>>;
|
||||
const code = err?.data?.code;
|
||||
if (code === "BAD_REQUEST" || code === "FORBIDDEN" || code === "UNAUTHORIZED") {
|
||||
// if input data is wrong or you're not authorized there's no point retrying a query
|
||||
return false;
|
||||
}
|
||||
const MAX_QUERY_RETRIES = 3;
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/data-transformers
|
||||
*/
|
||||
transformer: superjson,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/ssr
|
||||
*/
|
||||
ssr: false,
|
||||
})(MyApp);
|
||||
export default trpc.withTRPC(MyApp);
|
||||
|
||||
@@ -1,58 +1,36 @@
|
||||
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
|
||||
import Script from "next/script";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getDirFromLang } from "@calcom/lib/i18n";
|
||||
|
||||
import { csp } from "@lib/csp";
|
||||
|
||||
type Props = Record<string, unknown> & DocumentProps;
|
||||
|
||||
function toRunBeforeReactOnClient() {
|
||||
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
|
||||
/* Iframe Name */
|
||||
window.name.includes("cal-embed");
|
||||
|
||||
window.isEmbed = () => {
|
||||
// Once an embed mode always an embed mode
|
||||
return calEmbedMode;
|
||||
};
|
||||
|
||||
window.resetEmbedStatus = () => {
|
||||
try {
|
||||
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||
window.sessionStorage.removeItem("calEmbedMode");
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
window.getEmbedTheme = () => {
|
||||
const url = new URL(document.URL);
|
||||
return url.searchParams.get("theme") as "dark" | "light";
|
||||
};
|
||||
|
||||
window.getEmbedNamespace = () => {
|
||||
const url = new URL(document.URL);
|
||||
const namespace = url.searchParams.get("embed");
|
||||
return namespace;
|
||||
};
|
||||
|
||||
window.isPageOptimizedForEmbed = () => {
|
||||
// Those pages are considered optimized, which know at backend that they are rendering for embed.
|
||||
// Such pages can be shown straightaway without a loader for a better embed experience
|
||||
return location.pathname.includes("forms/");
|
||||
};
|
||||
}
|
||||
|
||||
class MyDocument extends Document<Props> {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const { nonce } = csp(ctx.req || null, ctx.res || null);
|
||||
if (!process.env.CSP_POLICY) {
|
||||
ctx.res?.setHeader("x-csp", "not-opted-in");
|
||||
} else if (!ctx.res?.getHeader("x-csp")) {
|
||||
// If x-csp not set by gSSP, then it's initialPropsOnly
|
||||
ctx.res?.setHeader("x-csp", "initialPropsOnly");
|
||||
}
|
||||
const isEmbed = ctx.asPath?.includes("/embed") || ctx.asPath?.includes("embedType=");
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { isEmbed, ...initialProps };
|
||||
return { isEmbed, nonce, ...initialProps };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { locale } = this.props.__NEXT_DATA__;
|
||||
const { isEmbed } = this.props;
|
||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||
const dir = getDirFromLang(locale);
|
||||
return (
|
||||
<Html lang={locale} dir={dir}>
|
||||
<Head>
|
||||
<Head nonce={nonce}>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
@@ -68,22 +46,13 @@ class MyDocument extends Document<Props> {
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
|
||||
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
|
||||
Persist the embed mode in sessionStorage because query param might get lost during browsing.
|
||||
*/}
|
||||
<Script
|
||||
id="run-before-client"
|
||||
strategy="beforeInteractive"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(${toRunBeforeReactOnClient.toString()})()`,
|
||||
}}
|
||||
/>
|
||||
<Script src="/embed-init-iframe.js" strategy="beforeInteractive" />
|
||||
</Head>
|
||||
|
||||
<body
|
||||
className="dark:bg-darkgray-50 desktop-transparent bg-gray-100 antialiased"
|
||||
style={
|
||||
this.props.isEmbed
|
||||
isEmbed
|
||||
? {
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
@@ -95,7 +64,7 @@ class MyDocument extends Document<Props> {
|
||||
: {}
|
||||
}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
<NextScript nonce={nonce} />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
@@ -164,6 +164,7 @@ if (IS_GOOGLE_LOGIN_ENABLED) {
|
||||
GoogleProvider({
|
||||
clientId: GOOGLE_CLIENT_ID,
|
||||
clientSecret: GOOGLE_CLIENT_SECRET,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -202,6 +203,7 @@ if (isSAMLLoginEnabled) {
|
||||
clientId: "dummy",
|
||||
clientSecret: "dummy",
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -360,7 +362,7 @@ export default NextAuth({
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
const hasValidLicense = await checkLicense(process.env.CALCOM_LICENSE_KEY || "");
|
||||
const hasValidLicense = await checkLicense(prisma);
|
||||
const calendsoSession: Session = {
|
||||
...session,
|
||||
hasValidLicense,
|
||||
@@ -497,7 +499,13 @@ export default NextAuth({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
||||
// User signs up with email/password and then tries to login with Google/SAML using the same email
|
||||
if (
|
||||
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
|
||||
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
|
||||
) {
|
||||
return true;
|
||||
} else if (existingUserWithEmail.identityProvider === IdentityProvider.CAL) {
|
||||
return "/auth/error?error=use-password-login";
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
OR: [
|
||||
{ username },
|
||||
{
|
||||
AND: [{ email: userEmail }, { password: { not: null } }, { username: { not: null } }],
|
||||
AND: [{ email: userEmail }],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
@@ -65,6 +66,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
res.status(200).json({ message: "Calendar Selection Saved" });
|
||||
}
|
||||
|
||||
if (["DELETE", "POST"].includes(req.method)) {
|
||||
await revalidateCalendarCache(res.revalidate, `${session?.user?.username}`);
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
|
||||
where: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Session } from "next-auth";
|
||||
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
|
||||
|
||||
@@ -53,7 +54,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
/* Absolute path didn't work */
|
||||
const handlerMap = (await import("@calcom/app-store/apps.server.generated")).apiHandlers;
|
||||
|
||||
const handlerKey = deriveAppDictKeyFromType(appName, handlerMap);
|
||||
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
|
||||
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
|
||||
@@ -63,8 +63,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
||||
if (typeof handler === "function") {
|
||||
await handler(req, res);
|
||||
if (appName.includes("calendar") && req.session?.user?.username) {
|
||||
await revalidateCalendarCache(res.revalidate, req.session?.user?.username);
|
||||
}
|
||||
} else {
|
||||
await defaultIntegrationAddHandler({ user: req.session?.user, ...handler });
|
||||
if (handler.appType.includes("calendar") && req.session?.user?.username) {
|
||||
await revalidateCalendarCache(res.revalidate, req.session?.user?.username);
|
||||
}
|
||||
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
|
||||
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
|
||||
let isCold = true;
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||
const prePrismaDate = performance.now();
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const preSessionDate = performance.now();
|
||||
const session = await getSession({ req });
|
||||
if (!session) return res.status(409).json({ message: "Unauthorized" });
|
||||
const preUserDate = performance.now();
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
if (!user) return res.status(404).json({ message: "No user found" });
|
||||
const lastUpdate = performance.now();
|
||||
|
||||
res.setHeader("x-is-cold", isCold.toString());
|
||||
isCold = false;
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Hello ${user.name}`,
|
||||
prePrismaDate,
|
||||
prismaDuration: `Prisma took ${preSessionDate - prePrismaDate}ms`,
|
||||
preSessionDate,
|
||||
sessionDuration: `Session took ${preUserDate - preSessionDate}ms`,
|
||||
preUserDate,
|
||||
userDuration: `User took ${lastUpdate - preUserDate}ms`,
|
||||
lastUpdate,
|
||||
wasCold: isCold,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { revalidateCalendarCache } from "@calcom/lib/server/revalidateCalendarCache";
|
||||
|
||||
const querySchema = z.object({
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* This endpoint revalidates users calendar cache several months ahead
|
||||
* Can be used as webhook
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { username } = querySchema.parse(req.query);
|
||||
try {
|
||||
await revalidateCalendarCache(res.revalidate, username);
|
||||
|
||||
return res.status(200).json({ revalidated: true });
|
||||
} catch (err) {
|
||||
return res.status(500).send({ message: "Error revalidating" });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* This file contains tRPC's HTTP response handler
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import * as trpcNext from "@calcom/trpc/server/adapters/next";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { appRouter } from "@calcom/trpc/server/routers/_app";
|
||||
@@ -30,34 +32,46 @@ export default trpcNext.createNextApiHandler({
|
||||
* @link https://trpc.io/docs/caching#api-response-caching
|
||||
*/
|
||||
responseMeta({ ctx, paths, type, errors }) {
|
||||
// assuming we have all our public routes in `viewer.public`
|
||||
// Some helpers relevant to this function only
|
||||
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
||||
// assuming you have all your public routes with the keyword `public` in them
|
||||
const allPublic = paths && paths.every((path) => path.startsWith("viewer.public."));
|
||||
// checking that no procedures errored
|
||||
const allOk = errors.length === 0;
|
||||
// checking we're doing a query request
|
||||
const isQuery = type === "query";
|
||||
const noHeaders = {};
|
||||
|
||||
// i18n response depends on request header
|
||||
const nonCacheableQueries = ["viewer.public.i18n"];
|
||||
const isThereANonCacheableQuery = paths?.some((path) => nonCacheableQueries.includes(path));
|
||||
const isThereACacheableQuery = paths?.some((path) => !nonCacheableQueries.includes(path));
|
||||
if (isThereANonCacheableQuery && isThereACacheableQuery) {
|
||||
console.warn(
|
||||
"Cacheable and Non-cacheable queries are mixed in the same request. Not going to cache the request"
|
||||
);
|
||||
}
|
||||
// We cannot set headers on SSG queries
|
||||
if (!ctx?.res) return noHeaders;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore ctx.req is available for SSR but not SSG
|
||||
if (!!ctx?.req && allPublic && allOk && isQuery && !isThereANonCacheableQuery) {
|
||||
// cache request for 1 day + revalidate once every 5 seconds
|
||||
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
|
||||
return {
|
||||
headers: {
|
||||
"cache-control": `s-maxage=5, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
const defaultHeaders: Record<"headers", Record<string, string>> = {
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const timezone = z.string().safeParse(ctx.req?.headers["x-vercel-ip-timezone"]);
|
||||
if (timezone.success) defaultHeaders.headers["x-cal-timezone"] = timezone.data;
|
||||
|
||||
// We need all these conditions to be true to set cache headers
|
||||
if (!(allPublic && allOk && isQuery)) return defaultHeaders;
|
||||
|
||||
// No cache by default
|
||||
defaultHeaders.headers["cache-control"] = `no-cache`;
|
||||
|
||||
// Our cache can change depending on our current paths value. Since paths is an array,
|
||||
// we want to create a map that can match potential paths with their desired cache value
|
||||
const cacheRules = {
|
||||
"viewer.public.session": `no-cache`,
|
||||
"viewer.public.i18n": `no-cache`,
|
||||
// Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321
|
||||
"viewer.public.slots.getSchedule": `no-cache`, // FIXME
|
||||
} as const;
|
||||
|
||||
// Find which element above is an exact match for this group of paths
|
||||
const matchedPath = paths.find((v) => v in cacheRules) as keyof typeof cacheRules;
|
||||
|
||||
if (matchedPath) defaultHeaders.headers["cache-control"] = cacheRules[matchedPath];
|
||||
|
||||
return defaultHeaders;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useReducer } from "react";
|
||||
import { useReducer, useState } from "react";
|
||||
import z from "zod";
|
||||
|
||||
import { AppSettings } from "@calcom/app-store/_components/AppSettings";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { EventLocationType, getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||
import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||
import { AppSetDefaultLinkDailog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RouterOutputs, trpc } from "@calcom/trpc/react";
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
Dropdown,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
FiBarChart,
|
||||
@@ -41,7 +44,7 @@ import AppListCard from "@components/AppListCard";
|
||||
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
|
||||
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
|
||||
|
||||
function ConnectOrDisconnectIntegrationButton(props: {
|
||||
function ConnectOrDisconnectIntegrationMenuItem(props: {
|
||||
credentialIds: number[];
|
||||
type: App["type"];
|
||||
isGlobal?: boolean;
|
||||
@@ -60,22 +63,15 @@ function ConnectOrDisconnectIntegrationButton(props: {
|
||||
|
||||
if (credentialId || type === "stripe_payment" || isGlobal) {
|
||||
return (
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => handleDisconnect(credentialId)}
|
||||
disabled={isGlobal}
|
||||
StartIcon={FiTrash}>
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => handleDisconnect(credentialId)}
|
||||
disabled={isGlobal}
|
||||
StartIcon={FiTrash}>
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,37 +108,98 @@ interface IntegrationsListProps {
|
||||
handleDisconnect: (credentialId: number) => void;
|
||||
}
|
||||
|
||||
const IntegrationsList = ({ data, handleDisconnect }: IntegrationsListProps) => {
|
||||
const IntegrationsList = ({ data, handleDisconnect, variant }: IntegrationsListProps) => {
|
||||
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Default app updated successfully", "success");
|
||||
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<List>
|
||||
{data.items
|
||||
.filter((item) => item.invalidCredentialIds)
|
||||
.map((item) => (
|
||||
<AppListCard
|
||||
key={item.name}
|
||||
description={item.description}
|
||||
title={item.name}
|
||||
logo={item.logo}
|
||||
isDefault={item.isGlobal}
|
||||
shouldHighlight
|
||||
slug={item.slug}
|
||||
invalidCredential={item.invalidCredentialIds.length > 0}
|
||||
actions={
|
||||
<div className="flex justify-end">
|
||||
<ConnectOrDisconnectIntegrationButton
|
||||
credentialIds={item.credentialIds}
|
||||
type={item.type}
|
||||
isGlobal={item.isGlobal}
|
||||
installed
|
||||
invalidCredentialIds={item.invalidCredentialIds}
|
||||
handleDisconnect={handleDisconnect}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<AppSettings slug={item.slug} />
|
||||
</AppListCard>
|
||||
))}
|
||||
</List>
|
||||
<>
|
||||
<List>
|
||||
{data.items
|
||||
.filter((item) => item.invalidCredentialIds)
|
||||
.map((item) => {
|
||||
const appSlug = item?.slug;
|
||||
const appIsDefault =
|
||||
appSlug === defaultConferencingApp?.appSlug ||
|
||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug);
|
||||
return (
|
||||
<AppListCard
|
||||
key={item.name}
|
||||
description={item.description}
|
||||
title={item.name}
|
||||
logo={item.logo}
|
||||
isDefault={appIsDefault}
|
||||
shouldHighlight
|
||||
slug={item.slug}
|
||||
invalidCredential={item.invalidCredentialIds.length > 0}
|
||||
actions={
|
||||
<div className="flex justify-end">
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={FiVideo}
|
||||
onClick={() => {
|
||||
const locationType = getEventLocationTypeFromApp(
|
||||
item?.locationOption?.value ?? ""
|
||||
);
|
||||
if (locationType?.linkType === "static") {
|
||||
setLocationType({ ...locationType, slug: appSlug });
|
||||
} else {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{t("change_default_conferencing_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<ConnectOrDisconnectIntegrationMenuItem
|
||||
credentialIds={item.credentialIds}
|
||||
type={item.type}
|
||||
isGlobal={item.isGlobal}
|
||||
installed
|
||||
invalidCredentialIds={item.invalidCredentialIds}
|
||||
handleDisconnect={handleDisconnect}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
}>
|
||||
<AppSettings slug={item.slug} />
|
||||
</AppListCard>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
{locationType && (
|
||||
<AppSetDefaultLinkDailog
|
||||
locationType={locationType}
|
||||
setLocationType={() => setLocationType(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -168,46 +225,43 @@ const IntegrationsContainer = ({
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
if (!data.items.length) {
|
||||
return (
|
||||
<EmptyScreen
|
||||
Icon={emptyIcon[variant || "other"]}
|
||||
headline={t("no_category_apps", {
|
||||
category: (variant && t(variant).toLowerCase()) || t("other").toLowerCase(),
|
||||
})}
|
||||
description={t(`no_category_apps_description_${variant || "other"}`)}
|
||||
buttonRaw={
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid={`connect-${variant || "other"}-apps`}
|
||||
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
|
||||
{t(`connect_${variant || "other"}_apps`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{data.items.length > 0 ? (
|
||||
<div className="rounded-md border border-gray-200 p-7">
|
||||
<ShellSubHeading
|
||||
title={t(variant || "other")}
|
||||
subtitle={t(`installed_app_${variant || "other"}_description`)}
|
||||
className="mb-6"
|
||||
actions={
|
||||
<Button
|
||||
href={
|
||||
variant
|
||||
? `/apps/categories/${variant === "conferencing" ? "video" : variant}`
|
||||
: "/apps"
|
||||
}
|
||||
color="secondary"
|
||||
StartIcon={FiPlus}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
<ShellSubHeading
|
||||
title={t(variant || "other")}
|
||||
subtitle={t(`installed_app_${variant || "other"}_description`)}
|
||||
className="mb-6"
|
||||
actions={
|
||||
<Button
|
||||
href={
|
||||
variant ? `/apps/categories/${variant === "conferencing" ? "video" : variant}` : "/apps"
|
||||
}
|
||||
/>
|
||||
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={emptyIcon[variant || "other"]}
|
||||
headline={t("no_category_apps", {
|
||||
category: (variant && t(variant).toLowerCase()) || t("other").toLowerCase(),
|
||||
})}
|
||||
description={t(`no_category_apps_description_${variant || "other"}`)}
|
||||
buttonRaw={
|
||||
<Button
|
||||
color="secondary"
|
||||
data-testid={`connect-${variant || "other"}-apps`}
|
||||
href={variant ? `/apps/categories/${variant}` : "/apps/categories/other"}>
|
||||
{t(`connect_${variant || "other"}_apps`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
color="secondary"
|
||||
StartIcon={FiPlus}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<IntegrationsList handleDisconnect={handleDisconnect} data={data} variant={variant} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
@@ -257,17 +311,19 @@ export default function InstalledApps() {
|
||||
return (
|
||||
<>
|
||||
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
|
||||
{categoryList.includes(category) && (
|
||||
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
|
||||
)}
|
||||
{category === "calendar" && <CalendarListContainer />}
|
||||
{category === "other" && (
|
||||
<IntegrationsContainer
|
||||
handleDisconnect={handleDisconnect}
|
||||
variant={category}
|
||||
exclude={[...categoryList, "calendar"]}
|
||||
/>
|
||||
)}
|
||||
<div className="rounded-md border border-gray-200 p-7">
|
||||
{categoryList.includes(category) && (
|
||||
<IntegrationsContainer handleDisconnect={handleDisconnect} variant={category} />
|
||||
)}
|
||||
{category === "calendar" && <CalendarListContainer />}
|
||||
{category === "other" && (
|
||||
<IntegrationsContainer
|
||||
handleDisconnect={handleDisconnect}
|
||||
variant={category}
|
||||
exclude={[...categoryList, "calendar"]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</InstalledAppsLayout>
|
||||
<DisconnectIntegrationModal
|
||||
handleModelClose={handleModelClose}
|
||||
|
||||
@@ -6,11 +6,10 @@ import Link from "next/link";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Button, TextField } from "@calcom/ui";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -5,10 +5,10 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, EmailField } from "@calcom/ui";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
|
||||
import { FiArrowLeft } from "@calcom/ui/components/icon";
|
||||
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import withNonce, { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import AddToHomescreen from "@components/AddToHomescreen";
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
@@ -40,7 +41,7 @@ export default function Login({
|
||||
isSAMLLoginEnabled,
|
||||
samlTenantID,
|
||||
samlProductID,
|
||||
}: inferSSRProps<typeof getServerSideProps>) {
|
||||
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const methods = useForm<LoginValues>();
|
||||
@@ -181,8 +182,6 @@ export default function Login({
|
||||
StartIcon={FaGoogle}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
// track Google logins. Without personal data/payload
|
||||
telemetry.event(telemetryEventTypes.googleLogin, collectPageParameters());
|
||||
await signIn("google");
|
||||
}}>
|
||||
{t("signin_with_google")}
|
||||
@@ -205,7 +204,8 @@ export default function Login({
|
||||
);
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
|
||||
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const { req } = context;
|
||||
const session = await getSession({ req });
|
||||
const ssr = await ssrInit(context);
|
||||
@@ -229,7 +229,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
csrfToken: await getCsrfToken(context),
|
||||
@@ -240,4 +239,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
samlProductID,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getServerSideProps = withNonce(_getServerSideProps);
|
||||
|
||||
@@ -1,40 +1,127 @@
|
||||
import { UserPermissionRole } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
|
||||
import { getDeploymentKey } from "@calcom/features/ee/deployment/lib/getDeploymentKey";
|
||||
import { getSession } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { WizardForm } from "@calcom/ui";
|
||||
import { Meta, WizardForm } from "@calcom/ui";
|
||||
|
||||
import SetupFormStep1 from "./steps/SetupFormStep1";
|
||||
import StepDone from "./steps/StepDone";
|
||||
import { AdminUserContainer as AdminUser } from "@components/setup/AdminUser";
|
||||
import ChooseLicense from "@components/setup/ChooseLicense";
|
||||
import EnterpriseLicense from "@components/setup/EnterpriseLicense";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const [isLoadingStep1, setIsLoadingStep1] = useState(false);
|
||||
const shouldDisable = props.userCount !== 0;
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState(props.isFreeLicense ? "FREE" : "EE");
|
||||
const isFreeLicense = value === "FREE";
|
||||
const [isEnabledEE, setIsEnabledEE] = useState(!props.isFreeLicense);
|
||||
const setStep = (newStep: number) => {
|
||||
router.replace(`/auth/setup?step=${newStep || 1}`, undefined, { shallow: true });
|
||||
};
|
||||
|
||||
const steps = [
|
||||
const steps: React.ComponentProps<typeof WizardForm>["steps"] = [
|
||||
{
|
||||
title: t("administrator_user"),
|
||||
description: t("lets_create_first_administrator_user"),
|
||||
content: shouldDisable ? <StepDone /> : <SetupFormStep1 setIsLoading={setIsLoadingStep1} />,
|
||||
isLoading: isLoadingStep1,
|
||||
content: (setIsLoading) => (
|
||||
<AdminUser
|
||||
onSubmit={() => {
|
||||
setIsLoading(true);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setStep(2);
|
||||
}}
|
||||
onError={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
userCount={props.userCount}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("enable_apps"),
|
||||
description: t("enable_apps_description"),
|
||||
content: <AdminAppsList baseURL="/auth/setup?step=2" useQueryParam={true} />,
|
||||
isLoading: false,
|
||||
title: t("choose_a_license"),
|
||||
description: t("choose_license_description"),
|
||||
content: (setIsLoading) => {
|
||||
return (
|
||||
<ChooseLicense
|
||||
id="wizard-step-2"
|
||||
name="wizard-step-2"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={() => {
|
||||
setIsLoading(true);
|
||||
setStep(3);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!isFreeLicense) {
|
||||
steps.push({
|
||||
title: t("step_enterprise_license"),
|
||||
description: t("step_enterprise_license_description"),
|
||||
content: (setIsLoading) => {
|
||||
const currentStep = 3;
|
||||
return (
|
||||
<EnterpriseLicense
|
||||
id={`wizard-step-${currentStep}`}
|
||||
name={`wizard-step-${currentStep}`}
|
||||
onSubmit={() => {
|
||||
setIsLoading(true);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setStep(currentStep + 1);
|
||||
}}
|
||||
onSuccessValidate={() => {
|
||||
setIsEnabledEE(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
isEnabled: isEnabledEE,
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
title: t("enable_apps"),
|
||||
description: t("enable_apps_description"),
|
||||
contentClassname: "!pb-0 mb-[-1px]",
|
||||
content: (setIsLoading) => {
|
||||
const currentStep = isFreeLicense ? 3 : 4;
|
||||
return (
|
||||
<AdminAppsList
|
||||
id={`wizard-step-${currentStep}`}
|
||||
name={`wizard-step-${currentStep}`}
|
||||
classNames={{
|
||||
form: "mb-4 rounded-md bg-white px-0 pt-0 md:max-w-full",
|
||||
appCategoryNavigationContainer: "max-h-[400px] overflow-y-auto md:p-4",
|
||||
verticalTabsItem: "!w-48 md:p-4",
|
||||
}}
|
||||
baseURL={`/auth/setup?step=${currentStep}`}
|
||||
useQueryParam={true}
|
||||
onSubmit={() => {
|
||||
setIsLoading(true);
|
||||
router.replace("/");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex items-center bg-gray-100 print:h-full">
|
||||
<Meta title={t("setup")} description={t("setup_description")} />
|
||||
<main className="flex items-center bg-gray-100 print:h-full md:h-screen">
|
||||
<WizardForm
|
||||
href="/auth/setup"
|
||||
steps={steps}
|
||||
@@ -49,6 +136,7 @@ export default function Setup(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
}
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const userCount = await prisma.user.count();
|
||||
const { req } = context;
|
||||
const session = await getSession({ req });
|
||||
@@ -62,8 +150,30 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
};
|
||||
}
|
||||
|
||||
let deploymentKey = await getDeploymentKey(prisma);
|
||||
|
||||
// Check existant CALCOM_LICENSE_KEY env var and acccount for it
|
||||
if (!!process.env.CALCOM_LICENSE_KEY && !deploymentKey) {
|
||||
await prisma.deployment.upsert({
|
||||
where: { id: 1 },
|
||||
update: {
|
||||
licenseKey: process.env.CALCOM_LICENSE_KEY,
|
||||
agreedLicenseAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
licenseKey: process.env.CALCOM_LICENSE_KEY,
|
||||
agreedLicenseAt: new Date(),
|
||||
},
|
||||
});
|
||||
deploymentKey = await getDeploymentKey(prisma);
|
||||
}
|
||||
|
||||
const isFreeLicense = deploymentKey === "";
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
isFreeLicense,
|
||||
userCount,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
)
|
||||
}
|
||||
CTA={
|
||||
<div className="mr-10 flex items-center justify-end xl:mr-36">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center rounded-md px-2 sm:hover:bg-gray-100">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
@@ -186,7 +186,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
});
|
||||
}}
|
||||
className="flex flex-col sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="mx-10 flex-1 flex-row xl:mr-0 xl:ml-36">
|
||||
<div className="flex-1 flex-row xl:mr-0">
|
||||
<div className="mb-6 rounded-md border">
|
||||
<div>
|
||||
{typeof me.data?.weekStart === "string" && (
|
||||
@@ -206,7 +206,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
{data?.workingHours && <DateOverride workingHours={data.workingHours} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 ml-10 space-y-2 lg:col-span-1 xl:ml-36">
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
@@ -228,7 +228,7 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
/>
|
||||
</div>
|
||||
<hr className="my-6 mr-8" />
|
||||
<div className="rounded-md">
|
||||
<div className="hidden rounded-md md:block">
|
||||
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
||||
<div className="mt-3 flex">
|
||||
<Button href="/availability/troubleshoot" color="secondary">
|
||||
|
||||
@@ -11,7 +11,11 @@ import { RRule } from "rrule";
|
||||
import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import { getEventLocationValue, getSuccessPageLocationMessage } from "@calcom/app-store/locations";
|
||||
import {
|
||||
getEventLocationValue,
|
||||
getSuccessPageLocationMessage,
|
||||
guessEventLocationType,
|
||||
} from "@calcom/app-store/locations";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import dayjs, { ConfigType } from "@calcom/dayjs";
|
||||
@@ -34,9 +38,10 @@ import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/t
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Button, EmailInput, HeadSeo } from "@calcom/ui";
|
||||
import { FiX, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
||||
import { FiX, FiExternalLink, FiChevronLeft, FiCheck, FiCalendar } from "@calcom/ui/components/icon";
|
||||
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -188,19 +193,22 @@ export default function Success(props: SuccessProps) {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
|
||||
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(props.bookingInfo.location)
|
||||
? props.bookingInfo.location[0]
|
||||
const location: ReturnType<typeof getEventLocationValue> = props.bookingInfo.location
|
||||
? props.bookingInfo.location
|
||||
: // If there is no location set then we default to Cal Video
|
||||
"integrations:daily";
|
||||
|
||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||
props?.bookingInfo?.metadata || {}
|
||||
)?.videoCallUrl;
|
||||
|
||||
if (!location) {
|
||||
// Can't use logger.error because it throws error on client. stdout isn't available to it.
|
||||
console.error(`No location found `);
|
||||
}
|
||||
|
||||
const status = props.bookingInfo?.status;
|
||||
const reschedule = props.bookingInfo.status === BookingStatus.ACCEPTED;
|
||||
const cancellationReason = props.bookingInfo.cancellationReason;
|
||||
const cancellationReason = props.bookingInfo.cancellationReason || props.bookingInfo.rejectionReason;
|
||||
|
||||
const attendeeName =
|
||||
typeof props?.bookingInfo?.attendees?.[0]?.name === "string"
|
||||
@@ -240,7 +248,7 @@ export default function Success(props: SuccessProps) {
|
||||
const giphyAppData = getEventTypeAppData(eventType, "giphy");
|
||||
const giphyImage = giphyAppData?.thankYouPage;
|
||||
|
||||
const eventName = getEventName(eventNameObject);
|
||||
const eventName = getEventName(eventNameObject, true);
|
||||
const needsConfirmation = eventType.requiresConfirmation && reschedule != true;
|
||||
const isCancelled = status === "CANCELLED" || status === "REJECTED";
|
||||
const telemetry = useTelemetry();
|
||||
@@ -281,8 +289,8 @@ export default function Success(props: SuccessProps) {
|
||||
|
||||
function eventLink(): string {
|
||||
const optional: { location?: string } = {};
|
||||
if (location) {
|
||||
optional["location"] = location;
|
||||
if (locationVideoCallUrl) {
|
||||
optional["location"] = locationVideoCallUrl;
|
||||
}
|
||||
|
||||
const event = createEvent({
|
||||
@@ -332,12 +340,17 @@ export default function Success(props: SuccessProps) {
|
||||
);
|
||||
const customInputs = bookingInfo?.customInputs;
|
||||
|
||||
const locationToDisplay = getSuccessPageLocationMessage(location, t);
|
||||
const locationToDisplay = getSuccessPageLocationMessage(
|
||||
locationVideoCallUrl ? locationVideoCallUrl : location,
|
||||
t,
|
||||
bookingInfo.status
|
||||
);
|
||||
|
||||
const hasSMSAttendeeAction =
|
||||
eventType.workflows.find((workflowEventType) =>
|
||||
workflowEventType.workflow.steps.find((step) => step.action === WorkflowActions.SMS_ATTENDEE)
|
||||
) !== undefined;
|
||||
const providerName = guessEventLocationType(location)?.label;
|
||||
|
||||
return (
|
||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||
@@ -427,6 +440,17 @@ export default function Success(props: SuccessProps) {
|
||||
<div className="mt-3">
|
||||
<p className="text-gray-600 dark:text-gray-300">{getTitle()}</p>
|
||||
</div>
|
||||
{props.paymentStatus && (
|
||||
<h4>
|
||||
{!props.paymentStatus.success &&
|
||||
!props.paymentStatus.refunded &&
|
||||
t("booking_with_payment_cancelled")}
|
||||
{props.paymentStatus.success &&
|
||||
!props.paymentStatus.refunded &&
|
||||
t("booking_with_payment_cancelled_already_paid")}
|
||||
{props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")}
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-bookinglightest text-bookingdark dark:border-darkgray-200 mt-8 grid grid-cols-3 border-t pt-8 text-left dark:text-gray-300">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
@@ -490,8 +514,14 @@ export default function Success(props: SuccessProps) {
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3">
|
||||
{locationToDisplay.startsWith("http") ? (
|
||||
<a title="Meeting Link" href={locationToDisplay}>
|
||||
{locationToDisplay}
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
className="flex items-center gap-2 text-gray-700 underline dark:text-gray-50"
|
||||
rel="noreferrer">
|
||||
{providerName || "Link"}
|
||||
<FiExternalLink className="inline h-4 w-4 text-gray-700 dark:text-white" />
|
||||
</a>
|
||||
) : (
|
||||
locationToDisplay
|
||||
@@ -517,7 +547,7 @@ export default function Success(props: SuccessProps) {
|
||||
<>
|
||||
{eventTypeCustomFound?.type === "RADIO" && (
|
||||
<>
|
||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{eventTypeCustomFound.label}
|
||||
</div>
|
||||
<div className="col-span-3 mt-1 mb-2">
|
||||
@@ -541,7 +571,9 @@ export default function Success(props: SuccessProps) {
|
||||
)}
|
||||
{eventTypeCustomFound?.type !== "RADIO" && customInput !== "" && (
|
||||
<>
|
||||
<div className="col-span-3 mt-8 border-t pt-8 pr-3 font-medium">{key}</div>
|
||||
<div className="border-bookinglightest dark:border-darkgray-300 col-span-3 mt-8 border-t pt-8 pr-3 font-medium">
|
||||
{key}
|
||||
</div>
|
||||
<div className="col-span-3 mt-2 mb-2">
|
||||
{typeof customInput === "boolean" ? (
|
||||
<p>{customInput ? "true" : "false"}</p>
|
||||
@@ -632,8 +664,8 @@ export default function Success(props: SuccessProps) {
|
||||
.format("YYYYMMDDTHHmmss[Z]")}&text=${eventName}&details=${
|
||||
props.eventType.description
|
||||
}` +
|
||||
(typeof location === "string"
|
||||
? "&location=" + encodeURIComponent(location)
|
||||
(typeof locationVideoCallUrl === "string"
|
||||
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
||||
: "") +
|
||||
(props.eventType.recurringEvent
|
||||
? "&recur=" +
|
||||
@@ -661,7 +693,10 @@ export default function Success(props: SuccessProps) {
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
) +
|
||||
(locationVideoCallUrl
|
||||
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
||||
: "")
|
||||
}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
||||
target="_blank">
|
||||
@@ -685,7 +720,10 @@ export default function Success(props: SuccessProps) {
|
||||
date.utc().format() +
|
||||
"&subject=" +
|
||||
eventName
|
||||
) + (location ? "&location=" + location : "")
|
||||
) +
|
||||
(locationVideoCallUrl
|
||||
? "&location=" + encodeURIComponent(locationVideoCallUrl)
|
||||
: "")
|
||||
}
|
||||
className="mx-2 h-10 w-10 rounded-sm border border-gray-200 px-3 py-2 dark:border-gray-700 dark:text-white"
|
||||
target="_blank">
|
||||
@@ -984,7 +1022,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
endTime: true,
|
||||
location: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
cancellationReason: true,
|
||||
rejectionReason: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -1087,6 +1127,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
});
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: {
|
||||
bookingId: bookingInfo.id,
|
||||
},
|
||||
select: {
|
||||
success: true,
|
||||
refunded: true,
|
||||
},
|
||||
});
|
||||
const paymentStatus = {
|
||||
success: payment?.success,
|
||||
refunded: payment?.refunded,
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
|
||||
@@ -1096,6 +1150,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
trpcState: ssr.dehydrate(),
|
||||
dynamicEventName: bookingInfo?.eventType?.eventName || "",
|
||||
bookingInfo,
|
||||
paymentStatus: payment ? paymentStatus : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ export default function Bookings() {
|
||||
return false;
|
||||
}
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
} else if (status === "upcoming") {
|
||||
return new Date(booking.startTime).toDateString() !== new Date().toDateString();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -121,7 +123,7 @@ export default function Bookings() {
|
||||
<p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("today")}</p>
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="today-bookings">
|
||||
<Fragment>
|
||||
{bookingsToday.map((booking: BookingOutput) => (
|
||||
<BookingListItem
|
||||
@@ -138,7 +140,6 @@ export default function Bookings() {
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 xl:pt-0">
|
||||
{/* <p className="mb-2 text-xs font-medium uppercase leading-4 text-gray-500">{t("all")}</p> */}
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
||||
|
||||
@@ -62,7 +62,7 @@ export type FormValues = {
|
||||
phone?: string;
|
||||
}[];
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number;
|
||||
schedule: number | null;
|
||||
periodType: PeriodType;
|
||||
periodDays: number;
|
||||
periodCountCalendarDays: "1" | "0";
|
||||
|
||||
@@ -136,7 +136,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
)}
|
||||
</div>
|
||||
<EventTypeDescription
|
||||
// @ts-expect-error FIXME We have a type mismtach here @hariombalhara @sean-brydon
|
||||
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
|
||||
eventType={type}
|
||||
/>
|
||||
</Link>
|
||||
@@ -386,15 +386,13 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
data-testid={"event-type-options-" + type.id}
|
||||
className="radix-state-open:rounded-r-md">
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
StartIcon={FiMoreHorizontal}
|
||||
className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
@@ -457,37 +455,30 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Link href={calLink} target="_blank">
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview")}
|
||||
</Button>
|
||||
</Link>
|
||||
<DropdownItem
|
||||
href={calLink}
|
||||
target="_blank"
|
||||
StartIcon={FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none text-left"
|
||||
<DropdownItem
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={FiClipboard}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}>
|
||||
}}
|
||||
StartIcon={FiClipboard}
|
||||
className="w-full rounded-none text-left">
|
||||
{t("copy_link")}
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{isNativeShare ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
<DropdownItem
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={FiUpload}
|
||||
onClick={() => {
|
||||
navigator
|
||||
.share({
|
||||
@@ -497,44 +488,41 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
})
|
||||
.then(() => showToast(t("link_shared"), "success"))
|
||||
.catch(() => showToast(t("failed"), "error"));
|
||||
}}>
|
||||
}}
|
||||
StartIcon={FiUpload}
|
||||
className="w-full rounded-none">
|
||||
{t("share")}
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
<DropdownItem
|
||||
onClick={() => router.push("/event-types/" + type.id)}
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
StartIcon={FiEdit}>
|
||||
StartIcon={FiEdit}
|
||||
className="w-full rounded-none">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
<DropdownItem
|
||||
onClick={() => openDuplicateModal(type, group)}
|
||||
StartIcon={FiCopy}
|
||||
onClick={() => openDuplicateModal(type, group)}>
|
||||
data-testid={"event-type-duplicate-" + type.id}>
|
||||
{t("duplicate")}
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
<DropdownItem
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
color="destructive"
|
||||
StartIcon={FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
|
||||
@@ -105,7 +105,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||
</p>
|
||||
))}
|
||||
</header>
|
||||
<Steps maxSteps={steps.length} currentStep={currentStepIndex} navigateToStep={goToIndex} />
|
||||
<Steps maxSteps={steps.length} currentStep={currentStepIndex + 1} navigateToStep={goToIndex} />
|
||||
</div>
|
||||
<StepCard>
|
||||
{currentStep === "user-settings" && <UserSettings user={user} nextStep={() => goToIndex(1)} />}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Head from "next/head";
|
||||
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
export default function MaintenancePage() {
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
if (!isLocaleReady) return null;
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<Head>
|
||||
<title>
|
||||
{t("under_maintenance")} | {APP_NAME}
|
||||
</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<div className="m-auto rounded-md bg-white p-10 text-right ltr:text-left">
|
||||
<h1 className="text-2xl font-medium text-black">{t("under_maintenance")}</h1>
|
||||
<p className="mt-4 mb-6 max-w-2xl text-sm text-gray-600">
|
||||
{t("under_maintenance_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
<Button href={`${WEBSITE_URL}/support`}>{t("contact_support")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EventLocationType, getEventLocationTypeFromApp } from "@calcom/app-store/locations";
|
||||
import { AppSetDefaultLinkDailog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -21,12 +22,10 @@ import {
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
} from "@calcom/ui";
|
||||
import { FiAlertCircle, FiMoreHorizontal, FiTrash } from "@calcom/ui/components/icon";
|
||||
import { FiAlertCircle, FiMoreHorizontal, FiTrash, FiVideo } from "@calcom/ui/components/icon";
|
||||
|
||||
import AppListCard from "@components/AppListCard";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
@@ -43,12 +42,13 @@ const ConferencingLayout = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery(
|
||||
{ variant: "conferencing", onlyInstalled: true },
|
||||
{
|
||||
suspense: true,
|
||||
}
|
||||
);
|
||||
const { data: defaultConferencingApp, isLoading: defaultConferencingAppLoading } =
|
||||
trpc.viewer.getUsersDefaultConferencingApp.useQuery();
|
||||
|
||||
const { data: apps, isLoading } = trpc.viewer.integrations.useQuery({
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
});
|
||||
const deleteAppMutation = trpc.viewer.deleteCredential.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Integration deleted successfully", "success");
|
||||
@@ -61,10 +61,23 @@ const ConferencingLayout = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast("Default app updated successfully", "success");
|
||||
utils.viewer.getUsersDefaultConferencingApp.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`Error: ${error.message}`, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteAppModal, setDeleteAppModal] = useState(false);
|
||||
const [locationType, setLocationType] = useState<(EventLocationType & { slug: string }) | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [deleteCredentialId, setDeleteCredentialId] = useState<number>(0);
|
||||
|
||||
if (isLoading)
|
||||
if (isLoading || defaultConferencingAppLoading)
|
||||
return <SkeletonLoader title={t("conferencing")} description={t("conferencing_description")} />;
|
||||
|
||||
return (
|
||||
@@ -74,39 +87,67 @@ const ConferencingLayout = () => {
|
||||
{apps?.items &&
|
||||
apps.items
|
||||
.map((app) => ({ ...app, title: app.title || app.name }))
|
||||
.map((app) => (
|
||||
<AppListCard
|
||||
description={app.description}
|
||||
title={app.title}
|
||||
logo={app.logo}
|
||||
key={app.title}
|
||||
isDefault={app.isGlobal}
|
||||
actions={
|
||||
<div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
disabled={app.isGlobal}
|
||||
StartIcon={FiTrash}
|
||||
onClick={() => {
|
||||
setDeleteCredentialId(app.credentialIds[0]);
|
||||
setDeleteAppModal(true);
|
||||
}}>
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
.map((app) => {
|
||||
const appSlug = app?.slug;
|
||||
const appIsDefault =
|
||||
appSlug === defaultConferencingApp?.appSlug ||
|
||||
(appSlug === "daily-video" && !defaultConferencingApp?.appSlug); // Default to cal video if the user doesnt have it set (we do this on new account creation but not old)
|
||||
return (
|
||||
<AppListCard
|
||||
description={app.description}
|
||||
title={app.title}
|
||||
logo={app.logo}
|
||||
key={app.title}
|
||||
isDefault={appIsDefault} // @TODO: Handle when a user doesnt have this value set
|
||||
actions={
|
||||
<div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon={FiMoreHorizontal} variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={FiVideo}
|
||||
onClick={() => {
|
||||
const locationType = getEventLocationTypeFromApp(
|
||||
app?.locationOption?.value ?? ""
|
||||
);
|
||||
if (locationType?.linkType === "static") {
|
||||
setLocationType({ ...locationType, slug: appSlug });
|
||||
} else {
|
||||
updateDefaultAppMutation.mutate({
|
||||
appSlug,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{t("change_default_conferencing_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color="destructive"
|
||||
disabled={app.isGlobal}
|
||||
StartIcon={FiTrash}
|
||||
onClick={() => {
|
||||
setDeleteCredentialId(app.credentialIds[0]);
|
||||
setDeleteAppModal(true);
|
||||
}}>
|
||||
{t("remove_app")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Dialog open={deleteAppModal} onOpenChange={setDeleteAppModal}>
|
||||
@@ -123,20 +164,14 @@ const ConferencingLayout = () => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{locationType && (
|
||||
<AppSetDefaultLinkDailog locationType={locationType} setLocationType={setLocationType} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ConferencingLayout.getLayout = getLayout;
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssr = await ssrInit(context);
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: ssr.dehydrate(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default ConferencingLayout;
|
||||
|
||||
@@ -69,7 +69,9 @@ const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const mutation = trpc.viewer.updateProfile.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
// Invalidate our previous i18n cache
|
||||
await utils.viewer.public.i18n.invalidate();
|
||||
reset(getValues());
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useState } from "react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
@@ -9,7 +8,7 @@ import { identityProviderNameMap } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui";
|
||||
import { Alert, Button, Form, Meta, PasswordField, Select, SettingsToggle, showToast } from "@calcom/ui";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
@@ -17,9 +16,11 @@ type ChangePasswordSessionFormValues = {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
sessionTimeout?: number;
|
||||
apiError: string;
|
||||
};
|
||||
|
||||
const PasswordView = () => {
|
||||
const { data } = useSession();
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
@@ -58,9 +59,24 @@ const PasswordView = () => {
|
||||
showToast(t("password_has_been_changed"), "success");
|
||||
formMethods.resetField("oldPassword");
|
||||
formMethods.resetField("newPassword");
|
||||
|
||||
if (data?.user.role === "INACTIVE_ADMIN") {
|
||||
/*
|
||||
AdminPasswordBanner component relies on the role returned from the session.
|
||||
Next-Auth doesn't provide a way to revalidate the session cookie,
|
||||
so this a workaround to hide the banner after updating the password.
|
||||
discussion: https://github.com/nextauthjs/next-auth/discussions/4229
|
||||
*/
|
||||
signOut({ callbackUrl: "/auth/login" });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(`${t("error_updating_password")}, ${error.message}`, "error");
|
||||
showToast(`${t("error_updating_password")}, ${t(error.message)}`, "error");
|
||||
|
||||
formMethods.setError("apiError", {
|
||||
message: t(error.message),
|
||||
type: "custom",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -91,6 +107,8 @@ const PasswordView = () => {
|
||||
|
||||
const isDisabled = formMethods.formState.isSubmitting || !formMethods.formState.isDirty;
|
||||
|
||||
const passwordMinLength = data?.user.role === "USER" ? 7 : 15;
|
||||
const isUser = data?.user.role === "USER";
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} />
|
||||
@@ -111,19 +129,34 @@ const PasswordView = () => {
|
||||
</div>
|
||||
) : (
|
||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||
<div className="max-w-[38rem] sm:flex sm:space-x-4">
|
||||
<div className="flex-grow">
|
||||
{formMethods.formState.errors.apiError && (
|
||||
<div className="pb-6">
|
||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-[38rem] sm:grid sm:grid-cols-2 sm:gap-x-4">
|
||||
<div>
|
||||
<PasswordField {...formMethods.register("oldPassword")} label={t("old_password")} />
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<PasswordField {...formMethods.register("newPassword")} label={t("new_password")} />
|
||||
<div>
|
||||
<PasswordField
|
||||
{...formMethods.register("newPassword", {
|
||||
minLength: {
|
||||
message: t(isUser ? "password_hint_min" : "password_hint_admin_min"),
|
||||
value: passwordMinLength,
|
||||
},
|
||||
pattern: {
|
||||
message: "Should contain a number, uppercase and lowercase letters",
|
||||
value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).*$/gm,
|
||||
},
|
||||
})}
|
||||
label={t("new_password")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 max-w-[38rem] text-sm text-gray-600">
|
||||
<Trans i18nKey="invalid_password_hint">
|
||||
Password must be at least at least 7 characters, mix of uppercase & lowercase letters, and
|
||||
contain at least 1 number.
|
||||
</Trans>
|
||||
{t("invalid_password_hint", { passwordLength: passwordMinLength })}
|
||||
</p>
|
||||
<div className="mt-8 border-t border-gray-200 py-8">
|
||||
<SettingsToggle
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function Signup({ prepopulateFormValues }: inferSSRProps<typeof g
|
||||
})
|
||||
.then(handleErrors)
|
||||
.then(async () => {
|
||||
telemetry.event(telemetryEventTypes.login, collectPageParameters());
|
||||
telemetry.event(telemetryEventTypes.signup, collectPageParameters());
|
||||
await signIn<"credentials">("credentials", {
|
||||
...data,
|
||||
callbackUrl: router.query.callbackUrl
|
||||
|
||||
@@ -100,6 +100,22 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
members: {
|
||||
where: {
|
||||
role: "OWNER",
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
weekStart: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -148,6 +164,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
booking = await getBooking(prisma, rescheduleUid);
|
||||
}
|
||||
|
||||
const weekStart = eventType.team?.members?.[0]?.user?.weekStart;
|
||||
|
||||
return {
|
||||
props: {
|
||||
profile: {
|
||||
@@ -155,7 +173,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
slug: team.slug,
|
||||
image: team.logo,
|
||||
theme: team.theme,
|
||||
weekStart: "Sunday",
|
||||
weekStart: weekStart ?? "Sunday",
|
||||
brandColor: team.brandColor,
|
||||
darkBrandColor: team.darkBrandColor,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,11 @@ export const createPaymentsFixture = (page: Page) => {
|
||||
currency: "usd",
|
||||
success,
|
||||
refunded,
|
||||
type: "STRIPE",
|
||||
app: {
|
||||
connect: {
|
||||
slug: "stripe",
|
||||
},
|
||||
},
|
||||
data: {},
|
||||
externalId: "DEMO_PAYMENT_FROM_DB_" + Date.now(),
|
||||
booking: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page, WorkerInfo } from "@playwright/test";
|
||||
import type Prisma from "@prisma/client";
|
||||
import { Prisma as PrismaType, MembershipRole } from "@prisma/client";
|
||||
import { MembershipRole, Prisma as PrismaType } from "@prisma/client";
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -34,11 +34,14 @@ const seededForm = {
|
||||
|
||||
type UserWithIncludes = PrismaType.UserGetPayload<typeof userWithEventTypes>;
|
||||
|
||||
const createTeamAndAddUser = async ({ user }: { user: { id: number; role?: MembershipRole } }) => {
|
||||
const createTeamAndAddUser = async (
|
||||
{ user }: { user: { id: number; role?: MembershipRole } },
|
||||
workerInfo: WorkerInfo
|
||||
) => {
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: "",
|
||||
slug: `team-${Date.now()}`,
|
||||
slug: `team-${workerInfo.workerIndex}-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
if (!team) {
|
||||
@@ -216,7 +219,7 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
||||
include: userIncludes,
|
||||
});
|
||||
if (scenario.hasTeam) {
|
||||
await createTeamAndAddUser({ user: { id: user.id, role: "OWNER" } });
|
||||
await createTeamAndAddUser({ user: { id: user.id, role: "OWNER" } }, workerInfo);
|
||||
}
|
||||
const userFixture = createUserFixture(user, store.page!);
|
||||
store.users.push(userFixture);
|
||||
|
||||
@@ -24,7 +24,7 @@ test.describe("Onboarding", () => {
|
||||
await page.locator("input[name=username]").fill("new user onboarding");
|
||||
await page.locator("input[name=name]").fill("new user 2");
|
||||
await page.locator("input[role=combobox]").click();
|
||||
await page.locator("text=Eastern Time").click();
|
||||
await page.locator("text=Europe/London GMT +0:00").click();
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
|
||||
@@ -113,7 +113,22 @@ test.describe("Reschedule Tests", async () => {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: false,
|
||||
});
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
metadata: {
|
||||
apps: {
|
||||
stripe: {
|
||||
price: 20000,
|
||||
enabled: true,
|
||||
currency: "usd",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const payment = await payments.create(booking.id);
|
||||
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
|
||||
|
||||
|
||||
@@ -38,22 +38,15 @@ test.describe("Wipe my Cal App Test", () => {
|
||||
await expect(page.locator("data-testid=wipe-today-button")).toBeVisible();
|
||||
|
||||
const $openBookingCount = await page.locator('[data-testid="bookings"] > *').count();
|
||||
expect($openBookingCount).toBe(3);
|
||||
const $todayBookingCount = await page.locator('[data-testid="today-bookings"] > *').count();
|
||||
expect($openBookingCount + $todayBookingCount).toBe(3);
|
||||
|
||||
await page.locator("data-testid=wipe-today-button").click();
|
||||
|
||||
// Don't await send_request click, otherwise mutation can possibly occur before observer is attached
|
||||
page.locator("data-testid=send_request").click();
|
||||
const $openBookings = page.locator('[data-testid="bookings"]');
|
||||
await $openBookings.evaluate((ul) => {
|
||||
return new Promise<void>((resolve) =>
|
||||
new window.MutationObserver(() => {
|
||||
if (ul.childElementCount === 2) {
|
||||
resolve();
|
||||
}
|
||||
}).observe(ul, { childList: true })
|
||||
);
|
||||
});
|
||||
// There will not be any today-bookings
|
||||
await expect(page.locator('[data-testid="today-bookings"]')).toBeHidden();
|
||||
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
|
||||
/* Iframe Name */
|
||||
window.name.includes("cal-embed");
|
||||
|
||||
window.isEmbed = () => {
|
||||
// Once an embed mode always an embed mode
|
||||
return calEmbedMode;
|
||||
};
|
||||
|
||||
window.resetEmbedStatus = () => {
|
||||
try {
|
||||
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||
window.sessionStorage.removeItem("calEmbedMode");
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
window.getEmbedTheme = () => {
|
||||
const url = new URL(document.URL);
|
||||
return url.searchParams.get("theme");
|
||||
};
|
||||
|
||||
window.getEmbedNamespace = () => {
|
||||
const url = new URL(document.URL);
|
||||
const namespace = url.searchParams.get("embed");
|
||||
return namespace;
|
||||
};
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} هو بديل Calendly مفتوح المصدر الذي يتيح لك التحكم في بياناتك وسير عملك والمظهر على التطبيق أو موقع الويب.",
|
||||
"have_any_questions": "هل لديك أسئلة؟ نحن هنا للمساعدة.",
|
||||
"reset_password_subject": "{{appName}}: إرشادات إعادة تعيين كلمة المرور",
|
||||
"event_declined_subject": "تم الرفض: {{eventType}} مع {{name}} في {{date}}",
|
||||
"event_cancelled_subject": "تم الإلغاء: {{eventType}} مع {{name}} في {{date}}",
|
||||
"event_declined_subject": "تم الرفض: {{title}} في {{date}}",
|
||||
"event_cancelled_subject": "تم الإلغاء: {{title}} في {{date}}",
|
||||
"event_request_declined": "تم رفض طلب الحدث الخاص بك",
|
||||
"event_request_declined_recurring": "تم رفض طلب الحدث المتكرر الخاص بك",
|
||||
"event_request_cancelled": "تم إلغاء حدثك المُجَدْوَل",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "فشل استرداد مقابل الحدث {{eventType}} مع {{userName}} في {{date}}.",
|
||||
"check_with_provider_and_user": "يُرجى التحقق من مزود الدفع لديك و{{user}} بشأن كيفية التعامل مع الأمر.",
|
||||
"a_refund_failed": "فشل الاسترداد",
|
||||
"awaiting_payment_subject": "في انتظار الدفع: {{eventType}} مع {{name}} في {{date}}",
|
||||
"awaiting_payment_subject": "في انتظار الدفع: {{title}} في {{date}}",
|
||||
"meeting_awaiting_payment": "اجتماعك في انتظار الدفع",
|
||||
"help": "المساعدة",
|
||||
"price": "السعر",
|
||||
@@ -63,11 +63,11 @@
|
||||
"someone_requested_an_event": "طلب شخص ما جدولة حدث في تقويمك.",
|
||||
"someone_requested_password_reset": "طلب شخص ما رابطا لتغيير كلمة المرور الخاصة بك.",
|
||||
"password_reset_instructions": "إذا لم تطلب هذا، يمكنك تجاهل هذا البريد الإلكتروني بأمان ولن يتم تغيير كلمة المرور الخاصة بك.",
|
||||
"event_awaiting_approval_subject": "في انتظار الموافقة: {{eventType}} مع {{name}} في {{date}}",
|
||||
"event_awaiting_approval_subject": "في انتظار الموافقة: {{title}} في {{date}}",
|
||||
"event_still_awaiting_approval": "الحدث لا يزال في انتظار موافقتك",
|
||||
"booking_submitted_subject": "تم إرسال الحجز: {{eventType}} مع {{name}} في {{date}}",
|
||||
"booking_submitted_subject": "تم إرسال الحجز: {{title}} في {{date}}",
|
||||
"your_meeting_has_been_booked": "لقد تم حجز الاجتماع الخاص بك",
|
||||
"event_type_has_been_rescheduled_on_time_date": "تم إعادة جدولة {{eventType}} مع {{name}} إلى {{date}}.",
|
||||
"event_type_has_been_rescheduled_on_time_date": "تم إعادة جدولة {{title}} إلى {{date}}.",
|
||||
"event_has_been_rescheduled": "تم التحديث - تم إعادة جدولة الحدث الخاص بك",
|
||||
"request_reschedule_title_attendee": "طلب إعادة جدولة الحجز الخاص بك",
|
||||
"request_reschedule_subtitle": "ألغى {{organizer}} الحجز وطلب منك اختيار حجز في وقت آخر.",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "إذا فقدت هذا السر أو نسيته، يمكنك تغييره، ولكن ضع في اعتبارك أن جميع التكاملات التي تستخدم هذا السر ستحتاج إلى تحديث",
|
||||
"current_incorrect_password": "كلمة المرور الحالية غير صحيحة",
|
||||
"password_hint_caplow": "مزيج من الحروف الكبيرة والحروف الصغيرة",
|
||||
"password_hint_min": "8 أحرف كحد أدنى",
|
||||
"password_hint_min": "7 أحرف كحد أدنى",
|
||||
"password_hint_admin_min": "15 محرفًا كحد أدنى",
|
||||
"password_hint_num": "يحتوي على رقم واحد على الأقل",
|
||||
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من 7 أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
|
||||
"invalid_password_hint": "يجب أن تتألف كلمة المرور على الأقل من {{passwordLength}} أحرف، ورقم واحد على الأقل، ومزيج من الحروف الكبيرة والصغيرة",
|
||||
"incorrect_password": "كلمة المرور غير صحيحة.",
|
||||
"24_h": "24 ساعة",
|
||||
"use_setting": "استخدام الإعداد",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} je open source alternativa, která vám dává kontrolu nad vlastními daty, workflow a vzhledem.",
|
||||
"have_any_questions": "Máte otázky? Jsme tu, abychom vám pomohli.",
|
||||
"reset_password_subject": "{{appName}}: Pokyny pro obnovení hesla",
|
||||
"event_declined_subject": "Odmítnuto: {{eventType}} s {{name}} v {{date}}",
|
||||
"event_cancelled_subject": "Zrušeno: {{eventType}} s {{name}} v {{date}}",
|
||||
"event_declined_subject": "Odmítnuto: {{title}} v {{date}}",
|
||||
"event_cancelled_subject": "Zrušeno: {{title}} v {{date}}",
|
||||
"event_request_declined": "Žádost o událost byla zamítnuta",
|
||||
"event_request_declined_recurring": "Žádost o opakovanou událost byla zamítnuta",
|
||||
"event_request_cancelled": "Vaše naplánovaná událost byla zrušena",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Vrácení peněz za událost {{eventType}} pro {{userName}} během {{date}} selhalo.",
|
||||
"check_with_provider_and_user": "Pro řešení prosím zkontaktujte vašeho správce plateb a/nebo uživatele {{user}}.",
|
||||
"a_refund_failed": "Vrácení platby selhalo",
|
||||
"awaiting_payment_subject": "Čekám na platbu: {{eventType}} s {{name}} během {{date}}",
|
||||
"awaiting_payment_subject": "Čekám na platbu: {{title}} během {{date}}",
|
||||
"meeting_awaiting_payment": "Vaše schůzka čeká na platbu",
|
||||
"help": "Nápověda",
|
||||
"price": "Cena",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Někdo požádal o naplánování události ve vašem kalendáři.",
|
||||
"someone_requested_password_reset": "Někdo požádal o odkaz pro změnu vašeho hesla.",
|
||||
"password_reset_instructions": "Pokud jste o obnovení hesla nepožádali, můžete tento e-mail ignorovat. Vaše heslo se nezmění.",
|
||||
"event_awaiting_approval_subject": "Čeká se na schválení: {{eventType}} s {{name}} dne {{date}}",
|
||||
"event_awaiting_approval_subject": "Čeká se na schválení: {{title}} dne {{date}}",
|
||||
"event_still_awaiting_approval": "Událost stále čeká na schválení",
|
||||
"booking_submitted_subject": "Rezervace odeslána: {{eventType}} s {{name}} během {{date}}",
|
||||
"booking_submitted_subject": "Rezervace odeslána: {{title}} během {{date}}",
|
||||
"your_meeting_has_been_booked": "Vaše schůzka byla rezervována",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Váš {{title}} byl přeplánován na {{date}}.",
|
||||
"event_has_been_rescheduled": "Změna - Vaše událost byla přesunuta na jindy",
|
||||
@@ -423,7 +423,7 @@
|
||||
"password_hint_min": "Minimální délka 7 znaků",
|
||||
"password_hint_admin_min": "Minimální délka 15 znaků",
|
||||
"password_hint_num": "Obsahuje alespoň 1 číslo",
|
||||
"invalid_password_hint": "Heslo musí být minimálně 7 znaků dlouhé, obsahovat alespoň jedno číslo a obsahovat kombinaci velkých a malých písmen",
|
||||
"invalid_password_hint": "Heslo musí být minimálně {{passwordLength}} znaků dlouhé, obsahovat alespoň jedno číslo a obsahovat kombinaci velkých a malých písmen",
|
||||
"incorrect_password": "Heslo není správné.",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Použít nastavení",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"accept_invitation": "Einladung annehmen",
|
||||
"have_any_questions": "Haben Sie Fragen? Wir sind hier um Ihnen zu helfen.",
|
||||
"reset_password_subject": "{{appName}}: Anleitung zum Zurücksetzen des Passworts",
|
||||
"event_declined_subject": "Abgelehnt: {{eventType}} mit {{name}} am {{date}}",
|
||||
"event_declined_subject": "Abgelehnt: {{title}} am {{date}}",
|
||||
"event_cancelled_subject": "Storniert: {{title}} um {{date}}",
|
||||
"event_request_declined": "Ihre Event-Anfrage wurde abgelehnt",
|
||||
"event_request_declined_recurring": "Ihre wiederkehrende Terminanfrage wurde abgelehnt",
|
||||
@@ -31,7 +31,7 @@
|
||||
"refund_failed": "Die Rückerstattung für den Termin {{eventType}} mit {{userName}} am {{date}} ist fehlgeschlagen.",
|
||||
"check_with_provider_and_user": "Bitte überprüfen Sie bei Ihrem Zahlungsanbieter und {{userName}}, wie Sie damit umgehen.",
|
||||
"a_refund_failed": "Erstattung fehlgeschlagen",
|
||||
"awaiting_payment_subject": "Warte auf Zahlung: {{eventType}} mit {{name}} am {{date}}",
|
||||
"awaiting_payment_subject": "Warte auf Zahlung: {{title}} am {{date}}",
|
||||
"meeting_awaiting_payment": "Zahlung für den Termin noch offen",
|
||||
"help": "Hilfe",
|
||||
"price": "Preis",
|
||||
@@ -62,9 +62,9 @@
|
||||
"someone_requested_an_event": "Jemand hat eine Buchungsanfrage gestellt.",
|
||||
"someone_requested_password_reset": "Jemand hat einen Link angefordert, um Ihr Passwort zu ändern.",
|
||||
"password_reset_instructions": "Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail sicher ignorieren und Ihr Passwort wird nicht geändert.",
|
||||
"event_awaiting_approval_subject": "Warte auf Bestätigung: {{eventType}} mit {{name}} um {{date}}",
|
||||
"event_awaiting_approval_subject": "Warte auf Bestätigung: {{title}} um {{date}}",
|
||||
"event_still_awaiting_approval": "Ein Termin wartet noch auf Ihre Bestätigung",
|
||||
"booking_submitted_subject": "Buchung eingereicht: {{eventType}} mit {{name}} am {{date}}",
|
||||
"booking_submitted_subject": "Buchung eingereicht: {{title}} am {{date}}",
|
||||
"your_meeting_has_been_booked": "Dein Termin wurde gebucht",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Ihr {{title}} wurde auf den {{date}} verschoben.",
|
||||
"event_has_been_rescheduled": "Ihr Termin wurde verschoben.",
|
||||
@@ -419,10 +419,10 @@
|
||||
"forgotten_secret_description": "Wenn Sie dieses Passwort verloren oder vergessen haben, können Sie es ändern. Bitte beachten Sie, dass alle Integrationen, die dieses Geheimnis nutzen, aktualisiert werden müssen",
|
||||
"current_incorrect_password": "Aktuelles Passwort ist falsch",
|
||||
"password_hint_caplow": "Mischung aus Groß- und Kleinbuchstaben",
|
||||
"password_hint_min": "Mindestens 8 Zeichen lang",
|
||||
"password_hint_min": "Mindestens 7 Zeichen lang",
|
||||
"password_hint_admin_min": "Mindestens 15 Zeichen lang",
|
||||
"password_hint_num": "Enthält mindestens 1 Zahl",
|
||||
"invalid_password_hint": "Das Passwort muss mindestens 7 Zeichen lang sein und mindestens eine Nummer beinhalten, sowie eine Mischung aus Groß- und Kleinbuchstaben sein",
|
||||
"invalid_password_hint": "Das Passwort muss mindestens {{passwordLength}} Zeichen lang sein und mindestens eine Nummer beinhalten, sowie eine Mischung aus Groß- und Kleinbuchstaben sein",
|
||||
"incorrect_password": "Passwort ist falsch",
|
||||
"24_h": "24 Std",
|
||||
"use_setting": "Benutze Einstellung",
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"accept_invitation": "Αποδοχή Πρόσκλησης",
|
||||
"have_any_questions": "Έχετε ερωτήσεις? Είμαστε εδώ για να βοηθήσουμε.",
|
||||
"reset_password_subject": "{{appName}}: Οδηγίες επαναφοράς κωδικού πρόσβασης",
|
||||
"event_declined_subject": "Απορρίφθηκε: {{eventType}} με {{name}} στις {{date}}",
|
||||
"event_cancelled_subject": "Ακύρωση: {{eventType}} με {{name}} στις {{date}}",
|
||||
"event_declined_subject": "Απορρίφθηκε: {{title}} στις {{date}}",
|
||||
"event_cancelled_subject": "Ακύρωση: {{title}} στις {{date}}",
|
||||
"need_to_reschedule_or_cancel": "Χρειάζεται να επαναπρογραμματίσετε ή να ακυρώσετε;",
|
||||
"cancellation_reason": "Λόγος ακύρωσης (προαιρετικό)",
|
||||
"rejection_reason": "Λόγος απόρριψης",
|
||||
@@ -18,7 +18,7 @@
|
||||
"refund_failed_subject": "Η επιστροφή χρημάτων απέτυχε: {{name}} - {{date}} - {{eventType}}",
|
||||
"refund_failed": "Η επιστροφή χρημάτων για την εκδήλωση {{eventType}} με {{userName}} στις {{date}} απέτυχε.",
|
||||
"a_refund_failed": "Αποτυχία επιστροφής χρημάτων",
|
||||
"awaiting_payment_subject": "Σε αναμονή Πληρωμής: {{eventType}} με {{name}} στις {{date}}",
|
||||
"awaiting_payment_subject": "Σε αναμονή Πληρωμής: {{title}} στις {{date}}",
|
||||
"meeting_awaiting_payment": "Η πληρωμή της συνάντησής σας εκκρεμεί",
|
||||
"help": "Βοήθεια",
|
||||
"price": "Τιμή",
|
||||
|
||||
@@ -428,10 +428,10 @@
|
||||
"forgotten_secret_description": "If you have lost or forgotten this secret, you can change it, but be aware that all integrations using this secret will need to be updated",
|
||||
"current_incorrect_password": "Current password is incorrect",
|
||||
"password_hint_caplow": "Mix of uppercase & lowercase letters",
|
||||
"password_hint_min": "Minimum 8 characters long",
|
||||
"password_hint_min": "Minimum 7 characters long",
|
||||
"password_hint_admin_min": "Minimum 15 characters long",
|
||||
"password_hint_num": "Contain at least 1 number",
|
||||
"invalid_password_hint": "The password must be a minimum of 7 characters long containing at least one number and have a mixture of uppercase and lowercase letters",
|
||||
"invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters",
|
||||
"incorrect_password": "Password is incorrect.",
|
||||
"incorrect_username_password": "Username or password is incorrect.",
|
||||
"24_h": "24h",
|
||||
@@ -515,6 +515,8 @@
|
||||
"admin": "Admin",
|
||||
"administrator_user": "Administrator user",
|
||||
"lets_create_first_administrator_user": "Let's create the first administrator user.",
|
||||
"admin_user_created": "Administrator user setup",
|
||||
"admin_user_created_description": "You have already created an administrator user. You can now log in to your account.",
|
||||
"new_member": "New Member",
|
||||
"invite": "Invite",
|
||||
"add_team_members": "Add team members",
|
||||
@@ -796,7 +798,7 @@
|
||||
"number_apps_one": "{{count}} App",
|
||||
"number_apps_other": "{{count}} Apps",
|
||||
"trending_apps": "Trending Apps",
|
||||
"most_popular":"Most Popular",
|
||||
"most_popular": "Most Popular",
|
||||
"explore_apps": "{{category}} apps",
|
||||
"installed_apps": "Installed Apps",
|
||||
"free_to_use_apps": "Free",
|
||||
@@ -841,7 +843,7 @@
|
||||
"connect_metamask": "Connect Metamask",
|
||||
"create_events_on": "Create events on",
|
||||
"enterprise_license": "This is an enterprise feature",
|
||||
"enterprise_license_description": "To enable this feature, get a deployment key at {{consoleUrl}} console and add it to your .env as CALCOM_LICENSE_KEY. If your team already has a license, please contact {{supportMail}} for help.",
|
||||
"enterprise_license_description": "To enable this feature, have an administrator go to {{setupUrl}} to enter a license key. If a license key is already in place, please contact {{supportMail}} for help.",
|
||||
"missing_license": "Missing License",
|
||||
"signup_requires": "Commercial license required",
|
||||
"signup_requires_description": "{{companyName}} currently does not offer a free open source version of the sign up page. To receive full access to the signup components you need to acquire a commercial license. For personal use we recommend the Prisma Data Platform or any other Postgres interface to create accounts.",
|
||||
@@ -1114,8 +1116,8 @@
|
||||
"close": "Close",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
|
||||
"upgrade_to_access_recordings_description":"Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
|
||||
"recordings_are_part_of_the_teams_plan":"Recordings are part of the teams plan",
|
||||
"upgrade_to_access_recordings_description": "Recordings are only available as part of our teams plan. Upgrade to start recording your calls",
|
||||
"recordings_are_part_of_the_teams_plan": "Recordings are part of the teams plan",
|
||||
"team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.",
|
||||
"team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.",
|
||||
"show_eventtype_on_profile": "Show on Profile",
|
||||
@@ -1414,7 +1416,7 @@
|
||||
"team_url_required": "Must enter a team URL",
|
||||
"team_url_taken": "This URL is already taken",
|
||||
"team_publish": "Publish team",
|
||||
"number_sms_notifications": "Phone number (SMS\u00a0notifications)",
|
||||
"number_sms_notifications": "Phone number (SMS notifications)",
|
||||
"attendee_email_variable": "Attendee email",
|
||||
"attendee_email_info": "The person booking's email",
|
||||
"kbar_search_placeholder": "Type a command or search...",
|
||||
@@ -1451,6 +1453,9 @@
|
||||
"disabled_calendar": "If you have another calendar installed new bookings will be added to it. If not then connect a new calendar so you do not miss any new bookings.",
|
||||
"enable_apps": "Enable Apps",
|
||||
"enable_apps_description": "Enable apps that users can integrate with Cal.com",
|
||||
"purchase_license": "Purchase a License",
|
||||
"already_have_key": "I already have a key:",
|
||||
"already_have_key_suggestion": "Please copy your existing CALCOM_LICENSE_KEY environment variable here.",
|
||||
"app_is_enabled": "{{appName}} is enabled",
|
||||
"app_is_disabled": "{{appName}} is disabled",
|
||||
"keys_have_been_saved": "Keys have been saved",
|
||||
@@ -1496,11 +1501,11 @@
|
||||
"not_verified": "Not yet verified",
|
||||
"no_availability_in_month": "No availability in {{month}}",
|
||||
"view_next_month": "View next month",
|
||||
"send_code" : "Send code",
|
||||
"send_code": "Send code",
|
||||
"number_verified": "Number Verified",
|
||||
"create_your_first_team_webhook_description": "Create your first webhook for this team event type",
|
||||
"create_webhook_team_event_type": "Create a webhook for this team event type",
|
||||
"disable_success_page":"Disable Success Page (only works if you have a redirect URL)",
|
||||
"disable_success_page": "Disable Success Page (only works if you have a redirect URL)",
|
||||
"invalid_admin_password": "You are set as an admin but you do not have a password length of at least 15 characters",
|
||||
"change_password_admin": "Change Password to gain admin access",
|
||||
"username_already_taken": "Username is already taken",
|
||||
@@ -1526,6 +1531,25 @@
|
||||
"reporting_feature": "See all incoming from data and download it as a CSV",
|
||||
"teams_plan_required": "Teams plan required",
|
||||
"routing_forms_are_a_great_way": "Routing forms are a great way to route your incoming leads to the right person. Upgrade to a Teams plan to access this feature.",
|
||||
"choose_a_license": "Choose a license",
|
||||
"choose_license_description": "Cal.com comes with an accessible and free AGPLv3 license which some limitations which can be upgraded to an Enterprise license at any time. You can upgrade at anytime later.",
|
||||
"license": "License",
|
||||
"agplv3_license": "AGPLv3 License",
|
||||
"ee_enterprise_license": "“/ee” Enterprise License",
|
||||
"enterprise_booking_fee": "Starting at {{enterprise_booking_fee}}/month",
|
||||
"enterprise_license_includes": "Everything for a commercial use case",
|
||||
"no_need_to_keep_your_code_open_source": "No need to keep your code open-source",
|
||||
"repackage_rebrand_resell": "Repackage, rebrand and resell easily",
|
||||
"a_vast_suite_of_enterprise_features": "A vast suite of enterprise features",
|
||||
"free_license_fee": "$0.00/month",
|
||||
"forever_open_and_free": "Forever Open & Free",
|
||||
"required_to_keep_your_code_open_source": "Required to keep your code open source",
|
||||
"cannot_repackage_and_resell": "Cannot repackage, rebrand and resell easily",
|
||||
"no_enterprise_features": "No enterprise features",
|
||||
"step_enterprise_license": "Enterprise License",
|
||||
"step_enterprise_license_description": "Everything for a commercial use case with private hosting, repackaging, rebranding and reselling and access exclusive enterprise components.",
|
||||
"setup": "Setup",
|
||||
"setup_description": "Setup Cal.com instance",
|
||||
"configure": "Configure",
|
||||
"sso_configuration": "Single Sign-On",
|
||||
"sso_configuration_description": "Configure SAML/OIDC SSO and allow team members to login using an Identity Provider",
|
||||
@@ -1564,5 +1588,15 @@
|
||||
"meetings_are_better_with_the_right": "Meetings are better with the right team members there. Invite them now.",
|
||||
"create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.",
|
||||
"collective_or_roundrobin": "Collective or round-robin",
|
||||
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin."
|
||||
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.",
|
||||
"default_app_link_title":"Set a default app link",
|
||||
"default_app_link_description":"Setting a default app link allows all newly created event types to use the app link you set.",
|
||||
"change_default_conferencing_app":"Set as default",
|
||||
"under_maintenance": "Down for maintenance",
|
||||
"under_maintenance_description": "The {{appName}} team are performing scheduled maintenance. If you have any questions, please contact support.",
|
||||
"event_type_seats": "{{numberOfSeats}} seats",
|
||||
"booking_with_payment_cancelled": "Paying for this event is no longer possible",
|
||||
"booking_with_payment_cancelled_already_paid": "A refund for this booking payment it's on the way.",
|
||||
"booking_with_payment_cancelled_refunded": "This booking payment has been refunded.",
|
||||
"booking_confirmation_failed": "Booking confirmation failed"
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} es la alternativa de código abierto de Calendly poniéndole en control de sus propios datos, flujo de trabajo y apariencia.",
|
||||
"have_any_questions": "¿Tienes preguntas? Estamos aquí para ayudar.",
|
||||
"reset_password_subject": "{{appName}}: Instrucciones para restablecer la contraseña",
|
||||
"event_declined_subject": "Rechazado: {{eventType}} con {{name}} en {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{eventType}} con {{name}} en {{date}}",
|
||||
"event_declined_subject": "Rechazado: {{title}} en {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} en {{date}}",
|
||||
"event_request_declined": "Su solicitud de reunión ha sido rechazada",
|
||||
"event_request_declined_recurring": "Su solicitud de evento recurrente se ha rechazado",
|
||||
"event_request_cancelled": "Tu evento programado fue cancelado",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "El reembolso del evento {{eventType}} con {{userName}} en {{date}} falló.",
|
||||
"check_with_provider_and_user": "Por favor, compruebe con su proveedor de pago y {{userName}} cómo manejar esto.",
|
||||
"a_refund_failed": "Un reembolso falló",
|
||||
"awaiting_payment_subject": "Esperando pago: {{eventType}} con {{name}} en {{date}}",
|
||||
"awaiting_payment_subject": "Esperando pago: {{title}} en {{date}}",
|
||||
"meeting_awaiting_payment": "Su reunión está a la espera de pago",
|
||||
"help": "Ayuda",
|
||||
"price": "Precio",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Alguien ha solicitado programar un evento en su calendario.",
|
||||
"someone_requested_password_reset": "Alguien ha solicitado un enlace para cambiar su contraseña.",
|
||||
"password_reset_instructions": "Si usted no solicitó esto, puede ignorar este correo electrónico de forma segura y su contraseña no se cambiará.",
|
||||
"event_awaiting_approval_subject": "Esperando aprobación: {{eventType}} con {{name}} en {{date}}",
|
||||
"event_awaiting_approval_subject": "Esperando aprobación: {{title}} en {{date}}",
|
||||
"event_still_awaiting_approval": "Un evento aún está esperando su aprobación",
|
||||
"booking_submitted_subject": "Reserva enviada: {{eventType}} con {{name}} el {{date}}",
|
||||
"booking_submitted_subject": "Reserva enviada:{{title}} el {{date}}",
|
||||
"your_meeting_has_been_booked": "Su reunión ha sido reservada",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Tu {{title}} ha sido reprogramado para {{date}}.",
|
||||
"event_has_been_rescheduled": "Tu evento ha sido reprogramado.",
|
||||
@@ -423,7 +423,7 @@
|
||||
"password_hint_min": "Mínimo 7 caracteres",
|
||||
"password_hint_admin_min": "Longitud mínima de 15 caracteres",
|
||||
"password_hint_num": "Debe contener al menos 1 número",
|
||||
"invalid_password_hint": "La contraseña debe tener un mínimo de 7 caracteres que contengan al menos un número y una mezcla de letras mayúsculas y minúsculas",
|
||||
"invalid_password_hint": "La contraseña debe tener un mínimo de {{passwordLength}} caracteres que contengan al menos un número y una mezcla de letras mayúsculas y minúsculas",
|
||||
"incorrect_password": "La Contraseña es Incorrecta.",
|
||||
"24_h": "24hs",
|
||||
"use_setting": "Usar Ajuste",
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Quelqu'un a demandé à planifier un événement sur votre calendrier.",
|
||||
"someone_requested_password_reset": "Quelqu'un a demandé un lien pour modifier votre mot de passe.",
|
||||
"password_reset_instructions": "Si vous n'en avez pas fait la demande, vous pouvez ignorer cet e-mail en toute sécurité et votre mot de passe ne sera pas modifié.",
|
||||
"event_awaiting_approval_subject": "En attente de validation : {{eventType}} à {{date}}",
|
||||
"event_awaiting_approval_subject": "En attente de validation : {{title}} à {{date}}",
|
||||
"event_still_awaiting_approval": "Un événement est toujours en attente de votre validation",
|
||||
"booking_submitted_subject": "Réservation confirmée : {{eventType}} avec {{name}} le {{date}}",
|
||||
"booking_submitted_subject": "Réservation confirmée : {{title}} le {{date}}",
|
||||
"your_meeting_has_been_booked": "Votre rendez-vous a été réservé",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Votre {{title}} a été replanifié à {{date}}.",
|
||||
"event_has_been_rescheduled": "Mise à jour - Votre événement a été replanifié",
|
||||
@@ -423,10 +423,10 @@
|
||||
"forgotten_secret_description": "Vous pouvez modifier ce secret si vous l'avez perdu ou oublié. Soyez cependant conscient que toutes les intégrations l'utilisant devront être mises à jour.",
|
||||
"current_incorrect_password": "Le mot de passe actuel est incorrect",
|
||||
"password_hint_caplow": "Mélange de lettres majuscules et minuscules",
|
||||
"password_hint_min": "Minimum 8 caractères",
|
||||
"password_hint_min": "Minimum 7 caractères",
|
||||
"password_hint_admin_min": "Minimum 15 caractères",
|
||||
"password_hint_num": "Contient au moins 1 chiffre",
|
||||
"invalid_password_hint": "Le mot de passe doit contenir au moins 7 caractères, un chiffre et un mélange de lettres, majuscules et minuscules.",
|
||||
"invalid_password_hint": "Le mot de passe doit contenir au moins {{passwordLength}} caractères, un chiffre et un mélange de lettres, majuscules et minuscules.",
|
||||
"incorrect_password": "Le mot de passe est incorrect.",
|
||||
"24_h": "24 h",
|
||||
"use_setting": "Utiliser le paramètre",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} הוא חלופת הקוד הפתוח של Calendly המאפשרת לך לשלוט בנתונים, בתהליך העבודה ובמראה של האתר שלך.",
|
||||
"have_any_questions": "יש לך שאלות? אנחנו כאן כדי לעזור.",
|
||||
"reset_password_subject": "{{appName}}: הנחיות לאיפוס סיסמה",
|
||||
"event_declined_subject": "נדחה: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"event_cancelled_subject": "בוטל: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"event_declined_subject": "נדחה: {{title}} בתאריך {{date}}",
|
||||
"event_cancelled_subject": "בוטל: {{title}} בתאריך {{date}}",
|
||||
"event_request_declined": "בקשת האירוע שלך נדחתה",
|
||||
"event_request_declined_recurring": "בקשת האירוע החוזר שלך נדחתה",
|
||||
"event_request_cancelled": "האירוע המתוכנן בוטל",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "ההחזר הכספי עבור האירוע {{eventType}} עם {{userName}} בתאריך {{date}} נכשל.",
|
||||
"check_with_provider_and_user": "יש לברר עם ספק התשלום ועם {{user}} כיצד לטפל בעניין.",
|
||||
"a_refund_failed": "ההחזר הכספי נכשל",
|
||||
"awaiting_payment_subject": "בהמתנה לתשלום: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"awaiting_payment_subject": "בהמתנה לתשלום: {{title}} בתאריך {{date}}",
|
||||
"meeting_awaiting_payment": "התשלום על הפגישה שלך טרם בוצע",
|
||||
"help": "עזרה",
|
||||
"price": "מחיר",
|
||||
@@ -63,11 +63,11 @@
|
||||
"someone_requested_an_event": "מישהו ביקש לקבוע מועד לאירוע בלוח השנה שלך.",
|
||||
"someone_requested_password_reset": "מישהו ביקש קישור כדי לשנות את הסיסמה שלך.",
|
||||
"password_reset_instructions": "אם הבקשה לא היתה שלך, ניתן להתעלם מהודעת הדוא\"ל הזו בבטחה, והסיסמה שלך לא תשתנה.",
|
||||
"event_awaiting_approval_subject": "בהמתנה לאישור: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"event_awaiting_approval_subject": "בהמתנה לאישור: {{title}} בתאריך {{date}}",
|
||||
"event_still_awaiting_approval": "אירוע עדיין ממתין לאישורך",
|
||||
"booking_submitted_subject": "ההזמנה נשלחה: {{eventType}} עם {{name}} בתאריך {{date}}",
|
||||
"booking_submitted_subject": "ההזמנה נשלחה: {{title}} בתאריך {{date}}",
|
||||
"your_meeting_has_been_booked": "הפגישה שלך הוזמנה",
|
||||
"event_type_has_been_rescheduled_on_time_date": "ה{{eventType}} שלך עם {{name}} הועבר לתאריך {{date}}.",
|
||||
"event_type_has_been_rescheduled_on_time_date": "{{title}} הועבר לתאריך {{date}}.",
|
||||
"event_has_been_rescheduled": "עודכן – מועד האירוע שלך השתנה",
|
||||
"request_reschedule_title_attendee": "בקשה לשינוי מועד ההזמנה",
|
||||
"request_reschedule_subtitle": "{{organizer}} ביטל/ה את ההזמנה וביקש/ה שתבחר/י מועד אחר.",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "אם שכחת או איבדת את הסוד, יש לך אפשרות לשנות אותו, אבל חשוב לזכור שיהיה צורך לעדכן את כל השילובים שבהם נעשה שימוש בסוד זה",
|
||||
"current_incorrect_password": "הסיסמה הנוכחית שגויה",
|
||||
"password_hint_caplow": "שילוב של אותיות רישיות וקטנות",
|
||||
"password_hint_min": "באורך 8 תווים לפחות",
|
||||
"password_hint_min": "באורך 7 תווים לפחות",
|
||||
"password_hint_admin_min": "באורך של 15 תווים לפחות",
|
||||
"password_hint_num": "לכלול לפחות ספרה אחת",
|
||||
"invalid_password_hint": "הסיסמה חייבת להיות באורך של 7 תווים לפחות ולכלול לפחות ספרה אחת ושילוב של אותיות רישיות וקטנות",
|
||||
"invalid_password_hint": "הסיסמה חייבת להיות באורך של {{passwordLength}} תווים לפחות ולכלול לפחות ספרה אחת ושילוב של אותיות רישיות וקטנות",
|
||||
"incorrect_password": "הסיסמה שגויה.",
|
||||
"24_h": "24 שעות",
|
||||
"use_setting": "השתמש בהגדרה",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} è l'alternativa Calendly open source che ti consente di controllare i tuoi dati, il flusso di lavoro e l'aspetto.",
|
||||
"have_any_questions": "Hai domande? Siamo qui per aiutare.",
|
||||
"reset_password_subject": "{{appName}}: istruzioni per reimpostare la password",
|
||||
"event_declined_subject": "Rifiutato: {{eventType}} con {{name}} il {{date}}",
|
||||
"event_cancelled_subject": "Cancellato: {{eventType}} con {{name}} il {{date}}",
|
||||
"event_declined_subject": "Rifiutato: {{title}} il {{date}}",
|
||||
"event_cancelled_subject": "Cancellato: {{title}} il {{date}}",
|
||||
"event_request_declined": "La tua richiesta per l'evento è stata rifiutata",
|
||||
"event_request_declined_recurring": "La tua richiesta per l'evento ricorrente è stata rifiutata",
|
||||
"event_request_cancelled": "Il tuo evento programmato è stato cancellato",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Il rimborso per l'evento {{eventType}} di {{userName}} del {{date}} non è riuscito.",
|
||||
"check_with_provider_and_user": "Verifica con il tuo fornitore di servizi di pagamento e {{user}} come gestirlo.",
|
||||
"a_refund_failed": "Un rimborso non è riuscito",
|
||||
"awaiting_payment_subject": "In attesa del pagamento: {{eventType}} con {{name}} il {{date}}",
|
||||
"awaiting_payment_subject": "In attesa del pagamento: {{title}} il {{date}}",
|
||||
"meeting_awaiting_payment": "La tua riunione è in attesa di pagamento",
|
||||
"help": "Guida",
|
||||
"price": "Prezzo",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Qualcuno ha richiesto di programmare un evento sul tuo calendario.",
|
||||
"someone_requested_password_reset": "Qualcuno ha richiesto un link per cambiare la tua password.",
|
||||
"password_reset_instructions": "Se non l'hai richiesto, puoi tranquillamente ignorare questa email e la tua password non verrà modificata.",
|
||||
"event_awaiting_approval_subject": "In attesa di approvazione: {{eventType}} con {{name}} il {{date}}",
|
||||
"event_awaiting_approval_subject": "In attesa di approvazione: {{title}} il {{date}}",
|
||||
"event_still_awaiting_approval": "Un evento è ancora in attesa della tua approvazione",
|
||||
"booking_submitted_subject": "Prenotazione inviata: {{eventType}} con {{name}} il {{date}}",
|
||||
"booking_submitted_subject": "Prenotazione inviata: {{title}} il {{date}}",
|
||||
"your_meeting_has_been_booked": "Il tuo meeting è stata prenotato",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Il tuo {{title}} è stato riprogrammato per il {{date}}.",
|
||||
"event_has_been_rescheduled": "Aggiornato: il tuo evento è stato riprogrammato",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "Nel caso la parola segreta venga persa o dimenticata, è possibile cambiarla, ma tieni presente che tutte le integrazioni che la utilizzano dovranno essere aggiornate",
|
||||
"current_incorrect_password": "La password attuale non è corretta",
|
||||
"password_hint_caplow": "Combinazione di caratteri maiuscoli e minuscoli",
|
||||
"password_hint_min": "La lunghezza minima è di 8 caratteri",
|
||||
"password_hint_min": "La lunghezza minima è di 7 caratteri",
|
||||
"password_hint_admin_min": "La lunghezza minima è di 15 caratteri",
|
||||
"password_hint_num": "Deve contenere almeno 1 numero",
|
||||
"invalid_password_hint": "La password deve avere una lunghezza minima di 7 caratteri e deve contenere almeno 1 numero e una combinazione di caratteri maiuscoli e minuscoli",
|
||||
"invalid_password_hint": "La password deve avere una lunghezza minima di {{passwordLength}} caratteri e deve contenere almeno 1 numero e una combinazione di caratteri maiuscoli e minuscoli",
|
||||
"incorrect_password": "La password non è corretta.",
|
||||
"24_h": "24 ore",
|
||||
"use_setting": "Usa impostazione",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} は、Calendly の代替となるオープンソースのツールです。データ、ワークフロー、外観などを自由にコントロールできます。",
|
||||
"have_any_questions": "ご不明な点があれば、お気軽にお問い合わせください。",
|
||||
"reset_password_subject": "{{appName}}: パスワードのリセット手順",
|
||||
"event_declined_subject": "拒否されました: {{date}} の {{name}} との {{eventType}}",
|
||||
"event_cancelled_subject": "キャンセルされました: {{date}} の {{name}} との {{eventType}}",
|
||||
"event_declined_subject": "拒否されました: {{date}} の {{title}}",
|
||||
"event_cancelled_subject": "キャンセルされました: {{date}} の {{title}}",
|
||||
"event_request_declined": "イベントのリクエストが拒否されました",
|
||||
"event_request_declined_recurring": "繰り返しイベントのリクエストが拒否されました",
|
||||
"event_request_cancelled": "スケジュールされていた予定がキャンセルされました",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "{{userName}} の {{date}} における {{eventType}} の返金に失敗しました。",
|
||||
"check_with_provider_and_user": "これを処理する方法については、支払い業者と {{user}} にご確認ください。",
|
||||
"a_refund_failed": "返金に失敗しました",
|
||||
"awaiting_payment_subject": "お支払いを待機しています: {{date}} の {{name}} との {{eventType}}",
|
||||
"awaiting_payment_subject": "お支払いを待機しています: {{date}} の {{title}}",
|
||||
"meeting_awaiting_payment": "会議の支払いを待っています",
|
||||
"help": "ヘルプ",
|
||||
"price": "料金",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "別のユーザーがあなたのカレンダーにイベントを予定するリクエストしています。",
|
||||
"someone_requested_password_reset": "別のユーザーがあなたのパスワードを変更するためのリンクをリクエストしています。",
|
||||
"password_reset_instructions": "このリクエストをしていない場合は、このメールを無視しても大丈夫です。パスワードも変更されません。",
|
||||
"event_awaiting_approval_subject": "承認を待っています: {{date}} の {{name}} との {{eventType}}",
|
||||
"event_awaiting_approval_subject": "承認を待っています: {{date}} の {{title}}",
|
||||
"event_still_awaiting_approval": "イベントはまだあなたの承認待ちです",
|
||||
"booking_submitted_subject": "予約を送信しました: {{date}} の {{name}} との {{eventType}}",
|
||||
"booking_submitted_subject": "予約を送信しました: {{date}} の {{title}}",
|
||||
"your_meeting_has_been_booked": "ミーティングが予約されました",
|
||||
"event_type_has_been_rescheduled_on_time_date": "あなたの {{title}} は {{date}} に再スケジュールされました。",
|
||||
"event_has_been_rescheduled": "更新 - イベントのスケジュールが変更されました",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "このシークレットを紛失または忘れた場合は変更が可能です。しかしこのシークレットを使っている連携はすべて最新のものにする必要があります",
|
||||
"current_incorrect_password": "現在のパスワードが正しくありません",
|
||||
"password_hint_caplow": "アルファベットの大文字と小文字を両方使用してください",
|
||||
"password_hint_min": "8 文字以上入力してください",
|
||||
"password_hint_min": "7 文字以上入力してください",
|
||||
"password_hint_admin_min": "15 文字以上入力してください",
|
||||
"password_hint_num": "少なくとも 1 文字は数字を含めてください",
|
||||
"invalid_password_hint": "パスワードには少なくとも 1 文字以上数字を含め、アルファベットの大文字と小文字を両方使用した上で 7 文字以上の長さに設定してください",
|
||||
"invalid_password_hint": "パスワードには少なくとも 1 文字以上数字を含め、アルファベットの大文字と小文字を両方使用した上で {{passwordLength}} 文字以上の長さに設定してください",
|
||||
"incorrect_password": "パスワードが正しくありません。",
|
||||
"24_h": "24時間",
|
||||
"use_setting": "設定を使用",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}}은 오픈 소스 Calendly 대안으로 자신의 데이터, 워크플로 및 형태를 컨트롤할 수 있습니다.",
|
||||
"have_any_questions": "질문이 있나요? 도와드릴게요.",
|
||||
"reset_password_subject": "{{appName}}: 비밀번호 재설정 방법",
|
||||
"event_declined_subject": "거절됨: {{date}} {{name}} 의 {{eventType}}",
|
||||
"event_cancelled_subject": "취소됨: {{date}} {{name}} 의 {{eventType}}",
|
||||
"event_declined_subject": "거절됨: {{date}} {{title}}",
|
||||
"event_cancelled_subject": "취소됨: {{date}} {{title}}",
|
||||
"event_request_declined": "회의 요청이 거절되었습니다.",
|
||||
"event_request_declined_recurring": "되풀이 이벤트 요청이 거부되었습니다",
|
||||
"event_request_cancelled": "회의 일정이 취소되었습니다.",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "{{date}} {{userName}} 의 {{eventType}} 이벤트에 대한 환불이 이루어지지 않았습니다.",
|
||||
"check_with_provider_and_user": "이 문제의 처리 방법은 결제 제공업체 및 {{user}}에서 확인하십시오.",
|
||||
"a_refund_failed": "환불이 이루어지지 않았습니다.",
|
||||
"awaiting_payment_subject": "결제를 기다리고 있습니다: {{date}} {{name}} 의 {{eventType}}",
|
||||
"awaiting_payment_subject": "결제를 기다리고 있습니다: {{date}} {{title}}",
|
||||
"meeting_awaiting_payment": "회의를 위해 결제해 주십시오.",
|
||||
"help": "Help",
|
||||
"price": "가격",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "누군가 귀하의 캘린더에 일정을 예약하도록 요청했습니다.",
|
||||
"someone_requested_password_reset": "누군가 귀하의 비밀번호 변경을 위한 링크를 요청했습니다.",
|
||||
"password_reset_instructions": "요청하지 않으셨다면 이 이메일을 무시하십시오. 비밀번호는 변경되지 않습니다.",
|
||||
"event_awaiting_approval_subject": "승인을 기다리고 있습니다: {{date}} {{name}} 의 {{eventType}}",
|
||||
"event_awaiting_approval_subject": "승인을 기다리고 있습니다: {{date}} {{title}}",
|
||||
"event_still_awaiting_approval": "이벤트가 아직 승인을 기다리고 있습니다.",
|
||||
"booking_submitted_subject": "예약 제출됨: {{date}}에 {{name}}에 의한 {{eventType}}",
|
||||
"booking_submitted_subject": "예약 제출됨: {{date}}에 {{title}}",
|
||||
"your_meeting_has_been_booked": "회의가 예약되었습니다.",
|
||||
"event_type_has_been_rescheduled_on_time_date": "귀하의 {{title}} 일정이 {{date}}로 변경되었습니다.",
|
||||
"event_has_been_rescheduled": "업데이트 - 이벤트 일정이 변경되었습니다.",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"calcom_explained": "{{appName}} is het open source Calendly-alternatief waarmee u uw eigen gegevens, werkstroom en uiterlijk in de hand houdt.",
|
||||
"have_any_questions": "Heeft u vragen? We zijn er om te helpen.",
|
||||
"reset_password_subject": "{{appName}}: Instructies voor het opnieuw instellen van uw wachtwoord",
|
||||
"event_declined_subject": "Afgewezen: {{title}} op {{date}}",
|
||||
"event_declined_subject": "Afgewezen: {{title}}",
|
||||
"event_cancelled_subject": "Geannuleerd: {{title}} op {{date}}",
|
||||
"event_request_declined": "Uw gebeurtenisverzoek is geweigerd",
|
||||
"event_request_declined_recurring": "Uw verzoek voor een terugkerende gebeurtenis is geweigerd",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "Als u dit geheim vergeten of verloren bent, kunt u het wijzigen. Maar wees u ervan bewust dat alle integraties die dit geheim gebruiken bijgewerkt moeten worden",
|
||||
"current_incorrect_password": "Uw wachtwoord is verkeerd",
|
||||
"password_hint_caplow": "Mix van hoofd- en kleine letters",
|
||||
"password_hint_min": "Minimaal 8 tekens lang",
|
||||
"password_hint_min": "Minimaal 7 tekens lang",
|
||||
"password_hint_admin_min": "Minimaal 15 tekens lang",
|
||||
"password_hint_num": "Minimaal 1 cijfer bevatten",
|
||||
"invalid_password_hint": "Het wachtwoord moet minimaal 7 tekens lang zijn, minimaal 1 cijfer bevatten en bestaan uit een mix van hoofd- en kleine letters",
|
||||
"invalid_password_hint": "Het wachtwoord moet minimaal {{passwordLength}} tekens lang zijn, minimaal 1 cijfer bevatten en bestaan uit een mix van hoofd- en kleine letters",
|
||||
"incorrect_password": "Uw wachtwoord is incorrect.",
|
||||
"24_h": "24u",
|
||||
"use_setting": "Instelling gebruiken",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} er en åpen kildekodeversjon av Calendly som gir deg kontroll over dine egne data, arbeidsflyt og utseende.",
|
||||
"have_any_questions": "Har du spørsmål? Vi er her for å hjelpe.",
|
||||
"reset_password_subject": "{{appName}}: Instruksjoner for tilbakestilling av passord",
|
||||
"event_declined_subject": "Avslått: {{eventType}} med {{name}} kl. {{date}}",
|
||||
"event_cancelled_subject": "Avbrutt: {{eventType}} med {{name}} kl. {{date}}",
|
||||
"event_declined_subject": "Avslått: {{title}} kl. {{date}}",
|
||||
"event_cancelled_subject": "Avbrutt: {{title}} kl. {{date}}",
|
||||
"event_request_declined": "Din hendelsesforespørsel har blitt avvist",
|
||||
"event_request_declined_recurring": "Din gjentagende hendelsesforespørsel har blitt avvist",
|
||||
"event_request_cancelled": "Din planlagte hendelse ble kansellert",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Tilbakebetalingen for hendelsen {{eventType}} med {{userName}} på {{date}} mislyktes.",
|
||||
"check_with_provider_and_user": "Sjekk med betalingsleverandøren din og {{user}} hvordan du håndterer dette.",
|
||||
"a_refund_failed": "En tilbakebetaling mislyktes",
|
||||
"awaiting_payment_subject": "Venter på betaling: {{eventType}} med {{name}} {{date}}",
|
||||
"awaiting_payment_subject": "Venter på betaling: {{title}} {{date}}",
|
||||
"meeting_awaiting_payment": "Møtet ditt venter på betaling",
|
||||
"help": "Hjelp",
|
||||
"price": "Pris",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Noen har bedt om å planlegge en hendelse i kalenderen din.",
|
||||
"someone_requested_password_reset": "Noen har bedt om en lenke for å endre passordet ditt.",
|
||||
"password_reset_instructions": "Hvis du ikke har bedt om dette, kan du trygt ignorere denne e-posten og passordet ditt vil ikke bli endret.",
|
||||
"event_awaiting_approval_subject": "Venter på godkjenning: {{eventType}} med {{name}} kl. {{date}}",
|
||||
"event_awaiting_approval_subject": "Venter på godkjenning: {{title}} kl. {{date}}",
|
||||
"event_still_awaiting_approval": "En hendelse venter fortsatt på din godkjenning",
|
||||
"booking_submitted_subject": "Booking sendt inn: {{eventType}} med {{name}} kl. {{date}}",
|
||||
"booking_submitted_subject": "Booking sendt inn: {{title}} kl. {{date}}",
|
||||
"your_meeting_has_been_booked": "Møtet ditt er booket",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Din {{title}} har blitt flyttet til {{date}}.",
|
||||
"event_has_been_rescheduled": "Oppdatert – Hendelsen din har blitt endret",
|
||||
@@ -423,10 +423,10 @@
|
||||
"forgotten_secret_description": "Hvis du har mistet eller glemt denne hemmeligheten, kan du endre den, men vær oppmerksom på at alle integrasjoner som bruker denne hemmeligheten må oppdateres",
|
||||
"current_incorrect_password": "Nåværende passord er feil",
|
||||
"password_hint_caplow": "Blanding av store og små bokstaver",
|
||||
"password_hint_min": "Minimum 8 tegn langt",
|
||||
"password_hint_min": "Minimum 7 tegn langt",
|
||||
"password_hint_admin_min": "Minimum 15 tegn langt",
|
||||
"password_hint_num": "Inneholde minst 1 tall",
|
||||
"invalid_password_hint": "Passordet må være minst 7 tegn langt og inneholde minst ett tall og ha en blanding av store og små bokstaver",
|
||||
"invalid_password_hint": "Passordet må være minst {{passwordLength}} tegn langt og inneholde minst ett tall og ha en blanding av store og små bokstaver",
|
||||
"incorrect_password": "Passordet er feil.",
|
||||
"24_h": "24t",
|
||||
"use_setting": "Bruk innstilling",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} to alternatywa open source Calendly, która daje Ci kontrolę nad własnymi danymi, przepływem pracy i wyglądem.",
|
||||
"have_any_questions": "Masz pytania? Jesteśmy tutaj, aby pomóc.",
|
||||
"reset_password_subject": "{{appName}}: Instrukcje resetowania hasła",
|
||||
"event_declined_subject": "Odrzucono: {{eventType}} z {{name}} w dniu {{date}}",
|
||||
"event_cancelled_subject": "Anulowano: {{eventType}} z {{name}} w {{date}}",
|
||||
"event_declined_subject": "Odrzucono: {{title}} w dniu {{date}}",
|
||||
"event_cancelled_subject": "Anulowano: {{title}} w {{date}}",
|
||||
"event_request_declined": "Twoja prośba o wydarzenie została odrzucona",
|
||||
"event_request_declined_recurring": "Twoja prośba o wydarzenie cykliczne została odrzucona",
|
||||
"event_request_cancelled": "Twoje zaplanowane wydarzenie zostało anulowane",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Zwrot środków za {{eventType}} z {{userName}} w {{date}} nie powiódł się.",
|
||||
"check_with_provider_and_user": "Proszę, sprawdź z dostawcą płatności i {{user}} jak to zrobić.",
|
||||
"a_refund_failed": "Zwrot środków nie powiódł się",
|
||||
"awaiting_payment_subject": "Oczekiwanie na płatność: {{eventType}} z {{name}} w {{date}}",
|
||||
"awaiting_payment_subject": "Oczekiwanie na płatność: {{title}} w {{date}}",
|
||||
"meeting_awaiting_payment": "Twoje spotkanie oczekuje na płatność",
|
||||
"help": "Pomoc",
|
||||
"price": "Cena",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Ktoś poprosił o zaplanowanie wydarzenia w Twoim kalendarzu.",
|
||||
"someone_requested_password_reset": "Ktoś poprosił o link do zmiany Twojego hasła.",
|
||||
"password_reset_instructions": "Jeśli nie poprosiłeś o to, możesz bezpiecznie zignorować ten e-mail, a Twoje hasło nie zostanie zmienione.",
|
||||
"event_awaiting_approval_subject": "Oczekiwanie na zatwierdzenie: {{eventType}} z {{name}} w {{date}}",
|
||||
"event_awaiting_approval_subject": "Oczekiwanie na zatwierdzenie: {{title}} w {{date}}",
|
||||
"event_still_awaiting_approval": "Wydarzenie nadal oczekuje na Twoje zatwierdzenie",
|
||||
"booking_submitted_subject": "Rezerwacja potwierdzona: {{eventType}} z {{name}} w {{date}}",
|
||||
"booking_submitted_subject": "Rezerwacja potwierdzona: {{title}} w {{date}}",
|
||||
"your_meeting_has_been_booked": "Twoje spotkanie zostało zarezerwowane",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Twój {{title}} został przełożony na {{date}}.",
|
||||
"event_has_been_rescheduled": "Zaktualizowano - Twoje wydarzenie zostało przełożone",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "Jeśli nie pamiętasz tego wpisu tajnego lub nie masz do niego dostępu, możesz go zmienić, ale pamiętaj, że wszystkie integracje korzystające z tego wpisu tajnego będą musiały zostać zaktualizowane.",
|
||||
"current_incorrect_password": "Bieżące hasło jest nieprawidłowe",
|
||||
"password_hint_caplow": "Kombinacja wielkich i małych liter",
|
||||
"password_hint_min": "Długość co najmniej 8 znaków",
|
||||
"password_hint_min": "Długość co najmniej 7 znaków",
|
||||
"password_hint_admin_min": "Długość co najmniej 15 znaków",
|
||||
"password_hint_num": "Zawrzyj co najmniej 1 cyfrę",
|
||||
"invalid_password_hint": "Hasło musi składać się z co najmniej 7 znaków, zawierać co najmniej jedną cyfrę oraz kombinację wielkich i małych liter",
|
||||
"invalid_password_hint": "Hasło musi składać się z co najmniej {{passwordLength}} znaków, zawierać co najmniej jedną cyfrę oraz kombinację wielkich i małych liter",
|
||||
"incorrect_password": "Hasło jest nieprawidłowe.",
|
||||
"24_h": "24 godz.",
|
||||
"use_setting": "Użyj ustawień",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} é a alternativa de código aberto ao Calendly que coloca você no controle dos seus dados, fluxo de trabalho e aparência.",
|
||||
"have_any_questions": "Precisa de ajuda? Estamos aqui para ajudar.",
|
||||
"reset_password_subject": "{{appName}}: Instruções para redefinir sua senha",
|
||||
"event_declined_subject": "Recusado: {{eventType}} com {{name}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{eventType}} com {{name}} em {{date}}",
|
||||
"event_declined_subject": "Recusado: {{title}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
|
||||
"event_request_declined": "Sua solicitação de reunião foi recusada",
|
||||
"event_request_declined_recurring": "Sua solicitação de evento recorrente foi recusada",
|
||||
"event_request_cancelled": "Seu evento agendado foi cancelado",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "O reembolso do evento {{eventType}} com {{userName}} em {{date}} falhou.",
|
||||
"check_with_provider_and_user": "Por favor, tente resolver junto do seu fornecedor de pagamentos e com o {{user}}.",
|
||||
"a_refund_failed": "Um reembolso falhou",
|
||||
"awaiting_payment_subject": "Aguardando pagamento: {{eventType}} com {{name}} em {{date}}",
|
||||
"awaiting_payment_subject": "Aguardando pagamento: {{title}} em {{date}}",
|
||||
"meeting_awaiting_payment": "Sua reunião está aguardando o pagamento",
|
||||
"help": "Ajuda",
|
||||
"price": "Preço",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Alguém solicitou o agendamento de um evento em sua agenda.",
|
||||
"someone_requested_password_reset": "Alguém solicitou um link para alterar sua senha.",
|
||||
"password_reset_instructions": "Se você não fez essa solicitação, você pode ignorar este e-mail e sua senha não será alterada.",
|
||||
"event_awaiting_approval_subject": "Aguardando aprovação: {{eventType}} com {{name}} em {{date}}",
|
||||
"event_awaiting_approval_subject": "Aguardando aprovação: {{title}} em {{date}}",
|
||||
"event_still_awaiting_approval": "Um evento está aguardando a sua aprovação",
|
||||
"booking_submitted_subject": "Reserva enviada: {{eventType}} com {{name}} em {{date}}",
|
||||
"booking_submitted_subject": "Reserva enviada: {{title}} em {{date}}",
|
||||
"your_meeting_has_been_booked": "A sua reunião foi agendada",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Seu {{title}} foi remarcado para {{date}}.",
|
||||
"event_has_been_rescheduled": "Atualização - O seu evento foi reagendado",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} é a alternativa de código aberto de Calendly que lhe dá o controlo dos seus próprios dados, do seu fluxo de trabalho e da apresentação.",
|
||||
"have_any_questions": "Tem perguntas? Estamos disponíveis para ajudar.",
|
||||
"reset_password_subject": "{{appName}}: Instruções de redefinição da senha",
|
||||
"event_declined_subject": "Declinado: {{eventType}} com {{name}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{eventType}} com {{name}} em {{date}}",
|
||||
"event_declined_subject": "Declinado: {{title}} em {{date}}",
|
||||
"event_cancelled_subject": "Cancelado: {{title}} em {{date}}",
|
||||
"event_request_declined": "O seu pedido de evento foi declinado",
|
||||
"event_request_declined_recurring": "O seu pedido de evento recorrente foi recusado",
|
||||
"event_request_cancelled": "O seu evento agendado foi cancelado",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Falhou o reembolso do evento {{eventType}} com {{userName}} em {{date}}.",
|
||||
"check_with_provider_and_user": "Por favor, tente resolver junto do seu fornecedor de pagamentos e com {{userName}}.",
|
||||
"a_refund_failed": "Falhou ao reembolsar",
|
||||
"awaiting_payment_subject": "A aguardar pagamento: {{eventType}} com {{name}} em {{date}}",
|
||||
"awaiting_payment_subject": "A aguardar pagamento: {{title}} em {{date}}",
|
||||
"meeting_awaiting_payment": "A sua reunião aguarda pagamento",
|
||||
"help": "Ajuda",
|
||||
"price": "Preço",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Alguém pediu para agendar um evento no seu calendário.",
|
||||
"someone_requested_password_reset": "Alguém pediu uma ligação para alterar a sua senha.",
|
||||
"password_reset_instructions": "Se não fez este pedido, pode ignorar este email com segurança e a sua senha não será alterada.",
|
||||
"event_awaiting_approval_subject": "A aguardar aprovação: {{eventType}} com {{name}} em {{date}}",
|
||||
"event_awaiting_approval_subject": "A aguardar aprovação: {{title}} em {{date}}",
|
||||
"event_still_awaiting_approval": "Um evento ainda aguarda a sua aprovação",
|
||||
"booking_submitted_subject": "Reserva submetida: {{eventType}} com {{name}} em {{date}}",
|
||||
"booking_submitted_subject": "Reserva submetida: {{title}} em {{date}}",
|
||||
"your_meeting_has_been_booked": "A sua reunião foi reservada",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Seu {{title}} foi remarcado para {{date}}.",
|
||||
"event_has_been_rescheduled": "O seu evento foi reagendado.",
|
||||
@@ -423,7 +423,7 @@
|
||||
"password_hint_min": "Tamanho mínimo de 7 caracteres",
|
||||
"password_hint_admin_min": "Tamanho mínimo de 15 caracteres",
|
||||
"password_hint_num": "Contém pelo menos 1 número",
|
||||
"invalid_password_hint": "A palavra-passe deve ter no mínimo 7 caracteres, com pelo menos um número e uma mistura de letras maiúsculas e minúsculas",
|
||||
"invalid_password_hint": "A palavra-passe deve ter no mínimo {{passwordLength}} caracteres, com pelo menos um número e uma mistura de letras maiúsculas e minúsculas",
|
||||
"incorrect_password": "Palavra-Passe incorreta",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Usar Configuração",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} este alternativa open-source la Calendly, care vă oferă control asupra propriilor date, asupra fluxului de lucru și asupra aspectului site-ului.",
|
||||
"have_any_questions": "Aveți întrebări? Suntem aici pentru a vă ajuta.",
|
||||
"reset_password_subject": "{{appName}}: instrucțiuni de resetare a parolei",
|
||||
"event_declined_subject": "Refuzat: {{eventType}} cu {{name}} în {{date}}",
|
||||
"event_cancelled_subject": "Anulat: {{eventType}} cu {{name}} în {{date}}",
|
||||
"event_declined_subject": "Refuzat: {{title}} în {{date}}",
|
||||
"event_cancelled_subject": "Anulat: {{title}} în {{date}}",
|
||||
"event_request_declined": "Solicitarea pentru eveniment a fost refuzată",
|
||||
"event_request_declined_recurring": "Solicitarea dvs. pentru evenimentul recurent a fost refuzată",
|
||||
"event_request_cancelled": "Evenimentul programat a fost anulat",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Rambursarea pentru eveniment {{eventType}} cu {{userName}} în {{date}} a eșuat.",
|
||||
"check_with_provider_and_user": "Vă rugăm să verificați cu furnizorul dvs. de plăți și cu {{userName}} cum să gestionați această activitate.",
|
||||
"a_refund_failed": "Rambursare eșuată",
|
||||
"awaiting_payment_subject": "Se așteaptă plata: {{eventType}} cu {{name}} în {{date}}",
|
||||
"awaiting_payment_subject": "Se așteaptă plata: {{title}} în {{date}}",
|
||||
"meeting_awaiting_payment": "Întâlnirea dvs. așteaptă plata",
|
||||
"help": "Ajutor",
|
||||
"price": "Prețul",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Cineva a solicitat să programeze un eveniment în calendar.",
|
||||
"someone_requested_password_reset": "Cineva a solicitat un link pentru a vă schimba parola.",
|
||||
"password_reset_instructions": "Dacă nu ați solicitat acest lucru, puteți ignora acest e-mail, iar parola nu va fi schimbată.",
|
||||
"event_awaiting_approval_subject": "Se așteaptă aprobarea: {{eventType}} cu {{name}} în {{date}}",
|
||||
"event_awaiting_approval_subject": "Se așteaptă aprobarea: {{title}} în {{date}}",
|
||||
"event_still_awaiting_approval": "Un eveniment încă așteaptă aprobarea dvs.",
|
||||
"booking_submitted_subject": "Rezervare transmisă: {{eventType}} cu {{name}} în {{date}}",
|
||||
"booking_submitted_subject": "Rezervare transmisă: {{title}} în {{date}}",
|
||||
"your_meeting_has_been_booked": "Întâlnirea dvs. a fost programată",
|
||||
"event_type_has_been_rescheduled_on_time_date": "{{title}} dvs. a fost reprogramat la {{date}}.",
|
||||
"event_has_been_rescheduled": "Evenimentul dvs. a fost reprogramat.",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "Dacă ați pierdut sau ați uitat acest secret, îl puteți modifica, dar rețineți că toate integrările cu acest secret vor trebui actualizate",
|
||||
"current_incorrect_password": "Parola curentă este incorectă",
|
||||
"password_hint_caplow": "Combinație de litere mari și litere mici",
|
||||
"password_hint_min": "Cel puțin 8 caractere",
|
||||
"password_hint_min": "Cel puțin 7 caractere",
|
||||
"password_hint_admin_min": "Cel puțin 15 caractere",
|
||||
"password_hint_num": "Conține cel puțin 1 cifră",
|
||||
"invalid_password_hint": "Parola trebuie să aibă cel puțin 7 caractere, să conțină cel puțin o cifră și o combinație de litere mari și litere mici",
|
||||
"invalid_password_hint": "Parola trebuie să aibă cel puțin {{passwordLength}} caractere, să conțină cel puțin o cifră și o combinație de litere mari și litere mici",
|
||||
"incorrect_password": "Parola este incorectă.",
|
||||
"24_h": "24h",
|
||||
"use_setting": "Folosește setarea",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} - это альтернатива Calendly с открытым исходным кодом, позволяющая контролировать собственные данные, рабочий процесс и внешний вид.",
|
||||
"have_any_questions": "Есть вопросы? Мы здесь, чтобы помочь.",
|
||||
"reset_password_subject": "{{appName}}: Инструкция по сбросу пароля",
|
||||
"event_declined_subject": "Отклонено: {{eventType}} с {{name}} на {{date}}",
|
||||
"event_cancelled_subject": "Отменено: {{eventType}} с {{name}} на {{date}}",
|
||||
"event_declined_subject": "Отклонено: {{title}} на {{date}}",
|
||||
"event_cancelled_subject": "Отменено:{{title}} на {{date}}",
|
||||
"event_request_declined": "Ваш запрос на встречу отклонен",
|
||||
"event_request_declined_recurring": "Ваш запрос на повторяющееся событие отклонен",
|
||||
"event_request_cancelled": "Ваша запланированная встреча была отменена",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Возврат за встречу {{eventType}} с {{userName}} на {{date}} не удался.",
|
||||
"check_with_provider_and_user": "Пожалуйста, проверьте в вашей платежной системе и {{userName}} как это сделать.",
|
||||
"a_refund_failed": "Возврат не удался",
|
||||
"awaiting_payment_subject": "Ожидание оплаты: {{eventType}} с {{name}} на {{date}}",
|
||||
"awaiting_payment_subject": "Ожидание оплаты: {{title}} на {{date}}",
|
||||
"meeting_awaiting_payment": "Ваша встреча ожидает оплаты",
|
||||
"help": "Помощь",
|
||||
"price": "Цена",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Кто-то записался на встречу в вашем календаре.",
|
||||
"someone_requested_password_reset": "Кто-то запросил ссылку для изменения вашего пароля.",
|
||||
"password_reset_instructions": "Если вы не запрашивали это, проигнорируйте это письмо и пароль не будет изменен.",
|
||||
"event_awaiting_approval_subject": "Ожидает подтверждения: {{eventType}} с {{name}} на {{date}}",
|
||||
"event_awaiting_approval_subject": "Ожидает подтверждения: {{title}} на {{date}}",
|
||||
"event_still_awaiting_approval": "Событие все еще ждет вашего подтверждения",
|
||||
"booking_submitted_subject": "Бронирование отправлено: {{eventType}} с {{name}}, {{date}}",
|
||||
"booking_submitted_subject": "Бронирование отправлено: {{title}}, {{date}}",
|
||||
"your_meeting_has_been_booked": "Ваша встреча была забронирована",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Ваше {{title}} было перенесено на {{date}}.",
|
||||
"event_has_been_rescheduled": "Ваше событие было перенесено.",
|
||||
@@ -423,7 +423,7 @@
|
||||
"password_hint_min": "Не короче 7 символов",
|
||||
"password_hint_admin_min": "Не короче 15 символов",
|
||||
"password_hint_num": "Не менее 1 цифры",
|
||||
"invalid_password_hint": "Пароль должен быть не короче 7 символов и содержать заглавные и строчные буквы, а также не менее одной цифры",
|
||||
"invalid_password_hint": "Пароль должен быть не короче {{passwordLength}} символов и содержать заглавные и строчные буквы, а также не менее одной цифры",
|
||||
"incorrect_password": "Неверный пароль",
|
||||
"24_h": "24 часа",
|
||||
"use_setting": "Использовать настройки",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} je Calendly alternativa sa otvorenim kodom koja vam omogućava kontrolu nad vašim podacima, procesom rada i izgledom.",
|
||||
"have_any_questions": "Imate pitanja? Tu smo da pomognemo.",
|
||||
"reset_password_subject": "{{appName}}: Upustva za resetovanje lozinke",
|
||||
"event_declined_subject": "Odbijeno: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"event_cancelled_subject": "Otkazano: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"event_declined_subject": "Odbijeno: {{title}} datuma {{date}}",
|
||||
"event_cancelled_subject": "Otkazano: {{title}} datuma {{date}}",
|
||||
"event_request_declined": "Vaš zahtev za dogadjaj je odbijen",
|
||||
"event_request_declined_recurring": "Vaš zahtev za ponavljajući događaj je odbijen",
|
||||
"event_request_cancelled": "Vaš zakazan dogadjaj je odbijen",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Povraćaj novca za dogadjaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
|
||||
"check_with_provider_and_user": "Molimo vas proverite sa vašom uslugom plaćanja i {{user}} kako bi rešili ovo.",
|
||||
"a_refund_failed": "Povraćaj novca neuspešan",
|
||||
"awaiting_payment_subject": "Čekanje na uplatu: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
|
||||
"meeting_awaiting_payment": "Vaš sastanak čeka na uplatu",
|
||||
"help": "Pomoć",
|
||||
"price": "Cena",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Neko zahteva da rezerviše dogadjaj na vašem kalendaru.",
|
||||
"someone_requested_password_reset": "Neko zahteva link da promeni vašu lozinku.",
|
||||
"password_reset_instructions": "Ako niste pokrenuli ovaj zahtev, slobodno ignorišite ovu e-poštu i vaša lozinka se neće promeniti.",
|
||||
"event_awaiting_approval_subject": "Čeka na odobrenje: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"event_awaiting_approval_subject": "Čeka na odobrenje: {{title}} datuma {{date}}",
|
||||
"event_still_awaiting_approval": "Događaj još čeka na vaše odobrenje",
|
||||
"booking_submitted_subject": "Rezervacija podneta: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"booking_submitted_subject": "Rezervacija podneta:{{title}} datuma {{date}}",
|
||||
"your_meeting_has_been_booked": "Sastanak je zakazan",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
|
||||
"event_has_been_rescheduled": "Ažurirano - Vaš dogadjaj je odložen",
|
||||
@@ -423,7 +423,7 @@
|
||||
"password_hint_min": "Dugačka minimalno 7 znakova",
|
||||
"password_hint_admin_min": "Dugačka minimalno 15 znakova",
|
||||
"password_hint_num": "Sadrži barem 1 broj",
|
||||
"invalid_password_hint": "Lozinka mora da bude dugačka najmanje 7 znakova, da sadrži najmanje jedan broj i da ima mešavinu velikih i malih slova",
|
||||
"invalid_password_hint": "Lozinka mora da bude dugačka najmanje {{passwordLength}} znakova, da sadrži najmanje jedan broj i da ima mešavinu velikih i malih slova",
|
||||
"incorrect_password": "Lozinka je netačna.",
|
||||
"24_h": "24 sata",
|
||||
"use_setting": "Koristi podešavanje",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}} är öppen källkods-alternativet till Calendly som ger dig kontroll över din egen data, ditt arbetsflöde och ditt utseende.",
|
||||
"have_any_questions": "Har du frågor? Vi är här för att hjälpa till.",
|
||||
"reset_password_subject": "{{appName}}: Instruktioner för att återställa ditt lösenord",
|
||||
"event_declined_subject": "Avböjd: {{eventType}} med {{name}} vid {{date}}",
|
||||
"event_cancelled_subject": "Inställd: {{eventType}} med {{name}} vid {{date}}",
|
||||
"event_declined_subject": "Avböjd: {{title}} vid {{date}}",
|
||||
"event_cancelled_subject": "Inställd: {{title}} vid {{date}}",
|
||||
"event_request_declined": "Din bokningsförfrågan har avbjöts",
|
||||
"event_request_declined_recurring": "Din återkommande händelse har avböjts",
|
||||
"event_request_cancelled": "Din schemalagda bokning ställdes in",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "Återbetalningen för bokningen {{eventType}} med {{userName}} den {{date}} misslyckades.",
|
||||
"check_with_provider_and_user": "Vänligen kontrollera med din betalningsleverantör och {{user}} hur du hanterar detta.",
|
||||
"a_refund_failed": "Återbetalning misslyckades",
|
||||
"awaiting_payment_subject": "Inväntar betalning: {{eventType}} med {{name}} på {{date}}",
|
||||
"awaiting_payment_subject": "Inväntar betalning: {{title}} på {{date}}",
|
||||
"meeting_awaiting_payment": "Din bokning väntar på betalning",
|
||||
"help": "Hjälp",
|
||||
"price": "Pris",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Någon har begärt att schemalägga en händelse i din kalender.",
|
||||
"someone_requested_password_reset": "Någon har begärt en länk för att ändra ditt lösenord.",
|
||||
"password_reset_instructions": "Om du inte begärde detta kan du ignorera detta e-postmeddelande och ditt lösenord kommer inte att ändras.",
|
||||
"event_awaiting_approval_subject": "Inväntar godkännande: {{eventType}} med {{name}} den {{date}}",
|
||||
"event_awaiting_approval_subject": "Inväntar godkännande: {{title}} den {{date}}",
|
||||
"event_still_awaiting_approval": "En bokning väntar fortfarande på ditt godkännande",
|
||||
"booking_submitted_subject": "Bokning skickad: {{eventType}} med {{name}} {{date}}",
|
||||
"booking_submitted_subject": "Bokning skickad: {{title}} {{date}}",
|
||||
"your_meeting_has_been_booked": "Ditt möte har bokats",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Din {{title}} har ändrats till {{date}}.",
|
||||
"event_has_been_rescheduled": "Uppdatering - Din bokning har blivit omplanerad",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "Om du tappat bort eller glömt den här hemligheten kan du ändra den, men var medveten om att alla integrationer som använder hemligheten måste uppdateras",
|
||||
"current_incorrect_password": "Nuvarande lösenord är felaktigt",
|
||||
"password_hint_caplow": "Blandning av versaler och gemener",
|
||||
"password_hint_min": "Minst 8 tecken",
|
||||
"password_hint_min": "Minst 7 tecken",
|
||||
"password_hint_admin_min": "Minst 15 tecken",
|
||||
"password_hint_num": "Innehåller minst 1 siffra",
|
||||
"invalid_password_hint": "Lösenordet måste innehålla minst 7 tecken och har minst en siffra samt en blandning av versaler och gemener",
|
||||
"invalid_password_hint": "Lösenordet måste innehålla minst {{passwordLength}} tecken och har minst en siffra samt en blandning av versaler och gemener",
|
||||
"incorrect_password": "Lösenordet är felaktigt.",
|
||||
"24_h": "24 timmar",
|
||||
"use_setting": "Använd inställning",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"calcom_explained": "{{appName}}, kendi verilerinizin, iş akışınızın ve web görünümünüzün kontrolünü size veren açık kaynaklı Calendly uygulamasının alternatifidir.",
|
||||
"have_any_questions": "Sorularınız mı var? Size yardımcı olmak için buradayız.",
|
||||
"reset_password_subject": "{{appName}}: Şifre sıfırlama talimatları",
|
||||
"event_declined_subject": "Reddedildi: {{date}} tarihinde {{name}} ile {{eventType}}",
|
||||
"event_cancelled_subject": "İptal edildi: {{date}} tarihinde {{name}} ile {{eventType}}",
|
||||
"event_declined_subject": "Reddedildi: {{date}} tarihinde {{title}}",
|
||||
"event_cancelled_subject": "İptal edildi: {{date}} tarihinde {{title}}",
|
||||
"event_request_declined": "Etkinlik talebiniz reddedildi",
|
||||
"event_request_declined_recurring": "Yinelenen etkinlik talebiniz reddedildi",
|
||||
"event_request_cancelled": "Planlanan etkinliğiniz iptal edildi",
|
||||
@@ -32,7 +32,7 @@
|
||||
"refund_failed": "{{date}} tarihindeki {{userName}} ile {{eventType}} etkinliğinin iadesi başarısız oldu.",
|
||||
"check_with_provider_and_user": "Lütfen ödeme sağlayıcınızla ve {{user}} ile bunu nasıl ele alınacağınızı kontrol edin.",
|
||||
"a_refund_failed": "İade başarısız oldu",
|
||||
"awaiting_payment_subject": "Ödeme Bekleniyor: {{date}} tarihinde {{name}} ile {{eventType}}",
|
||||
"awaiting_payment_subject": "Ödeme Bekleniyor: {{date}} tarihinde {{title}}",
|
||||
"meeting_awaiting_payment": "Toplantınız için ödeme bekleniyor",
|
||||
"help": "Yardım",
|
||||
"price": "Fiyat",
|
||||
@@ -63,9 +63,9 @@
|
||||
"someone_requested_an_event": "Birisi takviminizde bir etkinlik planlamak istiyor.",
|
||||
"someone_requested_password_reset": "Biri şifrenizi değiştirmek için bir bağlantı talebinde bulundu.",
|
||||
"password_reset_instructions": "Böyle bir talepte bulunmadıysanız bu e-postayı güvenle yok sayabilirsiniz. Şifreniz değiştirilmeyecektir.",
|
||||
"event_awaiting_approval_subject": "Onay Bekleniyor: {{date}} tarihinde {{name}} ile {{eventType}}",
|
||||
"event_awaiting_approval_subject": "Onay Bekleniyor: {{date}} tarihinde {{title}}",
|
||||
"event_still_awaiting_approval": "Bir etkinlik hâlâ onayınızı bekliyor",
|
||||
"booking_submitted_subject": "Rezervasyon Gönderildi: {{date}} tarihinde {{name}} ile {{eventType}}",
|
||||
"booking_submitted_subject": "Rezervasyon Gönderildi: {{date}} tarihinde {{title}}",
|
||||
"your_meeting_has_been_booked": "Toplantı rezervasyonunuz yapıldı",
|
||||
"event_type_has_been_rescheduled_on_time_date": "{{title}}, {{date}} olarak yeniden planlandı.",
|
||||
"event_has_been_rescheduled": "Güncellendi - Etkinliğiniz yeniden planlandı",
|
||||
@@ -420,10 +420,10 @@
|
||||
"forgotten_secret_description": "Bu gizli anahtarı kaybettiyseniz veya unuttuysanız değiştirebilirsiniz ancak bu gizli anahtarı kullanan tüm entegrasyonların güncellenmesi gerekecektir",
|
||||
"current_incorrect_password": "Mevcut şifre hatalı",
|
||||
"password_hint_caplow": "Büyük ve küçük harf karışımı",
|
||||
"password_hint_min": "En az 8 karakter uzunluğunda",
|
||||
"password_hint_min": "En az 7 karakter uzunluğunda",
|
||||
"password_hint_admin_min": "Minimum uzunluk 15 karakter",
|
||||
"password_hint_num": "En az 1 rakam içermelidir",
|
||||
"invalid_password_hint": "Şifre en az 7 karakter uzunluğunda olmalı, en az bir rakam içermeli ve büyük ve küçük harflerin karışımından oluşmalıdır",
|
||||
"invalid_password_hint": "Şifre en az {{passwordLength}} karakter uzunluğunda olmalı, en az bir rakam içermeli ve büyük ve küçük harflerin karışımından oluşmalıdır",
|
||||
"incorrect_password": "Şifre hatalı.",
|
||||
"24_h": "24 sa",
|
||||
"use_setting": "Ayarı kullan",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user