Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
368d1afe27 | ||
|
|
2069fd7248 | ||
|
|
7ed21b692e | ||
|
|
4fcdc7bf5c | ||
|
|
2a0a293f8c | ||
|
|
1b15b71a24 | ||
|
|
5d9889a90c | ||
|
|
a7ee3366eb | ||
|
|
c68e7595fe | ||
|
|
3f6be3b2c0 | ||
|
|
f1647248a2 | ||
|
|
d7db1ad303 | ||
|
|
bd48fd01b1 | ||
|
|
fabf692a08 | ||
|
|
ab62fc5d47 | ||
|
|
0c4cb2e2b0 | ||
|
|
7d77132102 | ||
|
|
50ac5f2892 | ||
|
|
fd12fcc4b8 | ||
|
+2 |
58453d0f2f | ||
|
|
44fd1b9fef | ||
|
|
0098591a56 | ||
|
|
41c1e960f8 | ||
|
|
4e49d32c61 | ||
|
+9 |
0b29c5063a | ||
|
|
95a92567cb | ||
|
+7 |
9e183e6ad1 | ||
|
|
7900f8bab5 | ||
|
|
50904d2588 | ||
|
|
6f811f4f0a | ||
|
|
d9000d0cbe | ||
|
|
ff78e66051 | ||
|
|
7c30f8ca4b | ||
|
|
0ebce77a7b | ||
|
|
4282adac4b | ||
|
|
ef611e2360 | ||
|
|
10ab86b332 | ||
|
|
f04cfef0af | ||
|
|
50151403c4 | ||
|
|
7aa131f349 | ||
|
|
d88bdc368b | ||
|
|
255938fda0 | ||
|
|
5766d6f5f4 | ||
|
|
9d299c6d5f | ||
|
|
f49783b453 | ||
|
|
246fce3ab8 | ||
|
|
82f262f60d | ||
|
|
da8d2da368 | ||
|
|
f01058e073 | ||
|
|
8e369d3fb5 | ||
|
|
11c44db609 | ||
|
|
9d911cbb59 | ||
|
|
30a887df6f | ||
|
|
62fea25eb9 | ||
|
|
d43d8700d0 | ||
|
|
e7ed20ea27 | ||
|
|
5e12b5defe | ||
|
|
d241a522fb | ||
|
+2 |
ae929f27b0 | ||
|
|
369d7c4e28 | ||
|
|
4438ca2a63 | ||
|
|
75589db569 | ||
|
|
3752ac767a | ||
|
+3 |
f3e2cf5075 | ||
|
|
92a29593aa | ||
|
|
367c5d89cc | ||
|
|
f21f36547c | ||
|
|
d3f8d954e7 | ||
|
|
27372d624d | ||
|
|
a5d2f2444a | ||
|
|
6d45e1c7c7 | ||
|
|
48419469a6 | ||
|
|
045ebb6e01 | ||
|
|
c78ea5de0b | ||
|
|
d1a787315c | ||
|
|
ce1f1f3c47 | ||
|
|
2f8bd3c07a | ||
|
|
d2f27a0463 | ||
|
|
9134ad8c5e | ||
|
|
506036e751 | ||
|
|
5e80f5056d | ||
|
|
9304ca1415 | ||
|
|
fb246fdee6 | ||
|
|
7b9e77f43a | ||
|
|
47df21322d | ||
|
|
6017806763 | ||
|
|
b347f29d6c | ||
|
|
c26cf74aec | ||
|
|
760ed74a11 | ||
|
|
145fb09b73 | ||
|
|
dd14e827e1 | ||
|
|
246c90c10f | ||
|
|
a42f703c1d | ||
|
|
7256dbf84f | ||
|
|
f5faebad67 | ||
|
+3 |
be15affa5d | ||
|
|
58dd5308ac | ||
|
|
14a6c5a03f | ||
|
|
5af20f5a7b | ||
|
|
8478363a23 | ||
|
|
19b3157c94 | ||
|
|
d4dd451c26 | ||
|
|
6c046f6507 | ||
|
|
a792d31939 | ||
|
|
fc997739b4 | ||
|
|
542d9aa3c6 | ||
|
|
8f3e088db7 | ||
|
|
699313ac70 | ||
|
|
fca7087e7d | ||
|
|
a6b16490c0 | ||
|
|
5da735c418 | ||
|
|
f9ef883f3d | ||
|
|
9ddbaf0966 | ||
|
|
0695ce7edb | ||
|
|
74f0d39997 | ||
|
|
85ab3fb164 | ||
|
|
ab417ac267 | ||
|
|
24635af730 | ||
|
|
682f0c66ff | ||
|
|
ace27ca84e | ||
|
|
ce41397517 | ||
|
|
81aba55710 | ||
|
|
c5fc3a7e8e | ||
|
|
f628ac4366 | ||
|
|
6e2e252c49 | ||
|
|
10d3a126aa | ||
|
|
341c39cb6b | ||
|
|
91909281bc | ||
|
|
f14b13a1ba | ||
|
|
ca859c871c | ||
|
|
6e49a3e1c3 | ||
|
|
830f42bf9a | ||
|
|
0830523b84 | ||
|
|
612b293a70 | ||
|
|
3ecfa75f86 | ||
|
|
631e974769 | ||
|
|
42c128b790 | ||
|
|
7d1065d228 | ||
|
|
e2747f89d0 | ||
|
|
a1d4589e66 | ||
|
|
7d2f87a13e | ||
|
|
4e7200a44b | ||
|
|
52959fe257 | ||
|
|
61b010a60a | ||
|
|
4e6e484d8b | ||
|
|
0a2de203a6 | ||
|
|
48ee7a956c | ||
|
|
ae46d615ef | ||
|
|
9b6d5d2ff8 | ||
|
|
ec0352da20 | ||
|
|
7e65d5d61d | ||
|
|
f855f24d5c | ||
|
|
c2057c4cc2 | ||
|
|
1640264e40 | ||
|
|
579fd399c3 | ||
|
|
1de00d46bf | ||
|
|
01db3ad14e | ||
|
|
c1cb02a9d1 | ||
|
|
344710d1f9 | ||
|
|
6f0b4f94e6 | ||
|
|
c6ad2de2f9 | ||
|
|
9ae3a0eaba | ||
|
|
2f3d7318a4 | ||
|
|
6e71de7ca3 | ||
|
|
7d277ad89f | ||
|
|
ac276a9654 | ||
|
|
588748d78e | ||
|
|
25711ef18e | ||
|
|
440f53714b | ||
|
|
22f6d8ab9b | ||
|
|
57f0d115cf | ||
|
|
23bc6aa600 | ||
|
|
abdb196f56 | ||
|
|
8c0e2b5129 | ||
|
|
cf32287109 | ||
|
|
2ef8e230c5 | ||
|
|
546d0d50c4 | ||
|
|
33e8198779 | ||
|
|
e7dd0ff3e9 | ||
|
|
1b2707d4f0 | ||
|
|
00071bcdf0 | ||
|
|
fc5cc5b8bc | ||
|
|
1e79a55825 | ||
|
|
4645bbcb75 | ||
|
|
95b903110f | ||
|
|
2917455324 | ||
|
|
d71765848c | ||
|
|
cf7b7d7d3d | ||
|
|
96aaa36751 | ||
|
|
52e74245a4 | ||
|
|
cc03b132e6 |
@@ -97,7 +97,7 @@ LARK_OPEN_VERIFICATION_TOKEN=""
|
||||
|
||||
# - WEB3
|
||||
# Used for the Web3 plugin
|
||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/web3/README.md
|
||||
# @see https://github.com/calcom/cal.com/blob/main/packages/app-store/rainbow/README.md
|
||||
ALCHEMY_API_KEY=""
|
||||
INFURA_API_KEY=""
|
||||
# *********************************************************************************************************
|
||||
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
jobs:
|
||||
check-types:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -20,8 +22,13 @@ jobs:
|
||||
|
||||
- name: Use Node 16.x
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
with:
|
||||
node-version: 16.x
|
||||
cache: "yarn"
|
||||
|
||||
- name: Show info
|
||||
run: node -e "console.log(require('v8').getHeapStatistics())"
|
||||
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn type-check
|
||||
|
||||
Vendored
+1
-1
@@ -6,5 +6,5 @@
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"spellright.language": ["en"],
|
||||
"spellright.documentTypes": ["markdown"]
|
||||
"spellright.documentTypes": ["markdown", "typescript"]
|
||||
}
|
||||
|
||||
@@ -4,23 +4,6 @@ Contributions are what make the open source community such an amazing place to l
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/cal.com/pulls) or [issues](https://github.com/calcom/cal.com/issues) for an open or closed item that relates to your submission.
|
||||
|
||||
## Areas of expertise
|
||||
|
||||
### Legend
|
||||
|
||||
✅ = has knowledge
|
||||
|
||||
🥇 = is their main priority
|
||||
|
||||
⚠️ = is the only one with knowledge
|
||||
|
||||
👀 = has no knowledge but wants to be onboarded
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://dynamic-svgs.vercel.app/image.svg?dark">
|
||||
<img alt="Areas of expertise table" src="https://dynamic-svgs.vercel.app/image.svg">
|
||||
</picture>
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<a href="https://news.ycombinator.com/item?id=26817795"><img src="https://img.shields.io/badge/Hacker%20News-311-%23FF6600" alt="Hacker News"></a>
|
||||
<a href="https://github.com/calcom/cal.com/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||
<a href="https://github.com/calcom/cal.com/pulse"><img src="https://img.shields.io/github/commit-activity/m/calcom/cal.com" alt="Commits-per-month"></a>
|
||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
|
||||
+1
-1
Submodule apps/api updated: fcb9ca6ace...7e9226fabc
@@ -6,7 +6,7 @@ title: Vercel
|
||||
|
||||
## Requirements
|
||||
|
||||
You need a PostgresDB database hosted somewhere. [Heroku](https://www.heroku.com) and [Supabase](https://supabase.com/) offer great free options.
|
||||
You need a PostgresDB database hosted somewhere. [Supabase](https://supabase.com/) offers a great free option.
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
@@ -15,14 +15,15 @@
|
||||
"dependencies": {
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@radix-ui/react-avatar": "^0.1.4",
|
||||
"@radix-ui/react-collapsible": "^0.1.0",
|
||||
"@radix-ui/react-dialog": "^0.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^0.1.1",
|
||||
"@radix-ui/react-id": "^0.1.0",
|
||||
"@radix-ui/react-radio-group": "^0.1.1",
|
||||
"@radix-ui/react-slider": "^0.1.1",
|
||||
"@radix-ui/react-switch": "^0.1.1",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
||||
"@radix-ui/react-id": "^1.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.0",
|
||||
"@radix-ui/react-radio-group": "^1.0.0",
|
||||
"@radix-ui/react-slider": "^1.0.0",
|
||||
"@radix-ui/react-switch": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"next": "^12.2.5",
|
||||
"next-transpile-modules": "^9.0.0",
|
||||
|
||||
@@ -426,7 +426,7 @@ paths:
|
||||
- installed: true
|
||||
type: caldav_calendar
|
||||
credential: null
|
||||
title: CalDav Server
|
||||
title: CalDav (Beta)
|
||||
imageSrc: integrations/caldav.svg
|
||||
description: For personal and business calendars
|
||||
__N_SSP: true
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
import classNames from "classnames";
|
||||
import { useRouter } from "next/router";
|
||||
import { NextRouter, useRouter } from "next/router";
|
||||
import { createRef, forwardRef, MutableRefObject, RefObject, useRef, useState } from "react";
|
||||
import { components, ControlProps } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { InputLeading, Label, TextArea, TextField } from "@calcom/ui/form/fields";
|
||||
import { HorizontalTabs, showToast } from "@calcom/ui/v2";
|
||||
import { Button, Switch } from "@calcom/ui/v2";
|
||||
import { Button, HorizontalTabs, showToast, Switch } from "@calcom/ui/v2";
|
||||
import { Dialog, DialogClose, DialogContent } from "@calcom/ui/v2/core/Dialog";
|
||||
|
||||
import { EMBED_LIB_URL, WEBAPP_URL } from "@lib/config/constants";
|
||||
|
||||
@@ -46,6 +45,23 @@ const getDimension = (dimension: string) => {
|
||||
return dimension;
|
||||
};
|
||||
|
||||
const goto = (router: NextRouter, searchParams: Record<string, string>) => {
|
||||
const newQuery = new URLSearchParams(router.asPath.split("?")[1]);
|
||||
Object.keys(searchParams).forEach((key) => {
|
||||
newQuery.set(key, searchParams[key]);
|
||||
});
|
||||
router.push(`${router.asPath.split("?")[0]}?${newQuery.toString()}`, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
};
|
||||
|
||||
const removeQueryParams = (router: NextRouter, queryParams: string[]) => {
|
||||
queryParams.forEach((param) => {
|
||||
delete router.query[param];
|
||||
});
|
||||
router.push(`${router.asPath.split("?")[0]}?${router.query.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* It allows us to show code with certain reusable blocks indented according to the block variable placement
|
||||
* So, if you add a variable ${abc} with indentation of 4 spaces, it will automatically indent all newlines in `abc` with the same indent before constructing the final string
|
||||
@@ -452,7 +468,7 @@ const embeds: {
|
||||
const tabs = [
|
||||
{
|
||||
name: "HTML",
|
||||
embedTabName: "embed-code",
|
||||
href: "embedTabName=embed-code",
|
||||
icon: Icon.FiCode,
|
||||
type: "code",
|
||||
Component: forwardRef<
|
||||
@@ -503,7 +519,7 @@ ${getEmbedTypeSpecificString({ embedFramework: "HTML", embedType, calLink, previ
|
||||
},
|
||||
{
|
||||
name: "React",
|
||||
embedTabName: "embed-react",
|
||||
href: "embedTabName=embed-react",
|
||||
icon: Icon.FiCode,
|
||||
type: "code",
|
||||
Component: forwardRef<
|
||||
@@ -543,8 +559,8 @@ ${getEmbedTypeSpecificString({ embedFramework: "react", embedType, calLink, prev
|
||||
},
|
||||
{
|
||||
name: "Preview",
|
||||
embedTabName: "embed-preview",
|
||||
icon: Icon.FiEye,
|
||||
href: "embedTabName=embed-preview",
|
||||
icon: Icon.FiTrello,
|
||||
type: "iframe",
|
||||
Component: forwardRef<
|
||||
HTMLIFrameElement | HTMLTextAreaElement | null,
|
||||
@@ -590,7 +606,7 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<DialogContent size="lg">
|
||||
<DialogContent type="creation" useOwnActionButtons size="lg">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{t("how_you_want_add_cal_site")}
|
||||
@@ -606,11 +622,8 @@ const ChooseEmbedTypesDialogContent = () => {
|
||||
key={index}
|
||||
data-testid={embed.type}
|
||||
onClick={() => {
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
embedType: embed.type,
|
||||
},
|
||||
goto(router, {
|
||||
embedType: embed.type,
|
||||
});
|
||||
}}>
|
||||
<div className="order-none box-border flex-none rounded-sm border border-solid bg-white">
|
||||
@@ -635,6 +648,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const s = (href: string) => {
|
||||
const searchParams = new URLSearchParams(router.asPath.split("?")[1] || "");
|
||||
const [a, b] = href.split("=");
|
||||
searchParams.set(a, b);
|
||||
return `${router.asPath.split("?")[0]}?${searchParams.toString()}`;
|
||||
};
|
||||
const parsedTabs = tabs.map((t) => ({ ...t, href: s(t.href) }));
|
||||
const embedCodeRefs: Record<typeof tabs[0]["name"], RefObject<HTMLTextAreaElement>> = {};
|
||||
tabs
|
||||
.filter((tab) => tab.type === "code")
|
||||
@@ -661,28 +681,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
const noPopupQuery = {
|
||||
...router.query,
|
||||
};
|
||||
|
||||
delete noPopupQuery.dialog;
|
||||
|
||||
queryParamsForDialog.forEach((queryParam) => {
|
||||
delete noPopupQuery[queryParam];
|
||||
});
|
||||
|
||||
router.push({
|
||||
query: noPopupQuery,
|
||||
});
|
||||
removeQueryParams(router, ["dialog", ...queryParamsForDialog]);
|
||||
};
|
||||
|
||||
// Use embed-code as default tab
|
||||
if (!router.query.embedTabName) {
|
||||
router.query.embedTabName = "embed-code";
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
},
|
||||
goto(router, {
|
||||
embedTabName: "embed-code",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -779,20 +784,13 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent size="lg">
|
||||
<DialogContent size="xl" className="p-0.5" type="creation" useOwnActionButtons>
|
||||
<div className="flex">
|
||||
<div className="flex w-1/3 flex-col bg-white p-6">
|
||||
<div className="flex w-1/3 flex-col bg-gray-50 p-8">
|
||||
<h3 className="mb-2 flex text-xl font-bold leading-6 text-gray-900" id="modal-title">
|
||||
<button
|
||||
onClick={() => {
|
||||
const newQuery = { ...router.query };
|
||||
delete newQuery.embedType;
|
||||
delete newQuery.embedTabName;
|
||||
router.push({
|
||||
query: {
|
||||
...newQuery,
|
||||
},
|
||||
});
|
||||
removeQueryParams(router, ["embedType", "embedTabName"]);
|
||||
}}>
|
||||
<Icon.FiArrowLeft className="mr-4 w-4" />
|
||||
</button>
|
||||
@@ -1040,14 +1038,14 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-2/3 flex-col p-6">
|
||||
<HorizontalTabs tabNameKey="embedTabName" data-testid="embed-tabs" tabs={tabs} />
|
||||
<div className="flex w-2/3 flex-col p-8">
|
||||
<HorizontalTabs data-testid="embed-tabs" tabs={parsedTabs} linkProps={{ shallow: true }} />
|
||||
{tabs.map((tab) => {
|
||||
return (
|
||||
<div
|
||||
key={tab.embedTabName}
|
||||
key={tab.href}
|
||||
className={classNames(
|
||||
router.query.embedTabName === tab.embedTabName ? "flex flex-grow flex-col" : "hidden"
|
||||
router.query.embedTabName === tab.href.split("=")[1] ? "flex flex-grow flex-col" : "hidden"
|
||||
)}>
|
||||
<div className="flex h-[55vh] flex-grow flex-col">
|
||||
{tab.type === "code" ? (
|
||||
@@ -1133,19 +1131,10 @@ export const EmbedButton = <T extends React.ElementType>({
|
||||
const router = useRouter();
|
||||
className = classNames(className, "hidden lg:inline-flex");
|
||||
const openEmbedModal = () => {
|
||||
const query = {
|
||||
...router.query,
|
||||
goto(router, {
|
||||
dialog: "embed",
|
||||
embedUrl,
|
||||
};
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
});
|
||||
};
|
||||
const Component = as ?? Button;
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ export function useViewerI18n() {
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
* We intend to not cache i18n query
|
||||
**/
|
||||
context: { skipBatch: true },
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type AvailableTimesProps = {
|
||||
eventTypeId: number;
|
||||
recurringCount: number | undefined;
|
||||
eventTypeSlug: string;
|
||||
date: Dayjs;
|
||||
date?: Dayjs;
|
||||
seatsPerTimeSlot?: number | null;
|
||||
slots?: Slot[];
|
||||
isLoading: boolean;
|
||||
@@ -44,8 +44,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
|
||||
}, []);
|
||||
|
||||
if (!date) return null;
|
||||
|
||||
return (
|
||||
<div className="dark:bg-darkgray-100 mt-8 flex flex-col px-4 text-center sm:mt-0 sm:w-1/3 sm:p-5 md:-mb-5">
|
||||
<div className="dark:bg-darkgray-100 mt-8 flex h-full w-full flex-col px-4 text-center sm:mt-0 sm:p-5 md:-mb-5 md:min-w-[200px] lg:min-w-[300px]">
|
||||
<div className="mb-4 text-left text-base">
|
||||
<span className="text-bookingdarker dark:text-darkgray-800 mb-8 w-1/2 break-words font-semibold text-gray-900">
|
||||
{nameOfDay(i18n.language, Number(date.format("d")))}
|
||||
@@ -150,4 +152,6 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
AvailableTimes.displayName = "AvailableTimes";
|
||||
|
||||
export default AvailableTimes;
|
||||
|
||||
@@ -29,7 +29,7 @@ type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringBookings?: BookingItem[];
|
||||
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
|
||||
};
|
||||
|
||||
function BookingListItem(booking: BookingItemProps) {
|
||||
@@ -44,10 +44,25 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const mutation = trpc.useMutation(["viewer.bookings.confirm"], {
|
||||
onSuccess: () => {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
utils.invalidateQueries("viewer.bookings");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.invalidateQueries("viewer.bookings");
|
||||
},
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
@@ -56,28 +71,18 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
||||
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
||||
const isRejected = booking.status === BookingStatus.REJECTED;
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
|
||||
const pendingActions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label:
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("reject_all")
|
||||
: t("reject"),
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
onClick: () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
},
|
||||
@@ -86,10 +91,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
label:
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("confirm_all")
|
||||
: t("confirm"),
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
},
|
||||
@@ -102,23 +104,15 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
label:
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? t("cancel_all_remaining")
|
||||
: t("cancel"),
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel"),
|
||||
/* When cancelling we need to let the UI and the API know if the intention is to
|
||||
cancel all remaining bookings or just that booking instance. */
|
||||
href: `/cancel/${booking.uid}${
|
||||
booking.listingStatus === "recurring" && booking.recurringEventId !== null
|
||||
? "?allRemainingBookings=true"
|
||||
: ""
|
||||
}`,
|
||||
href: `/cancel/${booking.uid}${isTabRecurring && isRecurring ? "?allRemainingBookings=true" : ""}`,
|
||||
icon: Icon.FiX,
|
||||
},
|
||||
{
|
||||
id: "edit_booking",
|
||||
label: t("edit_booking"),
|
||||
icon: Icon.FiEdit,
|
||||
label: t("edit"),
|
||||
actions: [
|
||||
{
|
||||
id: "reschedule",
|
||||
@@ -147,7 +141,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
},
|
||||
];
|
||||
|
||||
if (booking.listingStatus === "recurring" && booking.recurringEventId !== null) {
|
||||
if (isTabRecurring && isRecurring) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
@@ -184,12 +178,8 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
let recurringStrings: string[] = [];
|
||||
let recurringDates: Date[] = [];
|
||||
|
||||
if (booking.recurringBookings && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||
[recurringStrings, recurringDates] = extractRecurringDates(
|
||||
booking.recurringBookings,
|
||||
user?.timeZone,
|
||||
i18n
|
||||
);
|
||||
if (booking.recurringBookings !== undefined && booking.eventType.recurringEvent?.freq !== undefined) {
|
||||
[recurringStrings, recurringDates] = extractRecurringDates(booking, user?.timeZone, i18n);
|
||||
}
|
||||
|
||||
const location = booking.location || "";
|
||||
@@ -262,73 +252,89 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<tr className="flex hover:bg-neutral-50">
|
||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:w-48" onClick={onClick}>
|
||||
<tr className="flex flex-col hover:bg-neutral-50 sm:flex-row">
|
||||
<td className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[10rem]" onClick={onClick}>
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{booking.recurringBookings &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
(booking.listingStatus === "recurring" || booking.listingStatus === "cancelled") && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.map((aDate, key) => (
|
||||
<p key={key}>{aDate}</p>
|
||||
))}>
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCcw
|
||||
strokeWidth="3"
|
||||
className="float-left mr-1 mt-1.5 inline-block h-3 w-3 text-gray-400"
|
||||
/>
|
||||
<p className="mt-1 pl-5 text-xs">
|
||||
{booking.status === BookingStatus.ACCEPTED
|
||||
? `${t("event_remaining", {
|
||||
count: recurringDates.length,
|
||||
})}`
|
||||
: getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: booking.recurringBookings.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={"flex-1 px-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
|
||||
<div className="cursor-pointer py-4">
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Badge variant="orange" className="mb-2 ltr:mr-2 rtl:ml-2">
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-sm text-gray-400">
|
||||
<RecurringBookingsTooltip
|
||||
booking={booking}
|
||||
recurringStrings={recurringStrings}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={"w-full px-4" + (isRejected ? " line-through" : "")} onClick={onClick}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pt-4 pb-2 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="pr-2 text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="text-sm text-gray-400 sm:hidden">
|
||||
<RecurringBookingsTooltip
|
||||
booking={booking}
|
||||
recurringStrings={recurringStrings}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
title={booking.title}
|
||||
className={classNames(
|
||||
"max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max",
|
||||
"max-w-10/12 sm:max-w-56 text-sm font-medium leading-6 text-neutral-900 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{booking.title}
|
||||
<span> </span>
|
||||
{booking.eventType?.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
|
||||
)}
|
||||
{isPending && (
|
||||
<Badge variant="orange" className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
@@ -351,8 +357,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
|
||||
<td className="py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:pl-0">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{isPending && user?.id === booking.user?.id && <TableActions actions={pendingActions} />}
|
||||
@@ -372,6 +377,61 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface RecurringBookingsTooltipProps {
|
||||
booking: BookingItemProps;
|
||||
recurringStrings: string[];
|
||||
recurringDates: Date[];
|
||||
}
|
||||
|
||||
const RecurringBookingsTooltip = ({
|
||||
booking,
|
||||
recurringStrings,
|
||||
recurringDates,
|
||||
}: RecurringBookingsTooltipProps) => {
|
||||
const { t } = useLocale();
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
(booking.recurringBookings &&
|
||||
booking.eventType?.recurringEvent?.freq &&
|
||||
(booking.listingStatus === "recurring" ||
|
||||
booking.listingStatus === "unconfirmed" ||
|
||||
booking.listingStatus === "cancelled") && (
|
||||
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
||||
<div className="flex">
|
||||
<Tooltip
|
||||
content={recurringStrings.map((aDate, key) => (
|
||||
<p key={key} className={classNames(recurringDates[key] < now && "line-through")}>
|
||||
{aDate}
|
||||
</p>
|
||||
))}>
|
||||
<div className="text-gray-600 dark:text-white">
|
||||
<Icon.FiRefreshCcw
|
||||
strokeWidth="3"
|
||||
className="float-left mr-1 mt-1.5 inline-block h-3 w-3 text-gray-400"
|
||||
/>
|
||||
<p className="mt-1 pl-5 text-xs">
|
||||
{booking.status === BookingStatus.ACCEPTED
|
||||
? `${t("event_remaining", {
|
||||
count: recurringDates.length,
|
||||
})}`
|
||||
: getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
recurringCount: recurringDates.filter((date) => {
|
||||
return date >= now;
|
||||
}).length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)) ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
interface UserProps {
|
||||
id: number;
|
||||
name: string | null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Switch } from "@calcom/ui/v2";
|
||||
import TimezoneSelect, { ITimezoneOption } from "@calcom/ui/v2/core/TimezoneSelect";
|
||||
|
||||
import { is24h, timeZone } from "../../lib/clock";
|
||||
|
||||
@@ -10,9 +10,10 @@ type Props = {
|
||||
onSelectTimeZone: (selectedTimeZone: string) => void;
|
||||
onToggle24hClock: (is24hClock: boolean) => void;
|
||||
timeFormat: string;
|
||||
hideTimeFormatToggle?: boolean;
|
||||
};
|
||||
|
||||
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat }) => {
|
||||
const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat, hideTimeFormatToggle }) => {
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState("");
|
||||
const [is24hClock, setIs24hClock] = useState(timeFormat === "HH:mm" && true);
|
||||
const { t } = useLocale();
|
||||
@@ -33,20 +34,22 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone, timeFormat
|
||||
};
|
||||
|
||||
return selectedTimeZone !== "" ? (
|
||||
<div className="max-w-80 dark:border-darkgray-300 dark:bg-darkgray-200 absolute z-10 w-full rounded-sm border border-gray-200 bg-white px-4 pt-4 pb-3 shadow-sm">
|
||||
<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 className="ml-auto flex items-center">
|
||||
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
|
||||
{t("am_pm")}
|
||||
</label>
|
||||
<Switch
|
||||
name="24hClock"
|
||||
label={t("24_h")}
|
||||
defaultChecked={is24hClock}
|
||||
onCheckedChange={handle24hClockToggle}
|
||||
/>
|
||||
</div>
|
||||
{!hideTimeFormatToggle && (
|
||||
<div className="ml-auto flex items-center">
|
||||
<label className="ltl:mr-3 mr-2 align-text-top text-sm font-medium text-neutral-700 ltr:ml-3 rtl:mr-3 dark:text-white">
|
||||
{t("am_pm")}
|
||||
</label>
|
||||
<Switch
|
||||
name="24hClock"
|
||||
label={t("24_h")}
|
||||
defaultChecked={is24hClock}
|
||||
onCheckedChange={handle24hClockToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Get router variables
|
||||
import autoAnimate from "@formkit/auto-animate";
|
||||
import { EventType } from "@prisma/client";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useReducer, useEffect, useMemo, useState } from "react";
|
||||
import { useReducer, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { z } from "zod";
|
||||
@@ -24,8 +25,7 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
import { getRecurringFreq } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import { detectBrowserTimeFormat, getIs24hClockFromLocalStorage } from "@calcom/lib/timeFormat";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import DatePicker from "@calcom/ui/v2/modules/booker/DatePicker";
|
||||
@@ -79,7 +79,7 @@ const useSlots = ({
|
||||
usernameList: string[];
|
||||
timeZone?: string;
|
||||
}) => {
|
||||
const { data, isLoading, isIdle } = trpc.useQuery(
|
||||
const { data, isLoading, isPaused } = trpc.useQuery(
|
||||
[
|
||||
"viewer.public.slots.getSchedule",
|
||||
{
|
||||
@@ -101,8 +101,8 @@ const useSlots = ({
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// The very first time isIdle is set if auto-fetch is disabled, so isIdle should also be considered a loading state.
|
||||
return { slots: cachedSlots, isLoading: isLoading || isIdle };
|
||||
// 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 };
|
||||
};
|
||||
|
||||
const SlotPicker = ({
|
||||
@@ -129,6 +129,11 @@ const SlotPicker = ({
|
||||
const { date, setQuery: setDate } = useRouterQuery("date");
|
||||
const { month, setQuery: setMonth } = useRouterQuery("month");
|
||||
const router = useRouter();
|
||||
const slotPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
slotPickerRef.current && autoAnimate(slotPickerRef.current);
|
||||
}, [slotPickerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
@@ -176,10 +181,8 @@ const SlotPicker = ({
|
||||
<DatePicker
|
||||
isLoading={isLoading}
|
||||
className={classNames(
|
||||
"mt-8 w-full px-4 sm:mt-0 sm:min-w-[455px] md:px-5",
|
||||
selectedDate
|
||||
? "sm:dark:border-darkgray-200 border-gray-200 sm:w-1/2 sm:border-r sm:p-4 sm:pr-6 md:w-1/3"
|
||||
: "sm:p-4"
|
||||
"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)}
|
||||
locale={isLocaleReady ? i18n.language : "en"}
|
||||
@@ -194,10 +197,10 @@ const SlotPicker = ({
|
||||
weekStart={weekStart}
|
||||
/>
|
||||
|
||||
{selectedDate && (
|
||||
<div ref={slotPickerRef}>
|
||||
<AvailableTimes
|
||||
isLoading={isLoading}
|
||||
slots={slots[selectedDate.format("YYYY-MM-DD")]}
|
||||
slots={selectedDate && slots[selectedDate.format("YYYY-MM-DD")]}
|
||||
date={selectedDate}
|
||||
timeFormat={timeFormat}
|
||||
eventTypeId={eventType.id}
|
||||
@@ -206,7 +209,7 @@ const SlotPicker = ({
|
||||
recurringCount={recurringEventCount}
|
||||
ethSignature={ethSignature}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -216,16 +219,18 @@ function TimezoneDropdown({
|
||||
onChangeTimeZone,
|
||||
timeZone,
|
||||
timeFormat,
|
||||
hideTimeFormatToggle,
|
||||
}: {
|
||||
onChangeTimeFormat: (newTimeFormat: string) => void;
|
||||
onChangeTimeZone: (newTimeZone: string) => void;
|
||||
timeZone?: string;
|
||||
timeFormat: string;
|
||||
hideTimeFormatToggle?: boolean;
|
||||
}) {
|
||||
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
handleToggle24hClock(localStorage.getItem("timeOption.is24hClock") === "true");
|
||||
handleToggle24hClock(!!getIs24hClockFromLocalStorage());
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -241,8 +246,8 @@ function TimezoneDropdown({
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={isTimeOptionsOpen} onOpenChange={setIsTimeOptionsOpen} className="flex">
|
||||
<Collapsible.Trigger className="min-w-32 dark:text-darkgray-600 mb-2 -ml-2 px-2 text-left text-gray-600">
|
||||
<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 inline-block rounded-md px-2 py-2 text-left text-gray-600">
|
||||
<p className="text-sm font-medium">
|
||||
<Icon.FiGlobe className="mr-[10px] ml-[2px] -mt-[2px] inline-block h-4 w-4" />
|
||||
{timeZone}
|
||||
@@ -252,15 +257,21 @@ function TimezoneDropdown({
|
||||
<Icon.FiChevronDown className="ml-1 inline-block h-4 w-4" />
|
||||
)}
|
||||
</p>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<TimeOptions
|
||||
onSelectTimeZone={handleSelectTimeZone}
|
||||
onToggle24hClock={handleToggle24hClock}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</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}
|
||||
onToggle24hClock={handleToggle24hClock}
|
||||
timeFormat={timeFormat}
|
||||
hideTimeFormatToggle={hideTimeFormatToggle}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,7 +300,14 @@ const useRouterQuery = <T extends string>(name: T) => {
|
||||
|
||||
export type Props = AvailabilityTeamPageProps | AvailabilityPageProps | DynamicAvailabilityPageProps;
|
||||
|
||||
const timeFormatTotimeFormatString = (timeFormat?: number | null) => {
|
||||
if (!timeFormat) return null;
|
||||
return timeFormat === 24 ? "HH:mm" : "h:mma";
|
||||
};
|
||||
|
||||
const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
||||
const timeFormatFromProfile = timeFormatTotimeFormatString(user?.timeFormat);
|
||||
const router = useRouter();
|
||||
const isEmbed = useIsEmbed();
|
||||
const query = dateQuerySchema.parse(router.query);
|
||||
@@ -302,8 +320,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
const isBackgroundTransparent = useIsBackgroundTransparent();
|
||||
|
||||
const [timeZone, setTimeZone] = useState<string>();
|
||||
const [timeFormat, setTimeFormat] = useState(detectBrowserTimeFormat);
|
||||
const [isAvailableTimesVisible, setIsAvailableTimesVisible] = useState<boolean>();
|
||||
const [timeFormat, setTimeFormat] = useState(timeFormatFromProfile || detectBrowserTimeFormat);
|
||||
const [gateState, gateDispatcher] = useReducer(
|
||||
(state: GateState, newState: Partial<GateState>) => ({
|
||||
...state,
|
||||
@@ -316,10 +333,6 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
setTimeZone(localStorageTimeZone() || dayjs.tz.guess());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAvailableTimesVisible(!!query.date);
|
||||
}, [query.date]);
|
||||
|
||||
// TODO: Improve this;
|
||||
useExposePlanGlobally(eventType.users.length === 1 ? eventType.users[0].plan : "PRO");
|
||||
|
||||
@@ -338,14 +351,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
|
||||
// get dynamic user list here
|
||||
const userList = eventType.users ? eventType.users.map((user) => user.username).filter(notEmpty) : [];
|
||||
// Recurring event sidebar requires more space
|
||||
const maxWidth = isAvailableTimesVisible
|
||||
? recurringEventCount
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl"
|
||||
: recurringEventCount
|
||||
? "max-w-4xl"
|
||||
: "max-w-3xl";
|
||||
|
||||
const timezoneDropdown = useMemo(
|
||||
() => (
|
||||
<TimezoneDropdown
|
||||
@@ -353,9 +359,12 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
onChangeTimeFormat={setTimeFormat}
|
||||
timeZone={timeZone}
|
||||
onChangeTimeZone={setTimeZone}
|
||||
// Currently we don't allow the user to change the timeformat when they're logged in,
|
||||
// the only way to change it is if they go to their profile.
|
||||
hideTimeFormatToggle={!!timeFormatFromProfile}
|
||||
/>
|
||||
),
|
||||
[timeZone, timeFormat]
|
||||
[timeZone, timeFormat, timeFormatFromProfile]
|
||||
);
|
||||
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.
|
||||
@@ -385,201 +394,109 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
<div>
|
||||
<main
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "mx-auto" : "",
|
||||
isEmbed
|
||||
? classNames(maxWidth)
|
||||
: classNames("transition-max-width mx-auto my-0 duration-500 ease-in-out md:my-24", maxWidth)
|
||||
"flex flex-col",
|
||||
shouldAlignCentrally ? "items-center" : "items-start",
|
||||
!isEmbed && classNames("mx-auto my-0 ease-in-out md:my-24")
|
||||
)}>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent ? "" : "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white",
|
||||
"border-bookinglightest overflow-hidden rounded-md md:border",
|
||||
isEmbed ? "mx-auto" : maxWidth
|
||||
)}>
|
||||
{/* mobile: details */}
|
||||
<div className="block px-4 pt-4 sm:p-8 md:hidden">
|
||||
<div>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="dark:text-darkgray-900 mt-2 gap-2">
|
||||
<p className="break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</p>
|
||||
<h1 className="text-bookingdark dark:text-darkgray-900 mb-4 break-words text-xl font-semibold">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="flex flex-col space-y-3">
|
||||
{eventType?.description && (
|
||||
<div className="dark:text-darkgray-600 flex py-1 text-sm font-medium text-gray-600">
|
||||
<div>
|
||||
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
</div>
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
<div>
|
||||
<div
|
||||
style={availabilityDatePickerEmbedStyles}
|
||||
className={classNames(
|
||||
isBackgroundTransparent
|
||||
? ""
|
||||
: "dark:bg-darkgray-100 sm:dark:border-darkgray-300 bg-white pb-4 md:pb-0",
|
||||
"border-bookinglightest overflow-hidden rounded-md md:border",
|
||||
isEmbed && "mx-auto"
|
||||
)}>
|
||||
<div className="overflow-hidden md:flex">
|
||||
<div
|
||||
className={classNames(
|
||||
"sm:dark:border-darkgray-200 flex flex-col border-gray-200 p-5 sm:border-r",
|
||||
"min-w-full md:w-[280px] md:min-w-[280px]",
|
||||
recurringEventCount && "xl:w-[380px] xl:min-w-[380px]"
|
||||
)}>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="mt-2 break-words text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl font-semibold text-gray-900">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
|
||||
{eventType?.description && (
|
||||
<div className="flex">
|
||||
<div>
|
||||
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<p className="dark:text-darkgray-600 text-gray-600">
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
{t("requires_confirmation")}
|
||||
</p>
|
||||
)}
|
||||
<AvailableEventLocations locations={eventType.locations} />
|
||||
<p className="dark:text-darkgray-600 text-gray-600">
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
</div>
|
||||
)}
|
||||
<AvailableEventLocations locations={eventType.locations} />
|
||||
<p className="text-sm font-medium">
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
{eventType.price > 0 && (
|
||||
<div className="dark:text-darkgray-600 text-gray-600">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</div>
|
||||
)}
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="dark:text-darkgray-600 flex items-center text-gray-600">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-1 ml-[2px] inline-block h-4 w-4 shrink-0 text-gray-500" />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:text-darkgray-600 h-7 rounded-sm border-gray-300 bg-white text-sm text-gray-600 [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500 dark:bg-gray-600"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="dark:text-darkgray-600 inline text-gray-600">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
|
||||
<div className="md:hidden">
|
||||
{/* Temp disabled booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-8 text-gray-600 dark:text-darkgray-600"
|
||||
data-testid="former_time_p_mobile">
|
||||
{t("former_time")}
|
||||
</p>
|
||||
<p className="text-gray-500 line-through dark:text-darkgray-600">
|
||||
<CalendarIcon className="mr-[10px] -mt-1 inline-block h-4 w-4 text-gray-500" />
|
||||
{typeof booking.startTime === "string" &&
|
||||
parseDate(dayjs(booking.startTime), i18n)}
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden sm:flex">
|
||||
<div
|
||||
className={
|
||||
"sm:dark:border-darkgray-200 hidden overflow-hidden border-gray-200 p-5 sm:border-r md:flex md:flex-col " +
|
||||
(isAvailableTimesVisible ? "sm:w-1/3" : recurringEventCount ? "sm:w-2/3" : "sm:w-1/2")
|
||||
}>
|
||||
<UserAvatars
|
||||
profile={profile}
|
||||
users={eventType.users}
|
||||
showMembers={eventType.schedulingType !== SchedulingType.ROUND_ROBIN}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
<h2 className="break-words text-sm font-medium text-gray-600 dark:text-gray-300 lg:mt-2">
|
||||
{profile.name}
|
||||
</h2>
|
||||
<h1 className="font-cal dark:text-darkgray-900 mb-6 break-words text-2xl text-gray-900">
|
||||
{eventType.title}
|
||||
</h1>
|
||||
<div className="dark:text-darkgray-600 flex flex-col space-y-3 text-sm font-medium text-gray-600">
|
||||
{eventType?.description && (
|
||||
<div className="flex">
|
||||
<div>
|
||||
<Icon.FiInfo className="mr-[10px] ml-[2px] inline-block h-4 w-4" />
|
||||
</div>
|
||||
<EventTypeDescriptionSafeHTML eventType={eventType} />
|
||||
{!isEmbed && (
|
||||
<div className="mt-auto hidden md:block">
|
||||
<GoBackToPreviousPage t={t} />
|
||||
</div>
|
||||
)}
|
||||
{eventType?.requiresConfirmation && (
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<Icon.FiCheckSquare className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 " />
|
||||
</div>
|
||||
{t("requires_confirmation")}
|
||||
</div>
|
||||
)}
|
||||
<AvailableEventLocations locations={eventType.locations} />
|
||||
<p className="text-sm font-medium">
|
||||
<Icon.FiClock className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
|
||||
{eventType.length} {t("minutes")}
|
||||
</p>
|
||||
{!rescheduleUid && eventType.recurringEvent && (
|
||||
<div className="flex items-start text-sm font-medium">
|
||||
<Icon.FiRefreshCcw className="float-left mr-[10px] mt-[7px] ml-[2px] inline-block h-4 w-4 " />
|
||||
<div>
|
||||
<p className="mb-1 -ml-2 inline px-2 py-1">
|
||||
{getRecurringFreq({ t, recurringEvent: eventType.recurringEvent })}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={eventType.recurringEvent.count}
|
||||
className="w-15 dark:bg-darkgray-200 h-7 rounded-sm border-gray-300 bg-white text-sm font-medium [appearance:textfield] ltr:mr-2 rtl:ml-2 dark:border-gray-500"
|
||||
defaultValue={eventType.recurringEvent.count}
|
||||
onChange={(event) => {
|
||||
setRecurringEventCount(parseInt(event?.target.value));
|
||||
}}
|
||||
/>
|
||||
<p className="inline">
|
||||
{t("occurrence", {
|
||||
count: recurringEventCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{eventType.price > 0 && (
|
||||
<p className="-ml-2 px-2 text-sm font-medium">
|
||||
<Icon.FiCreditCard className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4" />
|
||||
<IntlProvider locale="en">
|
||||
<FormattedNumber
|
||||
value={eventType.price / 100.0}
|
||||
style="currency"
|
||||
currency={eventType.currency.toUpperCase()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</p>
|
||||
)}
|
||||
{timezoneDropdown}
|
||||
</div>
|
||||
|
||||
{!isEmbed && <GoBackToPreviousPage t={t} />}
|
||||
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
{/* Temporarily disabled - booking?.startTime && rescheduleUid && (
|
||||
<div>
|
||||
<p
|
||||
className="mt-4 mb-3 text-gray-600 dark:text-darkgray-600"
|
||||
@@ -592,26 +509,33 @@ const AvailabilityPage = ({ profile, eventType }: Props) => {
|
||||
</p>
|
||||
</div>
|
||||
)*/}
|
||||
</div>
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
? ([
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
].indexOf(profile.weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: profile.weekStart /* Allows providing weekStart as number */
|
||||
}
|
||||
eventType={eventType}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
ethSignature={gateState.rainbowToken}
|
||||
/>
|
||||
</div>
|
||||
<SlotPicker
|
||||
weekStart={
|
||||
typeof profile.weekStart === "string"
|
||||
? (["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||
profile.weekStart
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
: profile.weekStart /* Allows providing weekStart as number */
|
||||
}
|
||||
eventType={eventType}
|
||||
timeFormat={timeFormat}
|
||||
timeZone={timeZone}
|
||||
users={userList}
|
||||
seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined}
|
||||
recurringEventCount={recurringEventCount}
|
||||
ethSignature={gateState.rainbowToken}
|
||||
/>
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
</div>
|
||||
{(!eventType.users[0] || !isBrandingHidden(eventType.users[0])) && !isEmbed && <PoweredByCal />}
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInputType, WorkflowActions } from "@prisma/client";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Head from "next/head";
|
||||
@@ -9,7 +10,6 @@ import { useEffect, useMemo, useState, useReducer } from "react";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||
import { ReactMultiEmail } from "react-multi-email";
|
||||
import { useMutation } from "react-query";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -34,11 +34,11 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { Button } from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
|
||||
import { EmailInput, Form } from "@calcom/ui/form/fields";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
@@ -199,16 +199,12 @@ const BookingPage = ({
|
||||
|
||||
// There should only exists one default userData variable for primaryAttendee.
|
||||
const defaultUserValues = {
|
||||
email: booking?.attendees[0].email
|
||||
? booking.attendees[0].email
|
||||
email: rescheduleUid
|
||||
? booking?.attendees[0].email
|
||||
: router.query.email
|
||||
? (router.query.email as string)
|
||||
: "",
|
||||
name: booking?.attendees[0].name
|
||||
? booking.attendees[0].name
|
||||
: router.query.name
|
||||
? (router.query.name as string)
|
||||
: "",
|
||||
name: rescheduleUid ? booking?.attendees[0].name : router.query.name ? (router.query.name as string) : "",
|
||||
};
|
||||
|
||||
const defaultValues = () => {
|
||||
@@ -449,7 +445,6 @@ const BookingPage = ({
|
||||
<div
|
||||
className={classNames(
|
||||
"main overflow-hidden",
|
||||
isEmbed ? "" : "border border-gray-200",
|
||||
isBackgroundTransparent ? "" : "dark:border-1 dark:bg-darkgray-100 bg-white",
|
||||
"dark:border-darkgray-300 rounded-md sm:border"
|
||||
)}>
|
||||
@@ -595,9 +590,7 @@ const BookingPage = ({
|
||||
required
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
bookingForm.formState.errors.email
|
||||
? "border-red-700 focus:ring-red-700"
|
||||
: " border-gray-300 dark:border-gray-900"
|
||||
bookingForm.formState.errors.email && "!focus:ring-red-700 !border-red-700"
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
type="search" // Disables annoying 1password intrusive popup (non-optimal, I know I know...)
|
||||
@@ -719,81 +712,81 @@ const BookingPage = ({
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.BOOL && (
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
required={input.required}
|
||||
id={"custom_" + input.id}
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||
placeholder=""
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
<div className="my-6">
|
||||
<div className="flex">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
required={input.required}
|
||||
id={"custom_" + input.id}
|
||||
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black disabled:bg-gray-200 ltr:mr-2 rtl:ml-2 disabled:dark:text-gray-500"
|
||||
placeholder=""
|
||||
disabled={disabledExceptForOwner}
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="-mt-px block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!eventType.disableGuests && (
|
||||
{!eventType.disableGuests && guestToggle && (
|
||||
<div className="mb-4">
|
||||
{guestToggle && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="guests"
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("guests")}
|
||||
</label>
|
||||
{!disableInput && (
|
||||
<Controller
|
||||
control={bookingForm.control}
|
||||
name="guests"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactMultiEmail
|
||||
className="relative"
|
||||
placeholder="guest@example.com"
|
||||
emails={value}
|
||||
onChange={onChange}
|
||||
getLabel={(
|
||||
email: string,
|
||||
index: number,
|
||||
removeEmail: (index: number) => void
|
||||
) => {
|
||||
return (
|
||||
<div data-tag key={index} className="cursor-pointer">
|
||||
{email}
|
||||
{!disableInput && (
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Custom code when guest emails should not be editable */}
|
||||
{disableInput && guestListEmails && guestListEmails.length > 0 && (
|
||||
<div data-tag className="react-multi-email">
|
||||
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
|
||||
{guestListEmails.map((email, index) => {
|
||||
return (
|
||||
<div key={index} className="cursor-pointer">
|
||||
<span data-tag>{email}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="guests"
|
||||
className="mb-1 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("guests")}
|
||||
</label>
|
||||
{!disableInput && (
|
||||
<Controller
|
||||
control={bookingForm.control}
|
||||
name="guests"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<ReactMultiEmail
|
||||
className="relative"
|
||||
placeholder={<span className="dark:text-darkgray-600">guest@example.com</span>}
|
||||
emails={value}
|
||||
onChange={onChange}
|
||||
getLabel={(
|
||||
email: string,
|
||||
index: number,
|
||||
removeEmail: (index: number) => void
|
||||
) => {
|
||||
return (
|
||||
<div data-tag key={index} className="cursor-pointer">
|
||||
{email}
|
||||
{!disableInput && (
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* Custom code when guest emails should not be editable */}
|
||||
{disableInput && guestListEmails && guestListEmails.length > 0 && (
|
||||
<div data-tag className="react-multi-email">
|
||||
{/* // @TODO: user owners are appearing as guest here when should be only user input */}
|
||||
{guestListEmails.map((email, index) => {
|
||||
return (
|
||||
<div key={index} className="cursor-pointer">
|
||||
<span data-tag>{email}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isSmsReminderNumberNeeded && selectedLocationType !== LocationType.Phone && (
|
||||
@@ -849,27 +842,28 @@ const BookingPage = ({
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
{!guestToggle && (
|
||||
{!eventType.disableGuests && !guestToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
color="minimalSecondary"
|
||||
size="icon"
|
||||
tooltip={t("additional_guests")}
|
||||
StartIcon={Icon.FiUserPlus}
|
||||
onClick={() => setGuestToggle(!guestToggle)}
|
||||
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mr-auto rounded-md">
|
||||
<Icon.FiUserPlus className="mr-2" />
|
||||
{t("additional_guests")}
|
||||
</Button>
|
||||
className="mr-auto"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
color="minimal"
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
// We override this for this component only for now - as we don't support darkmode everywhere in the app
|
||||
className="dark:border-none">
|
||||
className="dark:hover:bg-darkgray-200 dark:border-none dark:text-white">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast rounded-md"
|
||||
className="dark:bg-darkmodebrand dark:text-darkmodebrandcontrast dark:hover:border-darkmodebrandcontrast mr-auto dark:border-transparent"
|
||||
data-testid={rescheduleUid ? "confirm-reschedule-button" : "confirm-book-button"}
|
||||
loading={mutation.isLoading || recurringMutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { RescheduleResponse } from "pages/api/book/request-reschedule";
|
||||
import React, { useState, Dispatch, SetStateAction } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
|
||||
@@ -14,10 +14,11 @@ const CalendarItem = (props: ICalendarItem) => {
|
||||
const { title, imageSrc, type } = props;
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="flex flex-row items-center p-5">
|
||||
<img src={imageSrc} alt={title} className="h-8 w-8" />
|
||||
<p className="mx-3 text-sm font-bold">{title}</p>
|
||||
|
||||
<div className="flex flex-row items-center justify-between p-5">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={imageSrc} alt={title} className="h-8 w-8" />
|
||||
<p className="text-sm font-bold">{title}</p>
|
||||
</div>
|
||||
<InstallAppButtonWithoutPlanCheck
|
||||
type={type}
|
||||
render={(buttonProps) => (
|
||||
@@ -29,8 +30,7 @@ const CalendarItem = (props: ICalendarItem) => {
|
||||
// Save cookie key to return url step
|
||||
document.cookie = `return-to=${window.location.href};path=/;max-age=3600;SameSite=Lax`;
|
||||
buttonProps && buttonProps.onClick && buttonProps?.onClick(event);
|
||||
}}
|
||||
className="ml-auto rounded-md border border-gray-200 py-[10px] px-4 text-sm font-bold">
|
||||
}}>
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from "react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -31,7 +31,8 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||
return (
|
||||
<>
|
||||
{/* Already connected calendars */}
|
||||
{firstCalendar &&
|
||||
{!queryConnectedCalendars.isLoading &&
|
||||
firstCalendar &&
|
||||
firstCalendar.integration &&
|
||||
firstCalendar.integration.title &&
|
||||
firstCalendar.integration.imageSrc && (
|
||||
@@ -75,7 +76,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
|
||||
</List>
|
||||
)}
|
||||
|
||||
{queryConnectedCalendars.isLoading && (
|
||||
{queryIntegrations.isLoading && (
|
||||
<ul className="divide-y divide-gray-200 rounded-md border border-gray-200 bg-white p-0 dark:bg-black">
|
||||
{[0, 0, 0, 0].map((_item, index) => {
|
||||
return (
|
||||
|
||||
@@ -149,7 +149,7 @@ const UserProfile = (props: IUserProfile) => {
|
||||
}}
|
||||
/>
|
||||
{errors.bio && (
|
||||
<p data-testid="required" className="text-xs italic text-red-500">
|
||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||
{t("required")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -85,7 +85,7 @@ const UserSettings = (props: IUserSettingsProps) => {
|
||||
className="w-full rounded-md border border-gray-300 text-sm"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p data-testid="required" className="text-xs italic text-red-500">
|
||||
<p data-testid="required" className="py-2 text-xs text-red-500">
|
||||
{t("required")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -185,8 +185,8 @@ function ConnectedCalendarsList(props: Props) {
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("calendar_error")}
|
||||
message={item.error?.message}
|
||||
title={t("something_went_wrong")}
|
||||
message={t("calendar_error")}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { ButtonBaseProps } from "@calcom/ui/Button";
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useRef, useState } from "react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Alert } from "@calcom/ui/v2/core/Alert";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@calcom/ui/v2/core/Dialog";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -28,15 +28,14 @@ export default function TeamCreate(props: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const createTeam = () => {
|
||||
createTeamMutation.mutate({ name: nameRef?.current?.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={props.isOpen} onOpenChange={props.onClose}>
|
||||
<DialogContent>
|
||||
<DialogContent type="creation" actionText={t("create_new_team")} actionOnClick={createTeam}>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Icon.FiUsers className="text-brandcontrast h-6 w-6" />
|
||||
@@ -50,7 +49,7 @@ export default function TeamCreate(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={createTeam}>
|
||||
<form>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
@@ -66,18 +65,6 @@ export default function TeamCreate(props: Props) {
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
<DialogFooter>
|
||||
<Button type="button" color="secondary" onClick={props.onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="ltr:ml-2 rtl:mr-2"
|
||||
data-testid="create-new-team-button">
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import TeamListItem from "./TeamListItem";
|
||||
|
||||
|
||||
@@ -4,19 +4,21 @@ import Link from "next/link";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import ButtonGroup from "@calcom/ui/v2/core/ButtonGroup";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import { Dialog, DialogTrigger } from "@calcom/ui/v2/core/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Tooltip } from "@calcom/ui/Tooltip";
|
||||
DropdownItem,
|
||||
} from "@calcom/ui/v2/core/Dropdown";
|
||||
import { Tooltip } from "@calcom/ui/v2/core/Tooltip";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
@@ -84,7 +86,7 @@ export default function TeamListItem(props: Props) {
|
||||
!isInvitee && "group hover:bg-neutral-50"
|
||||
)}>
|
||||
{!isInvitee ? (
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<Link href={"/settings/teams/" + team.id + "/profile"}>
|
||||
<a className="flex-grow cursor-pointer truncate text-sm" title={`${team.name}`}>
|
||||
{teamInfo}
|
||||
</a>
|
||||
@@ -112,7 +114,6 @@ export default function TeamListItem(props: Props) {
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={Icon.FiCheck}
|
||||
onClick={acceptInvite}>
|
||||
@@ -121,8 +122,7 @@ export default function TeamListItem(props: Props) {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
color="warn"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={Icon.FiX}
|
||||
onClick={declineInvite}>
|
||||
@@ -137,111 +137,103 @@ export default function TeamListItem(props: Props) {
|
||||
{!isInvitee && (
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<TeamRole role={team.role} />
|
||||
|
||||
<Tooltip side="top" content={t("copy_link_team")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
className="h-10 w-10 transition-none"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
type="button">
|
||||
<Icon.FiLink className="h-5 w-5 group-hover:text-gray-600" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent hidden={hideDropdown}>
|
||||
{isAdmin && (
|
||||
<ButtonGroup combined>
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL + "/team/" + team.slug
|
||||
);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
size="icon"
|
||||
StartIcon={Icon.FiLink}
|
||||
combined
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="secondary" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent hidden={hideDropdown}>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
href={"/settings/teams/" + team.id + "/profile"}
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
{t("edit_team") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
target="_blank"
|
||||
href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`}
|
||||
StartIcon={Icon.FiExternalLink}>
|
||||
{t("preview_team") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_WEBSITE_URL}/team/${team.slug}`} passHref={true}>
|
||||
<a target="_blank">
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={Icon.FiExternalLink}>
|
||||
{t("preview_team")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog open={hideDropdown} onOpenChange={setHideDropdown}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
size="sm"
|
||||
className="w-full rounded-none font-medium"
|
||||
StartIcon={Icon.FiTrash}>
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
isLoading={props.isLoading}
|
||||
onConfirm={() => {
|
||||
props.onActionSelect("disband");
|
||||
}}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{!isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="warn"
|
||||
size="lg"
|
||||
StartIcon={Icon.FiLogOut}
|
||||
className="w-full rounded-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog open={hideDropdown} onOpenChange={setHideDropdown}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="destructive"
|
||||
className="rounded-none px-3 font-normal"
|
||||
StartIcon={Icon.FiTrash}>
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
isLoading={props.isLoading}
|
||||
onConfirm={() => {
|
||||
props.onActionSelect("disband");
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={declineInvite}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{!isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="destructive"
|
||||
size="lg"
|
||||
StartIcon={Icon.FiLogOut}
|
||||
className="w-full rounded-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={declineInvite}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Avatar } from "@calcom/ui/v2";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
@@ -13,46 +10,46 @@ type TeamType = TeamPageProps["team"];
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number];
|
||||
|
||||
const Team = ({ team }: TeamPageProps) => {
|
||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const Member = ({ member }: { member: MemberType }) => {
|
||||
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 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 || ""}
|
||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||
/>
|
||||
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
||||
<p className="font-medium text-neutral-900 dark:text-white">{member.name}</p>
|
||||
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-neutral-500 dark:text-white">
|
||||
{member.bio || t("user_from_team", { user: member.name, team: team.name })}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
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 ">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={member.name || ""}
|
||||
imageSrc={WEBAPP_URL + "/" + member.username + "/avatar.png"}
|
||||
/>
|
||||
<section className="line-clamp-4 mt-2 w-full space-y-1">
|
||||
<p className="font-medium text-neutral-900 dark:text-white">{member.name}</p>
|
||||
<p className="line-clamp-3 overflow-ellipsis text-sm font-normal text-neutral-500 dark:text-white">
|
||||
{member.bio || t("user_from_team", { user: member.name, team: teamName })}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const Members = ({ members }: { members: MembersType }) => {
|
||||
if (!members || members.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
||||
{members.map((member) => {
|
||||
return member.username !== null && <Member key={member.id} member={member} />;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
const Members = ({ members, teamName }: { members: MembersType; teamName: string | null }) => {
|
||||
if (!members || members.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="lg:min-w-lg mx-auto flex min-w-full max-w-5xl flex-wrap justify-center gap-x-6 gap-y-6">
|
||||
{members.map((member) => {
|
||||
return member.username !== null && <Member key={member.id} member={member} teamName={teamName} />;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const Team = ({ team }: TeamPageProps) => {
|
||||
return (
|
||||
<div>
|
||||
<Members members={team.members} />
|
||||
<Members members={team.members} teamName={team.name} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import React, { useState } from "react";
|
||||
import { ControllerRenderProps } from "react-hook-form";
|
||||
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
const EditableHeading = ({
|
||||
title,
|
||||
const EditableHeading = function EditableHeading({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "",
|
||||
readOnly = false,
|
||||
isReady,
|
||||
...passThroughProps
|
||||
}: {
|
||||
title: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
}) => {
|
||||
isReady?: boolean;
|
||||
} & Omit<JSX.IntrinsicElements["input"], "name" | "onChange"> &
|
||||
ControllerRenderProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const enableEditing = () => !readOnly && setIsEditing(true);
|
||||
const enableEditing = () => setIsEditing(true);
|
||||
return (
|
||||
<div className="group relative cursor-pointer" onClick={enableEditing}>
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<h1
|
||||
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
|
||||
className="inline pl-0 normal-case text-gray-900 focus:text-black group-hover:text-gray-500">
|
||||
{title}
|
||||
</h1>
|
||||
{!readOnly ? (
|
||||
<Icon.FiEdit2 className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ marginBottom: -11 }}>
|
||||
<div className="flex items-center">
|
||||
<label className="min-w-8 relative inline-block">
|
||||
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value} </span>
|
||||
{!isEditing && isReady && (
|
||||
<Icon.FiEdit2 className="ml-1 inline h-4 w-4 align-top text-gray-700 group-hover:text-gray-500" />
|
||||
)}
|
||||
<input
|
||||
{...passThroughProps}
|
||||
type="text"
|
||||
autoFocus
|
||||
style={{ top: -6, fontSize: 22 }}
|
||||
value={value}
|
||||
required
|
||||
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
|
||||
placeholder={placeholder}
|
||||
defaultValue={title}
|
||||
className={classNames(
|
||||
"absolute top-0 left-0 w-full cursor-pointer border-none bg-transparent p-0 align-top text-xl text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
|
||||
)}
|
||||
onFocus={(e) => {
|
||||
setIsEditing(true);
|
||||
passThroughProps.onFocus && passThroughProps.onFocus(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsEditing(false);
|
||||
onChange && onChange(e.target.value);
|
||||
passThroughProps.onBlur && passThroughProps.onBlur(e);
|
||||
}}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
|
||||
@@ -43,39 +48,36 @@ const DropdownActions = ({
|
||||
) : (
|
||||
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
|
||||
)}
|
||||
<DropdownMenuContent portalled>
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none font-normal"
|
||||
href={action.href}
|
||||
StartIcon={action.icon}
|
||||
onClick={action.onClick || defaultAction}
|
||||
data-testid={action.id}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none font-normal"
|
||||
href={action.href}
|
||||
StartIcon={action.icon}
|
||||
onClick={action.onClick || defaultAction}
|
||||
data-testid={action.id}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const TableActions: FC<Props> = ({ actions }) => {
|
||||
const mobileActions = actions.flatMap((action) => {
|
||||
if (action.actions) {
|
||||
return action.actions;
|
||||
}
|
||||
return action;
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="hidden space-x-2 rtl:space-x-reverse lg:flex">
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
{actions.map((action) => {
|
||||
const button = (
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
key={action.id}
|
||||
data-testid={action.id}
|
||||
href={action.href}
|
||||
@@ -93,9 +95,6 @@ const TableActions: FC<Props> = ({ actions }) => {
|
||||
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="inline-block text-left lg:hidden">
|
||||
<DropdownActions actions={mobileActions} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,14 +5,15 @@ import { MutableRefObject, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getPremiumPlanMode, getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { fetchUsername } from "@calcom/lib/fetchUsername";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { User } from "@calcom/prisma/client";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Icon, StarIconSolid } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
import { Input, Label } from "@calcom/ui/v2";
|
||||
|
||||
export enum UsernameChangeStatusEnum {
|
||||
@@ -25,7 +26,7 @@ interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername: (value: string | undefined) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
||||
usernameRef: MutableRefObject<HTMLInputElement | null>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
@@ -43,9 +44,8 @@ interface ICustomUsernameProps {
|
||||
| "plan"
|
||||
| "brandColor"
|
||||
| "darkBrandColor"
|
||||
| "metadata"
|
||||
| "timeFormat"
|
||||
| "allowDynamicBooking"
|
||||
| "metadata"
|
||||
>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
@@ -62,24 +62,13 @@ const obtainNewUsernameChangeCondition = ({
|
||||
if (!userIsPremium && isNewUsernamePremium && !stripeCustomer?.paidForPremium) {
|
||||
return UsernameChangeStatusEnum.UPGRADE;
|
||||
}
|
||||
|
||||
if (userIsPremium && !isNewUsernamePremium && getPremiumPlanMode() === "subscription") {
|
||||
return UsernameChangeStatusEnum.DOWNGRADE;
|
||||
}
|
||||
return UsernameChangeStatusEnum.NORMAL;
|
||||
};
|
||||
|
||||
const useIsUsernamePremium = (username: string) => {
|
||||
const [isCurrentUsernamePremium, setIsCurrentUsernamePremium] = useState(false);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!username) return;
|
||||
const { data } = await fetchUsername(username);
|
||||
setIsCurrentUsernamePremium(data.premium);
|
||||
})();
|
||||
}, [username]);
|
||||
return isCurrentUsernamePremium;
|
||||
};
|
||||
|
||||
const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
@@ -91,6 +80,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
onSuccessMutation,
|
||||
onErrorMutation,
|
||||
readonly: disabled,
|
||||
user,
|
||||
} = props;
|
||||
const [usernameIsAvailable, setUsernameIsAvailable] = useState(false);
|
||||
const [markAsError, setMarkAsError] = useState(false);
|
||||
@@ -98,13 +88,13 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
const { paymentStatus: recentAttemptPaymentStatus } = router.query;
|
||||
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
|
||||
const { data: stripeCustomer } = trpc.useQuery(["viewer.stripeCustomer"]);
|
||||
const isCurrentUsernamePremium = useIsUsernamePremium(currentUsername || "");
|
||||
const isCurrentUsernamePremium =
|
||||
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
|
||||
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
|
||||
|
||||
const debouncedApiCall = useCallback(
|
||||
debounce(async (username) => {
|
||||
debounce(async (username: string) => {
|
||||
const { data } = await fetchUsername(username);
|
||||
setMarkAsError(!data.available && username !== currentUsername);
|
||||
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
|
||||
setIsInputUsernamePremium(data.premium);
|
||||
setUsernameIsAvailable(data.available);
|
||||
}, 150),
|
||||
@@ -183,7 +173,9 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
usernameRef.current.value = currentUsername;
|
||||
if (usernameRef.current) {
|
||||
usernameRef.current.value = currentUsername;
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
@@ -202,6 +194,20 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
let paymentMsg = !currentUsername ? (
|
||||
<span className="text-xs text-orange-400">
|
||||
You need to reserve your premium username for {getPremiumPlanPriceValue()}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid") {
|
||||
paymentMsg = (
|
||||
<span className="text-sm text-red-500">
|
||||
Your payment could not be completed. Your username is still not reserved
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-items-center">
|
||||
@@ -211,7 +217,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
<span
|
||||
className={classNames(
|
||||
isInputUsernamePremium ? "border-1 border-orange-400 " : "",
|
||||
"hidden items-center rounded-l-md border border-r-0 border-gray-300 border-r-gray-300 bg-gray-50 px-3 text-sm text-gray-500 md:inline-flex"
|
||||
"hidden h-9 items-center rounded-l-md border border-r-0 border-gray-300 border-r-gray-300 bg-gray-50 px-3 text-sm text-gray-500 md:inline-flex"
|
||||
)}>
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL.replace("https://", "").replace("http://", "")}/
|
||||
</span>
|
||||
@@ -237,6 +243,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
value={inputUsernameValue}
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
// Reset payment status
|
||||
delete router.query.paymentStatus;
|
||||
setInputUsernameValue(event.target.value);
|
||||
}}
|
||||
data-testid="username-input"
|
||||
@@ -248,7 +256,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
isInputUsernamePremium ? "text-orange-400" : "",
|
||||
usernameIsAvailable ? "" : ""
|
||||
)}>
|
||||
{isInputUsernamePremium ? <StarIconSolid className="mt-[4px] w-6" /> : <></>}
|
||||
{isInputUsernamePremium ? <StarIconSolid className="mt-[2px] w-6" /> : <></>}
|
||||
{!isInputUsernamePremium && usernameIsAvailable ? <Icon.FiCheck className="mt-2 w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
@@ -260,17 +268,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{paymentRequired ? (
|
||||
recentAttemptPaymentStatus && recentAttemptPaymentStatus !== "paid" ? (
|
||||
<span className="text-sm text-red-500">
|
||||
Your payment could not be completed. Your username is still not reserved
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-orange-400">
|
||||
You need to reserve your premium username for {getPremiumPlanPriceValue()}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
{paymentMsg}
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && (
|
||||
|
||||
@@ -7,16 +7,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader } from "@calcom/ui/Dialog";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Input, Label } from "@calcom/ui/v2";
|
||||
import { Input, Label, Button } from "@calcom/ui/v2";
|
||||
|
||||
interface ICustomUsernameProps {
|
||||
currentUsername: string | undefined;
|
||||
setCurrentUsername: (value: string | undefined) => void;
|
||||
inputUsernameValue: string | undefined;
|
||||
usernameRef: MutableRefObject<HTMLInputElement>;
|
||||
usernameRef: MutableRefObject<HTMLInputElement | null>;
|
||||
setInputUsernameValue: (value: string) => void;
|
||||
onSuccessMutation?: () => void;
|
||||
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
|
||||
@@ -73,15 +72,14 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const ActionButtons = (props: { index: string }) => {
|
||||
const { index } = props;
|
||||
const ActionButtons = () => {
|
||||
return usernameIsAvailable && currentUsername !== inputUsernameValue ? (
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="mx-2"
|
||||
onClick={() => setOpenDialogSaveUsername(true)}
|
||||
data-testid={`update-username-btn-${index}`}>
|
||||
data-testid="update-username-btn">
|
||||
{t("update")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -91,7 +89,9 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
onClick={() => {
|
||||
if (currentUsername) {
|
||||
setInputUsernameValue(currentUsername);
|
||||
usernameRef.current.value = currentUsername;
|
||||
if (usernameRef.current) {
|
||||
usernameRef.current.value = currentUsername;
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{t("cancel")}
|
||||
@@ -122,7 +122,7 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
||||
"mb-0 mt-0 h-6 rounded-md rounded-l-none",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
@@ -137,20 +137,20 @@ const UsernameTextfield = (props: ICustomUsernameProps) => {
|
||||
{currentUsername !== inputUsernameValue && (
|
||||
<div className="absolute right-[2px] top-0 flex flex-row">
|
||||
<span className={classNames("mx-2 py-2")}>
|
||||
{usernameIsAvailable ? <Icon.FiCheck className="mt-[4px] w-6" /> : <></>}
|
||||
{usernameIsAvailable ? <Icon.FiCheck className="mt-[2px] w-6" /> : <></>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:inline">
|
||||
<ActionButtons index="desktop" />
|
||||
<ActionButtons />
|
||||
</div>
|
||||
</div>
|
||||
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
|
||||
|
||||
{usernameIsAvailable && currentUsername !== inputUsernameValue && (
|
||||
<div className="mt-2 flex justify-end sm:hidden">
|
||||
<ActionButtons index="mobile" />
|
||||
<div className="mt-2 flex justify-end md:hidden">
|
||||
<ActionButtons />
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={openDialogSaveUsername}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import Select from "react-select";
|
||||
import { OptionProps } from "react-select";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
@@ -8,6 +7,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Select } from "@calcom/ui/v2";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
|
||||
@@ -65,23 +65,7 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
|
||||
name="additionalCalendar"
|
||||
placeholder={t("install_another")}
|
||||
options={options}
|
||||
styles={{
|
||||
placeholder: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
color: "#3E3E3E",
|
||||
marginLeft: "3px",
|
||||
};
|
||||
},
|
||||
control: (defaultStyles) => {
|
||||
return {
|
||||
...defaultStyles,
|
||||
borderRadius: "6px",
|
||||
};
|
||||
},
|
||||
}}
|
||||
isSearchable={false}
|
||||
className="min-w-52 block w-full flex-1 rounded-none rounded-r-sm border-gray-300 text-sm font-medium text-gray-700"
|
||||
isLoading={isLoading}
|
||||
components={{ Option: ImageOption }}
|
||||
/>
|
||||
|
||||
+206
-219
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
@@ -9,9 +9,8 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { App as AppType } from "@calcom/types/App";
|
||||
import Badge from "@calcom/ui/Badge";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { showToast } from "@calcom/ui/v2";
|
||||
import { showToast, SkeletonText } from "@calcom/ui/v2";
|
||||
import { Button, SkeletonButton, Shell } from "@calcom/ui/v2";
|
||||
import DisconnectIntegration from "@calcom/ui/v2/modules/integrations/DisconnectIntegration";
|
||||
|
||||
@@ -37,7 +36,6 @@ const Component = ({
|
||||
images,
|
||||
}: Parameters<typeof App>[0]) => {
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
||||
const hasImages = images && images.length > 0;
|
||||
const router = useRouter();
|
||||
|
||||
@@ -66,226 +64,213 @@ const Component = ({
|
||||
const allowedMultipleInstalls = categories.indexOf("calendar") > -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 pb-3 pt-3 md:px-8 lg:px-0 lg:pt-0">
|
||||
<Link href="/apps">
|
||||
<a className="text-md hover:text-brand-400 inline-flex rounded-sm py-2 font-semibold text-gray-900">
|
||||
<Icon.FiArrowLeft className="mr-2 h-6 w-6" /> {t("app_store")}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasImages && (
|
||||
<div className="flex-2 mb-4 -ml-4 -mr-4 flex w-auto snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:rounded-md">
|
||||
{images &&
|
||||
images.map((img) => (
|
||||
<img
|
||||
key={img}
|
||||
src={img}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 -mt-4 max-w-xl flex-1 pb-12 text-sm lg:pb-0",
|
||||
hasImages && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex pt-4">
|
||||
<header>
|
||||
<div className="mb-4 flex items-center">
|
||||
<img className="min-h-16 min-w-16 h-16 w-16" src={logo} alt={name} />
|
||||
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-gray-600">
|
||||
<Link href={`categories/${categories[0]}`}>
|
||||
<a className="rounded-md bg-gray-100 p-1 text-xs capitalize text-gray-800">
|
||||
{categories[0]}
|
||||
</a>
|
||||
</Link>{" "}
|
||||
• {t("published_by", { author })}
|
||||
</h2>
|
||||
</header>
|
||||
</div>
|
||||
{!appCredentials.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("default")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
StartIcon={Icon.FiPlus}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base"
|
||||
data-testid="install-app-button">
|
||||
{t("install_another")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
<DisconnectIntegration
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
router.replace("/apps/installed");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
|
||||
{hasImages && (
|
||||
<div className="flex-2 mb-4 -ml-4 -mr-4 flex min-h-[450px] w-auto snap-x snap-mandatory flex-row overflow-auto whitespace-nowrap bg-gray-100 p-4 md:mb-8 md:-ml-8 md:-mr-8 md:p-8 lg:mx-0 lg:mb-0 lg:max-w-2xl lg:flex-col lg:rounded-md">
|
||||
{images ? (
|
||||
images.map((img) => (
|
||||
<img
|
||||
key={img}
|
||||
src={img}
|
||||
alt={`Screenshot of app ${name}`}
|
||||
className="mr-4 h-auto max-h-80 max-w-[90%] snap-center rounded-md object-contain last:mb-0 md:max-h-min lg:mb-4 lg:mr-0 lg:max-w-full"
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<SkeletonButton className="h-10 w-24" />
|
||||
<SkeletonText />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="prose prose-sm mt-8 space-x-2">{body}</div>
|
||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||
<span>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h4 className="mt-8 mb-2 font-semibold text-gray-900 ">{t("learn_more")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-normal text-black no-underline hover:underline"
|
||||
href={docs}>
|
||||
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4 text-gray-500" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={website}>
|
||||
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={tos}>
|
||||
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-8" />
|
||||
<span className="leading-1 block text-xs text-gray-500">
|
||||
Every app published on the Cal.com App Store is open source and thoroughly tested via peer
|
||||
reviews. Nevertheless, Cal.com, Inc. does not endorse or certify these apps unless they are
|
||||
published by Cal.com. If you encounter inappropriate content or behaviour please report it.
|
||||
</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<Icon.FiFlag className="inline h-3 w-3" /> Report App
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"sticky top-0 -mt-4 max-w-xl flex-1 pb-12 text-sm lg:pb-0",
|
||||
hasImages && "lg:ml-8"
|
||||
)}>
|
||||
<div className="mb-8 flex pt-4">
|
||||
<header>
|
||||
<div className="mb-4 flex items-center">
|
||||
<img className="min-h-16 min-w-16 h-16 w-16" src={logo} alt={name} />
|
||||
<h1 className="font-cal ml-4 text-3xl text-gray-900">{name}</h1>
|
||||
</div>
|
||||
<h2 className="text-sm font-medium text-gray-600">
|
||||
<Link href={`categories/${categories[0]}`}>
|
||||
<a className="rounded-md bg-gray-100 p-1 text-xs capitalize text-gray-800">{categories[0]}</a>
|
||||
</Link>{" "}
|
||||
• {t("published_by", { author })}
|
||||
</h2>
|
||||
</header>
|
||||
</div>
|
||||
{!appCredentials.isLoading ? (
|
||||
isGlobal ||
|
||||
(existingCredentials.length > 0 && allowedMultipleInstalls ? (
|
||||
<div className="flex space-x-3">
|
||||
<Button StartIcon={Icon.FiCheck} color="secondary" disabled>
|
||||
{existingCredentials.length > 0
|
||||
? t("active_install", { count: existingCredentials.length })
|
||||
: t("default")}
|
||||
</Button>
|
||||
{!isGlobal && (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
StartIcon={Icon.FiPlus}
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base"
|
||||
data-testid="install-app-button">
|
||||
{t("install_another")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : existingCredentials.length > 0 ? (
|
||||
<DisconnectIntegration
|
||||
label={t("disconnect")}
|
||||
credentialId={existingCredentials[0]}
|
||||
onSuccess={() => {
|
||||
router.replace("/apps/installed");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InstallAppButton
|
||||
type={type}
|
||||
isProOnly={isProOnly}
|
||||
render={({ useDefaultComponent, ...props }) => {
|
||||
if (useDefaultComponent) {
|
||||
props = {
|
||||
onClick: () => {
|
||||
mutation.mutate({ type, variant, slug });
|
||||
},
|
||||
loading: mutation.isLoading,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
data-testid="install-app-button"
|
||||
{...props}
|
||||
// @TODO: Overriding color and size prevent us from
|
||||
// having to duplicate InstallAppButton for now.
|
||||
color="primary"
|
||||
size="base">
|
||||
{t("install_app")}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<SkeletonButton className="h-10 w-24" />
|
||||
)}
|
||||
{price !== 0 && (
|
||||
<span className="block text-right">
|
||||
{feeType === "usage-based" ? commission + "% + " + priceInDollar + "/booking" : priceInDollar}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="prose prose-sm mt-8">{body}</div>
|
||||
<h4 className="mt-8 font-semibold text-gray-900 ">{t("pricing")}</h4>
|
||||
<span>
|
||||
{price === 0 ? (
|
||||
"Free"
|
||||
) : (
|
||||
<>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
useGrouping: false,
|
||||
}).format(price)}
|
||||
{feeType === "monthly" && "/" + t("month")}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h4 className="mt-8 mb-2 font-semibold text-gray-900 ">{t("learn_more")}</h4>
|
||||
<ul className="prose-sm -ml-1 -mr-1 leading-5">
|
||||
{docs && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-normal text-black no-underline hover:underline"
|
||||
href={docs}>
|
||||
<Icon.FiBookOpen className="mr-1 -mt-1 inline h-4 w-4 text-gray-500" />
|
||||
{t("documentation")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{website && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={website}>
|
||||
<Icon.FiExternalLink className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
{website.replace("https://", "")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{email && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={"mailto:" + email}>
|
||||
<Icon.FiMail className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{tos && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={tos}>
|
||||
<Icon.FiFile className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
{t("terms_of_service")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
{privacy && (
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-normal text-black no-underline hover:underline"
|
||||
href={privacy}>
|
||||
<Icon.FiShield className="mr-1 -mt-px inline h-4 w-4 text-gray-500" />
|
||||
{t("privacy_policy")}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<hr className="my-8" />
|
||||
<span className="leading-1 block text-xs text-gray-500">{t("every_app_published")}</span>
|
||||
<a className="mt-2 block text-xs text-red-500" href="mailto:help@cal.com">
|
||||
<Icon.FiFlag className="inline h-3 w-3" /> {t("report_app")}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -312,8 +297,10 @@ export default function App(props: {
|
||||
isProOnly: AppType["isProOnly"];
|
||||
images?: string[];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell large isPublic>
|
||||
<Shell large isPublic heading={t("app_store")} backPath="/apps">
|
||||
{props.licenseRequired ? (
|
||||
<LicenseRequired>
|
||||
<Component {...props} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -153,7 +154,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List className="flex flex-col gap-6">
|
||||
<List className="flex flex-col gap-6" noBorderTreatment>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
@@ -163,7 +164,7 @@ function ConnectedCalendarsList(props: Props) {
|
||||
logo={item.integration.logo}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
actions={
|
||||
<div className="w-32">
|
||||
<div className="flex w-32 justify-end">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
@@ -193,10 +194,16 @@ function ConnectedCalendarsList(props: Props) {
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title={t("calendar_error")}
|
||||
message={item.error?.message}
|
||||
title={t("something_went_wrong")}
|
||||
message={
|
||||
<span>
|
||||
<Link href={"/apps/" + item.integration.slug}>{item.integration.name}</Link>:{" "}
|
||||
{t("calendar_error")}
|
||||
</span>
|
||||
}
|
||||
iconClassName="h-10 w-10 ml-2 mr-1 mt-0.5"
|
||||
actions={
|
||||
<div className="w-32">
|
||||
<div className="flex w-32 justify-end md:pr-1">
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
@@ -251,7 +258,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
<ShellSubHeading
|
||||
title={t("calendar")}
|
||||
subtitle={t("installed_app_calendar_description")}
|
||||
className="flex items-center"
|
||||
className="mb-0 flex flex-wrap items-center gap-4 md:mb-3 md:gap-0"
|
||||
actions={
|
||||
<div className="flex flex-col xl:flex-row xl:space-x-5">
|
||||
{!!data.connectedCalendars.length && (
|
||||
@@ -263,7 +270,7 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between rounded-md border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="flex w-full flex-col items-start gap-4 md:flex-row md:items-center">
|
||||
<div className="relative rounded-md border border-gray-200 bg-white p-1.5">
|
||||
<Icon.FiCalendar className="h-8 w-8" strokeWidth="1" />
|
||||
<Icon.FiPlus
|
||||
@@ -271,11 +278,11 @@ export function CalendarListContainer(props: { heading?: boolean; fromOnboarding
|
||||
strokeWidth="4"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-6/12">
|
||||
<div className="md:w-6/12">
|
||||
<h1 className="text-sm font-semibold">{t("create_events_on")}</h1>
|
||||
<p className="text-sm font-normal">{t("set_calendar")}</p>
|
||||
</div>
|
||||
<div className="flex w-6/12 justify-end">
|
||||
<div className="flex justify-end md:w-6/12">
|
||||
<DestinationCalendarSelector
|
||||
onChange={mutation.mutate}
|
||||
hidePlaceholder
|
||||
|
||||
@@ -51,7 +51,7 @@ const DestinationCalendarSelector = ({
|
||||
if (selected) {
|
||||
setSelectedOption({
|
||||
value: `${selected.integration}:${selected.externalId}`,
|
||||
label: `<span class="font-semibold">${selected.name}</span> (${selected.externalId})` || "",
|
||||
label: `${selected.name} (${selected.externalId})` || "",
|
||||
});
|
||||
}
|
||||
}, [query.data?.connectedCalendars, value]);
|
||||
@@ -66,7 +66,7 @@ const DestinationCalendarSelector = ({
|
||||
options: (selectedCalendar.calendars ?? [])
|
||||
.filter((cal) => cal.readOnly === false)
|
||||
.map((cal) => ({
|
||||
label: `<span class="font-semibold">${cal.name}</span> (${cal.externalId})` || "",
|
||||
label: `${cal.name} (${cal.externalId})` || "",
|
||||
value: `${cal.integration}:${cal.externalId}`,
|
||||
})),
|
||||
})) ?? [];
|
||||
@@ -83,9 +83,6 @@ const DestinationCalendarSelector = ({
|
||||
title={`${t("select_destination_calendar")}: ${selectedOption?.label || ""}`}>
|
||||
<Select
|
||||
name="primarySelectedCalendar"
|
||||
formatOptionLabel={function (data) {
|
||||
return <span dangerouslySetInnerHTML={{ __html: data.label }} />;
|
||||
}}
|
||||
placeholder={
|
||||
!hidePlaceholder ? (
|
||||
`${t("select_destination_calendar")}`
|
||||
|
||||
@@ -32,7 +32,7 @@ function IntegrationListItem(props: {
|
||||
<ListItem
|
||||
expanded={!!props.children}
|
||||
className={classNames(
|
||||
"my-0 flex-col rounded-none border-0 transition-colors duration-500",
|
||||
"my-0 flex-col border transition-colors duration-500 first:rounded-t-md last:rounded-b-md",
|
||||
highlight ? "bg-yellow-100" : ""
|
||||
)}>
|
||||
<div className={classNames("flex w-full flex-1 items-center space-x-2 p-4 rtl:space-x-reverse")}>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
import { SkeletonText } from "@calcom/ui/v2";
|
||||
@@ -32,9 +33,13 @@ function SkeletonItem() {
|
||||
);
|
||||
}
|
||||
|
||||
export const AvailabilitySelectSkeletonLoader = () => {
|
||||
export const SelectSkeletonLoader = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<li className="group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3">
|
||||
<li
|
||||
className={classNames(
|
||||
"group flex w-full items-center justify-between rounded-sm border border-gray-200 px-[10px] py-3",
|
||||
className
|
||||
)}>
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
|
||||
@@ -8,10 +8,10 @@ import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import Select from "@calcom/ui/v2/core/form/Select";
|
||||
import Select from "@calcom/ui/v2/core/form/select";
|
||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
import { AvailabilitySelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
|
||||
import { SelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
|
||||
|
||||
type AvailabilityOption = {
|
||||
label: string;
|
||||
@@ -30,7 +30,7 @@ const AvailabilitySelect = ({
|
||||
}) => {
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.availability.list"]);
|
||||
if (isLoading) {
|
||||
return <AvailabilitySelectSkeletonLoader />;
|
||||
return <SelectSkeletonLoader />;
|
||||
}
|
||||
|
||||
const schedules = data?.schedules || [];
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DialogContent,
|
||||
Label,
|
||||
showToast,
|
||||
Skeleton,
|
||||
Switch,
|
||||
TextField,
|
||||
Tooltip,
|
||||
@@ -37,10 +38,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
const connectedCalendarsQuery = trpc.useQuery(["viewer.connectedCalendars"]);
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const [showEventNameTip, setShowEventNameTip] = useState(false);
|
||||
const [hashedLinkVisible, setHashedLinkVisible] = useState(!!eventType.hashedLink);
|
||||
const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!eventType.successRedirectUrl);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
const [seatsInputVisible, setSeatsInputVisible] = useState(!!eventType.seatsPerTimeSlot);
|
||||
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(
|
||||
eventType.customInputs.sort((a, b) => a.id - b.id) || []
|
||||
);
|
||||
@@ -49,7 +51,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
|
||||
const animationRef = useRef(null);
|
||||
const seatsEnabled = !!eventType.seatsPerTimeSlot;
|
||||
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
|
||||
|
||||
useEffect(() => {
|
||||
animationRef.current && autoAnimate(animationRef.current);
|
||||
@@ -136,8 +138,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm font-semibold leading-none text-black">{t("additional_inputs")}</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("additional_input_description")}</p>
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("additional_inputs")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("additional_input_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="my-4" ref={animationRef}>
|
||||
@@ -182,12 +188,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
fitToHeight={true}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm font-semibold leading-none text-black">
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("requires_confirmation")}
|
||||
</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("requires_confirmation_description")}
|
||||
</p>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -207,8 +213,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
disabled={seatsEnabled}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm font-semibold leading-none text-black">{t("disable_guests")}</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("disable_guests_description")}</p>
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("disable_guests")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("disable_guests_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -228,13 +238,59 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm font-semibold leading-none text-black">{t("disable_notes")}</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("disable_notes_description")}</p>
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("disable_notes")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("disable_notes_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
control={formMethods.control}
|
||||
defaultValue={hashedUrl}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
<div className="flex space-x-3 ">
|
||||
<Switch
|
||||
name="successRedirectUrlCheck"
|
||||
fitToHeight={true}
|
||||
defaultChecked={redirectUrlVisible}
|
||||
onCheckedChange={(e) => {
|
||||
setRedirectUrlVisible(e);
|
||||
onChange(e ? value : "");
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("redirect_success_booking")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("redirect_url_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
{redirectUrlVisible && (
|
||||
<div className="">
|
||||
<TextField
|
||||
label={t("redirect_success_booking")}
|
||||
placeholder={t("external_redirect_url")}
|
||||
required={redirectUrlVisible}
|
||||
type="text"
|
||||
defaultValue={eventType.successRedirectUrl || ""}
|
||||
{...formMethods.register("successRedirectUrl")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="hashedLink"
|
||||
control={formMethods.control}
|
||||
@@ -253,8 +309,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm font-semibold leading-none text-black">{t("private_link")}</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("private_link_description")}</p>
|
||||
<Skeleton as={Label} className="text-sm font-semibold leading-none text-black">
|
||||
{t("private_link")}
|
||||
</Skeleton>
|
||||
<Skeleton as="p" className="-mt-2 text-sm leading-normal text-gray-600">
|
||||
{t("private_link_description")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -293,6 +353,59 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<hr />
|
||||
<Controller
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
control={formMethods.control}
|
||||
defaultValue={!!eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex space-x-3">
|
||||
<Switch
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
checked={value}
|
||||
onCheckedChange={(e) => {
|
||||
setSeatsInputVisible(e);
|
||||
// Enabling seats will disable guests and requiring confimation until fully supported
|
||||
if (e) {
|
||||
formMethods.setValue("disableGuests", true);
|
||||
formMethods.setValue("requiresConfirmation", false);
|
||||
formMethods.setValue("seatsPerTimeSlot", 2);
|
||||
} else {
|
||||
formMethods.setValue("seatsPerTimeSlot", null);
|
||||
}
|
||||
onChange(e);
|
||||
}}
|
||||
fitToHeight={true}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-sm font-semibold leading-none text-black">{t("offer_seats")}</Label>
|
||||
<p className="-mt-2 text-sm leading-normal text-gray-600">{t("offer_seats_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{seatsInputVisible && (
|
||||
<Controller
|
||||
name="seatsPerTimeSlot"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.seatsPerTimeSlot}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="">
|
||||
<TextField
|
||||
required
|
||||
name="seatsPerTimeSlot"
|
||||
label={t("number_of_seats")}
|
||||
type="number"
|
||||
defaultValue={value || 2}
|
||||
addOnSuffix={<>{t("seats")}</>}
|
||||
onChange={(e) => {
|
||||
onChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showEventNameTip && (
|
||||
<Dialog open={showEventNameTip} onOpenChange={setShowEventNameTip}>
|
||||
<DialogContent
|
||||
|
||||
@@ -27,13 +27,13 @@ const AppCard = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-4 rounded-md border border-gray-200 p-8">
|
||||
<div className="mb-6 flex w-full">
|
||||
<div className="flex w-full">
|
||||
<img src={logo} alt={name} className="mr-3 h-auto w-[42px] rounded-sm" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold leading-none text-black">{name}</span>
|
||||
<p className="pt-2 text-sm font-normal text-gray-600">{description}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center">
|
||||
<Switch onCheckedChange={switchOnClick} checked={switchChecked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useFormContext, Controller, useWatch } from "react-hook-form";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PeriodType } from "@calcom/prisma/client";
|
||||
import { Select, Switch, Label, Input } from "@calcom/ui/v2";
|
||||
import { Select, Switch, Label, Input, MinutesField } from "@calcom/ui/v2";
|
||||
import DateRangePicker from "@calcom/ui/v2/core/form/date-range-picker/DateRangePicker";
|
||||
|
||||
export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">) => {
|
||||
@@ -113,35 +113,12 @@ export const EventLimitsTab = (props: Pick<EventTypeSetupInfered, "eventType">)
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 pt-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
||||
<Controller
|
||||
name="minimumBookingNotice"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.minimumBookingNotice || 0}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const minimumBookingOptions = [
|
||||
{
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
label: minutes + " " + t("minutes"),
|
||||
value: minutes,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
defaultValue={
|
||||
minimumBookingOptions.find((option) => option.value === value) || minimumBookingOptions[0]
|
||||
}
|
||||
options={minimumBookingOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
<MinutesField
|
||||
required
|
||||
label={t("minimum_booking_notice")}
|
||||
type="number"
|
||||
placeholder="120"
|
||||
{...formMethods.register("minimumBookingNotice", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { getEventLocationType, EventLocationType } from "@calcom/app-store/locat
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Select, Label, TextField, Button } from "@calcom/ui/v2";
|
||||
import { Select, Label, TextField, Button, Skeleton } from "@calcom/ui/v2";
|
||||
|
||||
import { slugify } from "@lib/slugify";
|
||||
|
||||
@@ -222,7 +222,9 @@ export const EventSetupTab = (
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Label>{t("location")}</Label>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
{t("location")}
|
||||
</Skeleton>
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { EventTypeSetupInfered, FormValues } from "pages/v2/event-types/[type]";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Loader } from "react-feather";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
@@ -9,20 +10,18 @@ import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { trpc, TRPCClientError } from "@calcom/trpc/react";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Tooltip,
|
||||
showToast,
|
||||
VerticalTabItemProps,
|
||||
VerticalTabs,
|
||||
HorizontalTabs,
|
||||
Switch,
|
||||
Label,
|
||||
HorizontalTabItemProps,
|
||||
showToast,
|
||||
Switch,
|
||||
Tooltip,
|
||||
VerticalTabs,
|
||||
} from "@calcom/ui/v2";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import { Dialog } from "@calcom/ui/v2/core/Dialog";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
@@ -31,6 +30,7 @@ import Dropdown, {
|
||||
} from "@calcom/ui/v2/core/Dropdown";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
||||
import { Skeleton } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
import { ClientSuspense } from "@components/ClientSuspense";
|
||||
import { EmbedButton, EmbedDialog } from "@components/Embed";
|
||||
@@ -46,6 +46,59 @@ type Props = {
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
};
|
||||
|
||||
function getNavigation(props: {
|
||||
t: TFunction;
|
||||
eventType: Props["eventType"];
|
||||
enabledAppsNumber: number;
|
||||
enabledWorkflowsNumber: number;
|
||||
}) {
|
||||
const { eventType, t, enabledAppsNumber, enabledWorkflowsNumber } = props;
|
||||
return [
|
||||
{
|
||||
name: "event_setup_tab_title",
|
||||
href: `/event-types/${eventType.id}?tabName=setup`,
|
||||
icon: Icon.FiLink,
|
||||
info: `${eventType.length} Mins`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "availability",
|
||||
href: `/event-types/${eventType.id}?tabName=availability`,
|
||||
icon: Icon.FiCalendar,
|
||||
info: `Working Hours`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "event_limit_tab_title",
|
||||
href: `/event-types/${eventType.id}?tabName=limits`,
|
||||
icon: Icon.FiClock,
|
||||
info: `event_limit_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "event_advanced_tab_title",
|
||||
href: `/event-types/${eventType.id}?tabName=advanced`,
|
||||
icon: Icon.FiSliders,
|
||||
info: `event_advanced_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: `/event-types/${eventType.id}?tabName=recurring`,
|
||||
icon: Icon.FiRepeat,
|
||||
info: `recurring_event_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "apps",
|
||||
href: `/event-types/${eventType.id}?tabName=apps`,
|
||||
icon: Icon.FiGrid,
|
||||
info: `${enabledAppsNumber} ${t("active")}`,
|
||||
},
|
||||
{
|
||||
name: "workflows",
|
||||
href: `/event-types/${eventType.id}?tabName=workflows`,
|
||||
icon: Icon.FiZap,
|
||||
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function EventTypeSingleLayout({
|
||||
children,
|
||||
eventType,
|
||||
@@ -57,8 +110,8 @@ function EventTypeSingleLayout({
|
||||
formMethods,
|
||||
}: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
|
||||
@@ -67,6 +120,7 @@ function EventTypeSingleLayout({
|
||||
onSuccess: async () => {
|
||||
await utils.invalidateQueries(["viewer.eventTypes"]);
|
||||
showToast(t("event_type_deleted_successfully"), "success");
|
||||
await router.push("/event-types");
|
||||
setDeleteDialogOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -82,73 +136,17 @@ function EventTypeSingleLayout({
|
||||
|
||||
// Define tab navigation here
|
||||
const EventTypeTabs = useMemo(() => {
|
||||
const navigation: (VerticalTabItemProps & HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "event_setup_tab_title",
|
||||
tabName: "setup",
|
||||
icon: Icon.FiLink,
|
||||
info: `${eventType.length} Mins`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "availability",
|
||||
tabName: "availability",
|
||||
icon: Icon.FiCalendar,
|
||||
info: `Working Hours`, // TODO: Get this from props
|
||||
},
|
||||
{
|
||||
name: "event_limit_tab_title",
|
||||
tabName: "limits",
|
||||
icon: Icon.FiClock,
|
||||
info: `event_limit_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "event_advanced_tab_title",
|
||||
tabName: "advanced",
|
||||
icon: Icon.FiSliders,
|
||||
info: `event_advanced_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
tabName: "recurring",
|
||||
icon: Icon.FiRepeat,
|
||||
info: `recurring_event_tab_description`,
|
||||
},
|
||||
{
|
||||
name: "apps",
|
||||
tabName: "apps",
|
||||
icon: Icon.FiGrid,
|
||||
info: `${enabledAppsNumber} ${t("active")}`,
|
||||
},
|
||||
{
|
||||
name: "workflows",
|
||||
tabName: "workflows",
|
||||
icon: Icon.FiZap,
|
||||
info: `${enabledWorkflowsNumber} ${t("active")}`,
|
||||
},
|
||||
];
|
||||
|
||||
const navigation = getNavigation({ t, eventType, enabledAppsNumber, enabledWorkflowsNumber });
|
||||
// If there is a team put this navigation item within the tabs
|
||||
if (team)
|
||||
navigation.splice(2, 0, {
|
||||
name: "scheduling_type",
|
||||
tabName: "team",
|
||||
href: `/event-types/${eventType.id}?tabName=team`,
|
||||
icon: Icon.FiUsers,
|
||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||
});
|
||||
return navigation;
|
||||
}, [eventType, enabledAppsNumber, team]);
|
||||
|
||||
useEffect(() => {
|
||||
// Default to the first in the list
|
||||
if (!router.query.tabName) {
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
tabName: EventTypeTabs[0].tabName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [EventTypeTabs, router]);
|
||||
}, [t, eventType, enabledAppsNumber, enabledWorkflowsNumber, team]);
|
||||
|
||||
const permalink = `${CAL_URL}/${team ? `team/${team.slug}` : eventType.users[0].username}/${
|
||||
eventType.slug
|
||||
@@ -159,15 +157,18 @@ function EventTypeSingleLayout({
|
||||
return (
|
||||
<Shell
|
||||
backPath="/event-types"
|
||||
title={t("event_type_title", { eventTypeTitle: eventType.title })}
|
||||
title={eventType.title + " | " + t("event_type")}
|
||||
heading={eventType.title}
|
||||
subtitle={eventType.description || ""}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center rounded-md px-2 sm:hover:bg-gray-100">
|
||||
<Label htmlFor="hiddenSwitch" className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
|
||||
{t("hide_from_profile")}
|
||||
</Label>
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
defaultChecked={formMethods.getValues("hidden")}
|
||||
@@ -208,6 +209,7 @@ function EventTypeSingleLayout({
|
||||
StartIcon={Icon.FiCode}
|
||||
color="secondary"
|
||||
size="icon"
|
||||
tooltip={t("embed")}
|
||||
/>
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -226,27 +228,29 @@ function EventTypeSingleLayout({
|
||||
<Icon.FiMoreHorizontal className="group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
target="_blank"
|
||||
href={permalink}
|
||||
rel="noreferrer">
|
||||
rel="noreferrer"
|
||||
className="min-w-full">
|
||||
{t("preview")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<Button color="minimal" StartIcon={Icon.FiLink}>
|
||||
{t("copy_link")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem className="focus:ring-gray-100">
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiTrash}
|
||||
disabled={!hasPermsToDelete}
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
className="min-w-full">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
@@ -265,10 +269,15 @@ function EventTypeSingleLayout({
|
||||
<ClientSuspense fallback={<Loader />}>
|
||||
<div className="-mt-2 flex flex-col xl:flex-row xl:space-x-8">
|
||||
<div className="hidden xl:block">
|
||||
<VerticalTabs className="primary-navigation" tabs={EventTypeTabs} sticky />
|
||||
<VerticalTabs
|
||||
className="primary-navigation"
|
||||
tabs={EventTypeTabs}
|
||||
sticky
|
||||
linkProps={{ shallow: true }}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
||||
<HorizontalTabs tabNameKey="tabName" tabs={EventTypeTabs} />
|
||||
<HorizontalTabs tabs={EventTypeTabs} linkProps={{ shallow: true }} />
|
||||
</div>
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||
<div
|
||||
@@ -292,7 +301,7 @@ function EventTypeSingleLayout({
|
||||
e.preventDefault();
|
||||
deleteMutation.mutate({ id: eventType.id });
|
||||
}}>
|
||||
{t("delete_event_type_description") as string}
|
||||
{t("delete_event_type_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<EmbedDialog />
|
||||
|
||||
@@ -6,7 +6,7 @@ function SkeletonLoader() {
|
||||
<SkeletonContainer>
|
||||
<div className="mb-4 flex items-center">
|
||||
<SkeletonAvatar className="h-8 w-8" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from "react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -12,6 +12,7 @@ export function CalendarSwitch(props: {
|
||||
externalId: string;
|
||||
title: string;
|
||||
defaultSelected: boolean;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -69,7 +70,7 @@ export function CalendarSwitch(props: {
|
||||
key={props.externalId}
|
||||
name="enabled"
|
||||
label={props.title}
|
||||
defaultChecked={props.defaultSelected}
|
||||
defaultChecked={props.isSelected}
|
||||
onCheckedChange={(isOn: boolean) => {
|
||||
mutation.mutate({ isOn });
|
||||
}}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
QueryObserverIdleResult,
|
||||
QueryObserverLoadingErrorResult,
|
||||
QueryObserverLoadingResult,
|
||||
QueryObserverRefetchErrorResult,
|
||||
QueryObserverSuccessResult,
|
||||
UseQueryResult,
|
||||
} from "react-query";
|
||||
} from "@tanstack/react-query";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import type { UseTRPCQueryOptions } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type {
|
||||
inferHandlerInput,
|
||||
@@ -22,6 +20,8 @@ import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Loader from "@calcom/ui/Loader";
|
||||
|
||||
import type { UseTRPCQueryOptions } from "@trpc/react/shared";
|
||||
|
||||
type ErrorLike = {
|
||||
message: string;
|
||||
};
|
||||
@@ -34,7 +34,6 @@ interface QueryCellOptionsBase<TData, TError extends ErrorLike> {
|
||||
query: QueryObserverLoadingErrorResult<TData, TError> | QueryObserverRefetchErrorResult<TData, TError>
|
||||
) => JSXElementOrNull;
|
||||
loading?: (query: QueryObserverLoadingResult<TData, TError> | null) => JSXElementOrNull;
|
||||
idle?: (query: QueryObserverIdleResult<TData, TError>) => JSXElementOrNull;
|
||||
}
|
||||
|
||||
interface QueryCellOptionsNoEmpty<TData, TError extends ErrorLike>
|
||||
@@ -84,14 +83,11 @@ export function QueryCell<TData, TError extends ErrorLike>(
|
||||
);
|
||||
}
|
||||
|
||||
if (query.status === "idle") {
|
||||
return opts.idle?.(query) ?? StatusLoader;
|
||||
}
|
||||
// impossible state
|
||||
return null;
|
||||
}
|
||||
|
||||
type inferProcedures<TObj extends ProcedureRecord<any, any, any, any, any, any>> = {
|
||||
type inferProcedures<TObj extends ProcedureRecord> = {
|
||||
[TPath in keyof TObj]: {
|
||||
input: inferProcedureInput<TObj[TPath]>;
|
||||
output: inferProcedureOutput<TObj[TPath]>;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { trpc, proxy } from "@calcom/trpc/react";
|
||||
import { MetaProvider } from "@calcom/ui/v2/core/Meta";
|
||||
|
||||
import usePublicPage from "@lib/hooks/usePublicPage";
|
||||
@@ -38,7 +38,8 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
* We intend to not cache i18n query
|
||||
**/
|
||||
const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"], { context: { skipBatch: true } }).data ?? {
|
||||
const { i18n, locale } = trpc.useQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } })
|
||||
.data ?? {
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
@@ -54,7 +55,7 @@ const CustomI18nextProvider = (props: AppPropsWithChildren) => {
|
||||
};
|
||||
|
||||
const AppProviders = (props: AppPropsWithChildren) => {
|
||||
const session = trpc.useQuery(["viewer.public.session"]).data;
|
||||
const session = proxy.public.session.useQuery().data;
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isPublicPage = usePublicPage();
|
||||
const isThemeSupported =
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// handles logic related to user clock display using 24h display / timeZone options.
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import {
|
||||
getIs24hClockFromLocalStorage,
|
||||
isBrowserLocale24h,
|
||||
setIs24hClockInLocalStorage,
|
||||
} from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
|
||||
interface TimeOptions {
|
||||
@@ -20,8 +24,8 @@ const initClock = () => {
|
||||
return;
|
||||
}
|
||||
// This only sets browser locale if there's no preference on localStorage.
|
||||
if (!localStorage.getItem("timeOption.is24hClock")) set24hClock(isBrowserLocale24h());
|
||||
timeOptions.is24hClock = localStorage.getItem("timeOption.is24hClock") === "true";
|
||||
if (getIs24hClockFromLocalStorage() === null) set24hClock(isBrowserLocale24h());
|
||||
timeOptions.is24hClock = !!getIs24hClockFromLocalStorage();
|
||||
timeOptions.inviteeTimeZone = localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess();
|
||||
};
|
||||
|
||||
@@ -32,7 +36,7 @@ const is24h = (is24hClock?: boolean) => {
|
||||
};
|
||||
|
||||
const set24hClock = (is24hClock: boolean) => {
|
||||
localStorage.setItem("timeOption.is24hClock", is24hClock.toString());
|
||||
setIs24hClockInLocalStorage(is24hClock);
|
||||
timeOptions.is24hClock = is24hClock;
|
||||
};
|
||||
|
||||
|
||||
@@ -47,14 +47,27 @@ export const parseRecurringDates = (
|
||||
return [dateStrings, rule.all()];
|
||||
};
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
export const extractRecurringDates = (
|
||||
bookings: BookingItem[],
|
||||
booking: inferQueryOutput<"viewer.bookings">["bookings"][number] & {
|
||||
eventType: { recurringEvent: RecurringEvent | null };
|
||||
recurringEventId: string | null;
|
||||
recurringBookings: inferQueryOutput<"viewer.bookings">["recurringInfo"];
|
||||
},
|
||||
timeZone: string | undefined,
|
||||
i18n: I18n
|
||||
): [string[], Date[]] => {
|
||||
const dateStrings = bookings.map((booking) => processDate(dayjs(booking.startTime).tz(timeZone), i18n));
|
||||
const allDates = dateStrings.map((dateString) => dayjs(dateString).tz(timeZone).toDate());
|
||||
const { count = 0, ...rest } =
|
||||
booking.eventType.recurringEvent !== null ? booking.eventType.recurringEvent : {};
|
||||
const recurringInfo = booking.recurringBookings.find(
|
||||
(val) => val.recurringEventId === booking.recurringEventId
|
||||
);
|
||||
const allDates = new RRule({
|
||||
...rest,
|
||||
count: recurringInfo?._count.recurringEventId,
|
||||
dtstart: recurringInfo?._min.startTime,
|
||||
}).all();
|
||||
const dateStrings = allDates.map((r) => {
|
||||
return processDate(dayjs(r).tz(timeZone), i18n);
|
||||
});
|
||||
return [dateStrings, allDates];
|
||||
};
|
||||
|
||||
@@ -9,10 +9,10 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
|
||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||
WebhookTriggerEvents.MEETING_ENDED,
|
||||
] as const,
|
||||
routing_forms: [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
|
||||
"routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as ["FORM_SUBMITTED"],
|
||||
};
|
||||
|
||||
export const WEBHOOK_TRIGGER_EVENTS = [
|
||||
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.core,
|
||||
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP.routing_forms,
|
||||
...WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP["routing-forms"],
|
||||
] as const;
|
||||
|
||||
+12
-1
@@ -7,6 +7,7 @@ import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry
|
||||
|
||||
const V2_WHITELIST = [
|
||||
"/settings/admin",
|
||||
"/settings/billing",
|
||||
"/settings/developer/webhooks",
|
||||
"/settings/developer/api-keys",
|
||||
"/settings/my-account",
|
||||
@@ -17,13 +18,15 @@ const V2_WHITELIST = [
|
||||
"/event-types",
|
||||
"/workflows",
|
||||
"/apps",
|
||||
"/teams",
|
||||
"/success",
|
||||
"/auth/login",
|
||||
];
|
||||
|
||||
// For pages
|
||||
// - which has V1 versions being modified as V2
|
||||
const V2_BLACKLIST = ["/apps/routing_forms/"];
|
||||
// - Add routing_forms to keep old links working
|
||||
const V2_BLACKLIST = ["/apps/routing_forms/", "/apps/routing-forms/", "/apps/typeform/"];
|
||||
|
||||
const middleware: NextMiddleware = async (req) => {
|
||||
const url = req.nextUrl;
|
||||
@@ -42,6 +45,13 @@ const middleware: NextMiddleware = async (req) => {
|
||||
return NextResponse.redirect(req.nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't 404 old routing_forms links
|
||||
if (url.pathname.startsWith("/apps/routing_forms")) {
|
||||
url.pathname = url.pathname.replace("/apps/routing_forms", "/apps/routing-forms");
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
/** Display available V2 pages */
|
||||
if (
|
||||
!V2_BLACKLIST.some((p) => url.pathname.startsWith(p)) &&
|
||||
@@ -51,6 +61,7 @@ const middleware: NextMiddleware = async (req) => {
|
||||
url.pathname = `/v2${url.pathname}`;
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
};
|
||||
|
||||
|
||||
@@ -135,11 +135,11 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
source: "/forms/:formId",
|
||||
destination: "/apps/routing_forms/routing-link/:formId",
|
||||
destination: "/apps/routing-forms/routing-link/:formId",
|
||||
},
|
||||
{
|
||||
source: "/router",
|
||||
destination: "/apps/routing_forms/router",
|
||||
destination: "/apps/routing-forms/router",
|
||||
},
|
||||
/* TODO: have these files being served from another deployment or CDN {
|
||||
source: "/embed/embed.js",
|
||||
@@ -159,6 +159,11 @@ const nextConfig = {
|
||||
destination: "/settings/my-account/profile",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/teams",
|
||||
destination: "/teams",
|
||||
permanent: true,
|
||||
},
|
||||
/* V2 testers get redirected to the new settings */
|
||||
{
|
||||
source: "/settings/profile",
|
||||
|
||||
+12
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -45,18 +45,20 @@
|
||||
"@hookform/resolvers": "^2.9.7",
|
||||
"@next-auth/prisma-adapter": "^1.0.4",
|
||||
"@next/bundle-analyzer": "12.1.6",
|
||||
"@radix-ui/react-avatar": "^0.1.4",
|
||||
"@radix-ui/react-collapsible": "^0.1.0",
|
||||
"@radix-ui/react-dialog": "^0.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^0.1.1",
|
||||
"@radix-ui/react-id": "^0.1.0",
|
||||
"@radix-ui/react-radio-group": "^0.1.1",
|
||||
"@radix-ui/react-slider": "^0.1.1",
|
||||
"@radix-ui/react-switch": "^0.1.1",
|
||||
"@radix-ui/react-toggle-group": "^0.1.5",
|
||||
"@radix-ui/react-avatar": "^1.0.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.0",
|
||||
"@radix-ui/react-dropdown-menu": "^1.0.0",
|
||||
"@radix-ui/react-id": "^1.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.0",
|
||||
"@radix-ui/react-radio-group": "^1.0.0",
|
||||
"@radix-ui/react-slider": "^1.0.0",
|
||||
"@radix-ui/react-switch": "^1.0.0",
|
||||
"@radix-ui/react-toggle-group": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^4.3.9",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
"@wojtekmaj/react-daterange-picker": "^3.3.1",
|
||||
"accept-language-parser": "^1.5.0",
|
||||
@@ -105,7 +107,6 @@
|
||||
"react-live-chat-loader": "^2.7.3",
|
||||
"react-multi-email": "^0.5.3",
|
||||
"react-phone-number-input": "^3.2.7",
|
||||
"react-query": "^3.39.2",
|
||||
"react-select": "^5.4.0",
|
||||
"react-timezone-select": "^1.3.2",
|
||||
"react-use-intercom": "1.5.1",
|
||||
|
||||
+29
-31
@@ -73,7 +73,7 @@ export default function Custom404() {
|
||||
<ul role="list" className="mt-4">
|
||||
<li className="border-2 border-green-500 px-4 py-2">
|
||||
<a
|
||||
href={`${WEBSITE_URL}/pricing?infra`}
|
||||
href="https://console.cal.com"
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
|
||||
@@ -190,12 +190,10 @@ export default function Custom404() {
|
||||
{t("check_spelling_mistakes_or_go_back")}
|
||||
</span>
|
||||
) : isCalcom ? (
|
||||
<Link href={url}>
|
||||
<a className="mt-2 inline-block text-lg">
|
||||
{t("the_username")} <strong className="text-blue-500">cal.com{username}</strong>{" "}
|
||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
||||
</a>
|
||||
</Link>
|
||||
<a target="_blank" href={url} className="mt-2 inline-block text-lg" rel="noreferrer">
|
||||
{t("the_username")} <strong className="text-blue-500">cal.com{username}</strong>{" "}
|
||||
{t("is_still_available")} <span className="text-blue-500">{t("register_now")}</span>.
|
||||
</a>
|
||||
) : (
|
||||
<span className="mt-2 inline-block text-lg">
|
||||
{t("the_username")}{" "}
|
||||
@@ -211,31 +209,31 @@ export default function Custom404() {
|
||||
{!isSubpage && isCalcom && (
|
||||
<ul role="list" className="mt-4">
|
||||
<li className="border-2 border-green-500 px-4 py-2">
|
||||
<Link href={url}>
|
||||
<a className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
|
||||
<Icon.FiCheck className="h-6 w-6 text-green-500" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-medium text-gray-900">
|
||||
<span className="rounded-sm focus-within:ring-2 focus-within:ring-gray-500 focus-within:ring-offset-2">
|
||||
<span className="focus:outline-none">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{t("register")} <strong className="text-green-500">{username}</strong>
|
||||
</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="relative flex items-start space-x-4 py-6 rtl:space-x-reverse"
|
||||
rel="noreferrer">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-50">
|
||||
<Icon.FiCheck className="h-6 w-6 text-green-500" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-medium text-gray-900">
|
||||
<span className="rounded-sm focus-within:ring-2 focus-within:ring-gray-500 focus-within:ring-offset-2">
|
||||
<span className="focus:outline-none">
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
{t("register")} <strong className="text-green-500">{username}</strong>
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
{t("claim_username_and_schedule_events")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<Icon.FiChevronRight className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-base text-gray-500">{t("claim_username_and_schedule_events")}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<Icon.FiChevronRight className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -182,7 +182,6 @@ export default function User(props: inferSSRProps<typeof getServerSideProps>) {
|
||||
<h2 className="dark:text-darkgray-700 pr-2 text-sm font-semibold text-gray-700">
|
||||
{type.title}
|
||||
</h2>
|
||||
<p className="dark:text-darkgray-600 hidden text-sm font-normal leading-none text-gray-600 md:block">{`/${user.username}/${type.slug}`}</p>
|
||||
</div>
|
||||
<EventTypeDescription eventType={type} />
|
||||
</a>
|
||||
|
||||
@@ -115,7 +115,7 @@ const providers: Provider[] = [
|
||||
};
|
||||
},
|
||||
}),
|
||||
// ImpersonationProvider,
|
||||
ImpersonationProvider,
|
||||
];
|
||||
|
||||
if (IS_GOOGLE_LOGIN_ENABLED) {
|
||||
|
||||
@@ -76,7 +76,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
// get user's credentials + their connected integrations
|
||||
const calendarCredentials = getCalendarCredentials(user.credentials, user.id);
|
||||
const calendarCredentials = getCalendarCredentials(user.credentials);
|
||||
// get all the connected integrations' calendars (from third party)
|
||||
const connectedCalendars = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
||||
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
|
||||
|
||||
@@ -327,26 +327,29 @@ async function handler(req: NextApiRequest) {
|
||||
|
||||
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
|
||||
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
{
|
||||
...eventType,
|
||||
users,
|
||||
},
|
||||
{
|
||||
dateFrom: reqBody.start,
|
||||
dateTo: reqBody.end,
|
||||
}
|
||||
);
|
||||
if (!eventType.seatsPerTimeSlot) {
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
{
|
||||
...eventType,
|
||||
users,
|
||||
},
|
||||
{
|
||||
dateFrom: reqBody.start,
|
||||
dateTo: reqBody.end,
|
||||
}
|
||||
);
|
||||
|
||||
// Assign to only one user when ROUND_ROBIN
|
||||
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })];
|
||||
} else {
|
||||
// excluding ROUND_ROBIN, all users have availability required.
|
||||
if (availableUsers.length !== users.length) {
|
||||
throw new Error("Some users are unavailable for booking.");
|
||||
// Add an if conditional if there are no seats on the event type
|
||||
// Assign to only one user when ROUND_ROBIN
|
||||
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })];
|
||||
} else {
|
||||
// excluding ROUND_ROBIN, all users have availability required.
|
||||
if (availableUsers.length !== users.length) {
|
||||
throw new Error("Some users are unavailable for booking.");
|
||||
}
|
||||
users = availableUsers;
|
||||
}
|
||||
users = availableUsers;
|
||||
}
|
||||
|
||||
console.log("available users", users);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing_forms/pages/app-routing.config";
|
||||
import RoutingFormsRoutingConfig from "@calcom/app-store/ee/routing-forms/pages/app-routing.config";
|
||||
import TypeformRoutingConfig from "@calcom/app-store/typeform/pages/app-routing.config";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { AppGetServerSideProps } from "@calcom/types/AppGetServerSideProps";
|
||||
@@ -28,7 +28,7 @@ type NotFound = {
|
||||
|
||||
// TODO: It is a candidate for apps.*.generated.*
|
||||
const AppsRouting = {
|
||||
routing_forms: RoutingFormsRoutingConfig,
|
||||
"routing-forms": RoutingFormsRoutingConfig,
|
||||
typeform: TypeformRoutingConfig,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import Button from "@calcom/ui/Button";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import AuthContainer from "@components/ui/AuthContainer";
|
||||
import AuthContainer from "@components/v2/ui/AuthContainer";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
@@ -30,7 +29,7 @@ export default function Logout(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")}>
|
||||
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
|
||||
<div className="mb-4">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<Icon.FiCheck className="h-6 w-6 text-green-600" />
|
||||
@@ -44,9 +43,9 @@ export default function Logout(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/auth/login" passHref>
|
||||
<Button className="flex w-full justify-center"> {t("go_back_login")}</Button>
|
||||
</Link>
|
||||
<Button href="/auth/login" passHref className="flex w-full justify-center">
|
||||
{t("go_back_login")}
|
||||
</Button>
|
||||
</AuthContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import classNames from "classnames";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
@@ -9,7 +10,7 @@ import { isPasswordValid } from "@calcom/lib/auth";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||
import { TextField, EmailField, PasswordField } from "@calcom/ui/v2";
|
||||
import { TextField, EmailField, PasswordField, Label } from "@calcom/ui/v2";
|
||||
import WizardForm from "@calcom/ui/v2/core/WizardForm";
|
||||
|
||||
const StepDone = () => {
|
||||
@@ -85,6 +86,8 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
}
|
||||
}, onError);
|
||||
|
||||
const longWebsiteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL.length > 30;
|
||||
|
||||
return (
|
||||
<FormProvider {...formMethods}>
|
||||
<form id="setup-step-1" name="setup-step-1" className="space-y-4" onSubmit={onSubmit}>
|
||||
@@ -93,22 +96,36 @@ const SetupFormStep1 = (props: { setIsLoading: (val: boolean) => void }) => {
|
||||
name="username"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onBlur, onChange, value } }) => (
|
||||
<TextField
|
||||
addOnLeading={
|
||||
<span className="items-centerpx-3 inline-flex rounded-none text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
}
|
||||
value={value || ""}
|
||||
className="my-0"
|
||||
onBlur={onBlur}
|
||||
name="username"
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("username", e.target.value);
|
||||
await formMethods.trigger("username");
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<Label htmlFor="username" className={classNames(longWebsiteUrl && "mb-0")}>
|
||||
<span className="block">{t("username")}</span>
|
||||
{longWebsiteUrl && (
|
||||
<small className="items-centerpx-3 mt-2 inline-flex rounded-t-md border border-b-0 border-gray-300 bg-gray-100 py-1 px-3 text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}
|
||||
</small>
|
||||
)}
|
||||
</Label>
|
||||
<TextField
|
||||
addOnLeading={
|
||||
!longWebsiteUrl && (
|
||||
<span className="items-centerpx-3 inline-flex rounded-none text-sm text-gray-500">
|
||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||
</span>
|
||||
)
|
||||
}
|
||||
id="username"
|
||||
labelSrOnly={true}
|
||||
value={value || ""}
|
||||
className={classNames("my-0", longWebsiteUrl && "rounded-t-none")}
|
||||
onBlur={onBlur}
|
||||
name="username"
|
||||
onChange={async (e) => {
|
||||
onChange(e.target.value);
|
||||
formMethods.setValue("username", e.target.value);
|
||||
await formMethods.trigger("username");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `/apps/web/pages/v2/availability/[schedule].tsx` instead
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -51,7 +51,6 @@ export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availab
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
deleteFunction={deleteMutation.mutate}
|
||||
isDeleting={deleteMutation.isLoading}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -28,6 +28,7 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
recurring: "recurring_bookings",
|
||||
past: "past_bookings",
|
||||
cancelled: "cancelled_bookings",
|
||||
unconfirmed: "unconfirmed_bookings",
|
||||
};
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -53,17 +54,6 @@ export default function Bookings() {
|
||||
|
||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
||||
|
||||
// Get all recurring events of the series with the same recurringEventId
|
||||
const defineRecurrentBookings = (
|
||||
booking: BookingOutput,
|
||||
groupedBookings: Record<string, BookingOutput[]>
|
||||
) => {
|
||||
let recurringBookings = undefined;
|
||||
if (booking.recurringEventId !== null) {
|
||||
recurringBookings = groupedBookings[booking.recurringEventId];
|
||||
}
|
||||
return { recurringBookings };
|
||||
};
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
if (status === "recurring" || status === "cancelled") {
|
||||
@@ -91,7 +81,7 @@ export default function Bookings() {
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.status === "idle") && <SkeletonLoader />}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<>
|
||||
<div className="mt-6 overflow-hidden rounded-sm border border-b border-gray-200">
|
||||
@@ -103,7 +93,7 @@ export default function Bookings() {
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
{...defineRecurrentBookings(booking, shownBookings)}
|
||||
recurringBookings={page.recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @deprecated modifications to this file should be v2 only
|
||||
* Use `apps/web/pages/v2/event-types/[type].tsx` instead
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EventTypeCustomInput, MembershipRole, PeriodType, Prisma, SchedulingType } from "@prisma/client";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
|
||||
@@ -16,15 +22,16 @@ import { v5 as uuidv5 } from "uuid";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SelectGifInput } from "@calcom/app-store/giphy/components";
|
||||
import { getEventLocationType, EventLocationType } from "@calcom/app-store/locations";
|
||||
import { EventLocationType, getEventLocationType } from "@calcom/app-store/locations";
|
||||
import { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
||||
import { LocationObject, LocationType } from "@calcom/core/location";
|
||||
import { LocationObject } from "@calcom/core/location";
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { TRPCClientError } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
@@ -64,7 +71,6 @@ import * as RadioArea from "@components/ui/form/radio-area";
|
||||
import WebhookListContainer from "@components/webhook/WebhookListContainer";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
const RainbowInstallForm = dynamic(() => import("@calcom/rainbow/components/RainbowInstallForm"), {
|
||||
suspense: true,
|
||||
@@ -108,6 +114,7 @@ export type FormValues = {
|
||||
periodCountCalendarDays: "1" | "0";
|
||||
periodDates: { startDate: Date; endDate: Date };
|
||||
seatsPerTimeSlot: number | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
minimumBookingNotice: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { TRPCClientError } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
import Badge from "@calcom/ui/Badge";
|
||||
@@ -23,6 +24,7 @@ import Dropdown, {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/Dropdown";
|
||||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
@@ -42,8 +44,6 @@ import { LinkText } from "@components/ui/LinkText";
|
||||
import NoCalendarConnectedAlert from "@components/ui/NoCalendarConnectedAlert";
|
||||
import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader";
|
||||
|
||||
import { TRPCClientError } from "@trpc/react";
|
||||
|
||||
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
interface EventTypeListHeadingProps {
|
||||
@@ -346,36 +346,70 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button type="button" size="icon" color="minimal" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Link href={calLink}>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Link href={calLink}>
|
||||
<Button
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview") as string}
|
||||
</Button>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview") as string}
|
||||
className="w-full rounded-none text-left"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiClipboard}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}>
|
||||
{t("copy_link") as string}
|
||||
</Button>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none text-left"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiClipboard}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}>
|
||||
{t("copy_link") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
{isNativeShare ? (
|
||||
</DropdownMenuItem>
|
||||
{isNativeShare ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiUpload}
|
||||
onClick={() => {
|
||||
navigator
|
||||
.share({
|
||||
title: t("share"),
|
||||
text: t("share_event"),
|
||||
url: calLink,
|
||||
})
|
||||
.then(() => showToast(t("link_shared"), "success"))
|
||||
.catch(() => showToast(t("failed"), "error"));
|
||||
}}>
|
||||
{t("share") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
href={"/event-types/" + type.id}
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
{t("edit") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -383,59 +417,27 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
size="sm"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiUpload}
|
||||
onClick={() => {
|
||||
navigator
|
||||
.share({
|
||||
title: t("share"),
|
||||
text: t("share_event"),
|
||||
url: calLink,
|
||||
})
|
||||
.then(() => showToast(t("link_shared"), "success"))
|
||||
.catch(() => showToast(t("failed"), "error"));
|
||||
}}>
|
||||
{t("share") as string}
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={() => openModal(group, type)}>
|
||||
{t("duplicate") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
href={"/event-types/" + type.id}
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
{t("edit") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={() => openModal(group, type)}>
|
||||
{t("duplicate") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
color="warn"
|
||||
size="sm"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
color="warn"
|
||||
size="sm"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,6 +542,7 @@ const EventTypesPage = () => {
|
||||
<title>Home | Cal.com</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<Shell
|
||||
heading={t("event_types_page_title") as string}
|
||||
subtitle={t("event_types_page_subtitle") as string}
|
||||
|
||||
@@ -92,7 +92,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="mx-auto px-4 py-24">
|
||||
<div className="mx-auto px-4 py-6 md:py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
<div className="mx-auto sm:max-w-[520px]">
|
||||
@@ -129,7 +129,7 @@ const OnboardingPage = (props: IOnboardingPageProps) => {
|
||||
event.preventDefault();
|
||||
goToIndex(currentStepIndex + 1);
|
||||
}}
|
||||
className="mt-24 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
|
||||
{headers[currentStepIndex]?.skipText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import UserV2OptInBanner from "@calcom/features/users/components/UserV2OptInBanner";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MobileNavigationMoreItems } from "@calcom/ui/Shell";
|
||||
import { Shell } from "@calcom/ui/v2";
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Alert } from "@calcom/ui/Alert";
|
||||
@@ -39,8 +40,6 @@ import Schedule from "@components/availability/Schedule";
|
||||
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
|
||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||
|
||||
import { TRPCClientErrorLike } from "@trpc/client";
|
||||
|
||||
// Embed isn't applicable to onboarding, so ignore the rule
|
||||
/* eslint-disable @calcom/eslint/avoid-web-storage */
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function Settings() {
|
||||
const { data: routingForms } = trpc.useQuery([
|
||||
"viewer.appById",
|
||||
{
|
||||
appId: "routing_forms",
|
||||
appId: "routing-forms",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Settings() {
|
||||
<WebhookListContainer title="Event Webhooks" subtitle={t("receive_cal_meeting_data")} />
|
||||
{routingForms && (
|
||||
<WebhookListContainer
|
||||
appId="routing_forms"
|
||||
appId="routing-forms"
|
||||
title="Routing Webhooks"
|
||||
subtitle="Receive Routing Form responses at a specified URL, in real-time, when a Routing Form is submitted"
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { setIs24hClockInLocalStorage } from "@calcom/lib/timeFormat";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -229,7 +230,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||
// Write time format to localStorage if available
|
||||
// Embed isn't applicable to profile pages. So ignore the rule
|
||||
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
|
||||
window.localStorage.setItem("timeOption.is24hClock", selectedTimeFormat.value === 12 ? "false" : "true");
|
||||
setIs24hClockInLocalStorage(selectedTimeFormat.value === 24);
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
@@ -575,7 +576,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
* We intend to not cache i18n query
|
||||
**/
|
||||
const WithQuery = withQuery(["viewer.public.i18n"], { context: { skipBatch: true } });
|
||||
const WithQuery = withQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } });
|
||||
|
||||
export default function Settings(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma from "@calcom/prisma";
|
||||
import Button from "@calcom/ui/Button";
|
||||
@@ -203,7 +203,7 @@ export default function Success(props: SuccessProps) {
|
||||
// TODO: Add payment details
|
||||
});
|
||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
|
||||
setIs24h(!!getIs24hClockFromLocalStorage());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventType, needsConfirmation]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useEffect } from "react";
|
||||
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
@@ -17,7 +18,6 @@ import { Button } from "@calcom/ui/v2/core";
|
||||
import EventTypeDescription from "@calcom/ui/v2/modules/event-types/EventTypeDescription";
|
||||
|
||||
import { useExposePlanGlobally } from "@lib/hooks/useExposePlanGlobally";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -56,7 +56,6 @@ function TeamPage({ team }: TeamPageProps) {
|
||||
<div className="flex-shrink">
|
||||
<div className="flex flex-wrap items-center space-x-2">
|
||||
<h2 className="dark:text-darkgray-700 text-sm font-semibold text-gray-700">{type.title}</h2>
|
||||
<p className="dark:text-darkgray-600 hidden text-sm font-normal leading-none text-gray-600 md:block">{`/${team.slug}/${type.slug}`}</p>
|
||||
</div>
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
|
||||
@@ -18,21 +18,25 @@ export default function Apps({ apps }: InferGetStaticPropsType<typeof getStaticP
|
||||
|
||||
return (
|
||||
<>
|
||||
<Shell isPublic large>
|
||||
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-semibold md:px-8 lg:px-0 lg:pt-0">
|
||||
<Link href="/apps">
|
||||
<a className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
|
||||
<Icon.FiArrowLeft className="h-4 w-4" />
|
||||
{isLocaleReady ? t("app_store") : <SkeletonText className="h-4 w-24" />}{" "}
|
||||
</a>
|
||||
</Link>
|
||||
{category && (
|
||||
<span className="flex gap-1 text-gray-600">
|
||||
<span> / </span>
|
||||
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Shell
|
||||
isPublic
|
||||
backPath="/apps"
|
||||
heading={
|
||||
<>
|
||||
<Link href="/apps">
|
||||
<a className="inline-flex items-center justify-start gap-1 rounded-sm py-2 text-gray-900">
|
||||
{isLocaleReady ? t("app_store") : <SkeletonText className="h-4 w-24" />}{" "}
|
||||
</a>
|
||||
</Link>
|
||||
{category && (
|
||||
<span className="gap-1 text-gray-600">
|
||||
<span> / </span>
|
||||
{t("category_apps", { category: category[0].toUpperCase() + category?.slice(1) })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
large>
|
||||
<div className="mb-16">
|
||||
<div className="grid-col-1 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{apps.map((app) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { InferGetServerSidePropsType } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { InstallAppButton } from "@calcom/app-store/components";
|
||||
@@ -6,6 +6,7 @@ import { InstalledAppVariants } from "@calcom/app-store/utils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { App } from "@calcom/types/App";
|
||||
import { AppGetServerSidePropsContext } from "@calcom/types/AppGetServerSideProps";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import SkeletonLoader from "@calcom/ui/apps/SkeletonLoader";
|
||||
import { Alert } from "@calcom/ui/v2/core/Alert";
|
||||
@@ -90,7 +91,7 @@ interface IntegrationsContainerProps {
|
||||
|
||||
const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integrations"> }) => {
|
||||
return (
|
||||
<List>
|
||||
<List noBorderTreatment>
|
||||
{data.items.map((item) => (
|
||||
<IntegrationListItem
|
||||
name={item.name}
|
||||
@@ -100,7 +101,7 @@ const IntegrationsList = ({ data }: { data: inferQueryOutput<"viewer.integration
|
||||
logo={item.logo}
|
||||
description={item.description}
|
||||
actions={
|
||||
<div className="flex w-32 justify-center">
|
||||
<div className="flex w-32 justify-end">
|
||||
<ConnectOrDisconnectIntegrationButton
|
||||
credentialIds={item.credentialIds}
|
||||
type={item.type}
|
||||
@@ -177,12 +178,8 @@ const querySchema = z.object({
|
||||
category: z.nativeEnum(InstalledAppVariants),
|
||||
});
|
||||
|
||||
export default function InstalledApps() {
|
||||
export default function InstalledApps({ category }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { category } = router.isReady
|
||||
? querySchema.parse(router.query)
|
||||
: { category: InstalledAppVariants.calendar as const };
|
||||
|
||||
return (
|
||||
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
|
||||
@@ -202,3 +199,30 @@ export default function InstalledApps() {
|
||||
</InstalledAppsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Server side rendering
|
||||
export async function getServerSideProps(ctx: AppGetServerSidePropsContext) {
|
||||
// get return-to cookie and redirect if needed
|
||||
const { cookies } = ctx.req;
|
||||
if (cookies && cookies["return-to"]) {
|
||||
const returnTo = cookies["return-to"];
|
||||
if (returnTo) {
|
||||
ctx.res.setHeader("Set-Cookie", "return-to=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT");
|
||||
return {
|
||||
redirect: {
|
||||
destination: `${returnTo}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const params = querySchema.safeParse(ctx.params);
|
||||
|
||||
if (!params.success) return { notFound: true };
|
||||
|
||||
return {
|
||||
props: {
|
||||
category: params.data.category,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function Login({
|
||||
|
||||
const LoginFooter = (
|
||||
<a href={`${WEBSITE_URL}/signup`} className="text-brand-500 font-medium">
|
||||
{t("i_dont_have_an_account")}
|
||||
{t("dont_have_an_account")}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function Login({
|
||||
{...form.register("email")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute right-0 -top-[6px]">
|
||||
<div className="absolute right-0 -top-[6px] z-10">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a tabIndex={-1} className="text-sm font-medium text-gray-600">
|
||||
{t("forgot")}
|
||||
|
||||
@@ -9,20 +9,22 @@ import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { Schedule as ScheduleType } from "@calcom/types/schedule";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect";
|
||||
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
||||
import { Form, Label } from "@calcom/ui/v2/core/form/fields";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
import { Skeleton, SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import EditableHeading from "@components/ui/EditableHeading";
|
||||
import { SelectSkeletonLoader } from "@components/v2/availability/SkeletonLoader";
|
||||
|
||||
const querySchema = z.object({
|
||||
schedule: stringOrNumber,
|
||||
@@ -39,11 +41,12 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const me = useMeQuery();
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.availability.schedule", { scheduleId: schedule }]);
|
||||
|
||||
const form = useForm<AvailabilityFormValues>();
|
||||
const { control, reset, setValue } = form;
|
||||
const { control, reset } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && data) {
|
||||
@@ -79,22 +82,35 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
return (
|
||||
<Shell
|
||||
backPath="/availability"
|
||||
title={t("availability_title", { availabilityTitle: data?.schedule.name })}
|
||||
title={data?.schedule.name && data.schedule.name + " | " + t("availability")}
|
||||
heading={
|
||||
<EditableHeading title={data?.schedule.name || ""} onChange={(name) => setValue("name", name)} />
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => <EditableHeading isReady={!isLoading} {...field} />}
|
||||
/>
|
||||
}
|
||||
subtitle={
|
||||
data ? (
|
||||
data.schedule.availability.map((availability) => (
|
||||
<span key={availability.id}>
|
||||
{availabilityAsString(availability, { locale: i18n.language })}
|
||||
<br />
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<SkeletonText className="h-4 w-48" />
|
||||
)
|
||||
}
|
||||
subtitle={data?.schedule.availability.map((availability) => (
|
||||
<span key={availability.id}>
|
||||
{availabilityAsString(availability, { locale: i18n.language })}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center rounded-md px-2 sm:hover:bg-gray-100">
|
||||
<Label htmlFor="hiddenSwitch" className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor="hiddenSwitch"
|
||||
className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
|
||||
{t("set_to_default")}
|
||||
</Label>
|
||||
</Skeleton>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
disabled={isLoading}
|
||||
@@ -132,7 +148,17 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">
|
||||
{t("change_start_end")}
|
||||
</h3>
|
||||
<Schedule control={control} name="schedule" />
|
||||
{typeof me.data?.weekStart === "string" && (
|
||||
<Schedule
|
||||
control={control}
|
||||
name="schedule"
|
||||
weekStart={
|
||||
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
|
||||
me.data?.weekStart
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
@@ -147,11 +173,11 @@ export default function Availability({ schedule }: { schedule: number }) {
|
||||
value ? (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand mt-1 block rounded-md border-gray-300 text-sm"
|
||||
className="focus:border-brand mt-1 block w-72 rounded-md border-gray-300 text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonText className="h-6 w-full" />
|
||||
<SelectSkeletonLoader className="w-72" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -21,16 +21,32 @@ export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availab
|
||||
const meQuery = trpc.useQuery(["viewer.me"]);
|
||||
|
||||
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
|
||||
onSuccess: async () => {
|
||||
await utils.invalidateQueries(["viewer.availability.list"]);
|
||||
showToast(t("schedule_deleted_successfully"), "success");
|
||||
onMutate: async ({ scheduleId }) => {
|
||||
await utils.cancelQuery(["viewer.availability.list"]);
|
||||
const previousValue = utils.getQueryData(["viewer.availability.list"]);
|
||||
if (previousValue) {
|
||||
const filteredValue = previousValue.schedules.filter(({ id }) => id !== scheduleId);
|
||||
utils.setQueryData(["viewer.availability.list"], { ...previousValue, schedules: filteredValue });
|
||||
}
|
||||
|
||||
return { previousValue };
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousValue) {
|
||||
utils.setQueryData(["viewer.availability.list"], context.previousValue);
|
||||
}
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
utils.invalidateQueries(["viewer.availability.list"]);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showToast(t("schedule_deleted_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
// Adds smooth delete button - item fades and old item slides into place
|
||||
@@ -61,7 +77,6 @@ export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availab
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
deleteFunction={deleteMutation.mutate}
|
||||
isDeleting={deleteMutation.isLoading}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -20,13 +20,14 @@ import SkeletonLoader from "@components/v2/bookings/SkeletonLoader";
|
||||
type BookingListingStatus = inferQueryInput<"viewer.bookings">["status"];
|
||||
type BookingOutput = inferQueryOutput<"viewer.bookings">["bookings"][0];
|
||||
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled"] as const;
|
||||
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
upcoming: "upcoming_bookings",
|
||||
recurring: "recurring_bookings",
|
||||
past: "past_bookings",
|
||||
cancelled: "cancelled_bookings",
|
||||
unconfirmed: "unconfirmed_bookings",
|
||||
};
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -54,20 +55,9 @@ export default function Bookings() {
|
||||
|
||||
const isEmpty = !query.data?.pages[0]?.bookings.length;
|
||||
|
||||
// Get all recurring events of the series with the same recurringEventId
|
||||
const defineRecurrentBookings = (
|
||||
booking: BookingOutput,
|
||||
groupedBookings: Record<string, BookingOutput[]>
|
||||
) => {
|
||||
let recurringBookings = undefined;
|
||||
if (booking.recurringEventId !== null) {
|
||||
recurringBookings = groupedBookings[booking.recurringEventId];
|
||||
}
|
||||
return { recurringBookings };
|
||||
};
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
if (status === "recurring" || status === "cancelled") {
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
@@ -89,11 +79,11 @@ export default function Bookings() {
|
||||
|
||||
return (
|
||||
<BookingLayout heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<div className="flex w-full flex-1 flex-col" ref={animationParentRef}>
|
||||
<div className="flex w-full flex-col" ref={animationParentRef}>
|
||||
{query.status === "error" && (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
)}
|
||||
{(query.status === "loading" || query.status === "idle") && <SkeletonLoader />}
|
||||
{(query.status === "loading" || query.isPaused) && <SkeletonLoader />}
|
||||
{query.status === "success" && !isEmpty && (
|
||||
<div className="pt-2 xl:pt-0">
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
@@ -102,7 +92,7 @@ export default function Bookings() {
|
||||
*/}
|
||||
|
||||
<div className="overflow-hidden rounded-md border border-gray-200">
|
||||
<table className="w-full">
|
||||
<table className="w-full max-w-full table-fixed">
|
||||
<tbody className="divide-y divide-gray-200 bg-white" data-testid="bookings">
|
||||
{query.data.pages.map((page, index) => (
|
||||
<Fragment key={index}>
|
||||
@@ -110,7 +100,7 @@ export default function Bookings() {
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
listingStatus={status}
|
||||
{...defineRecurrentBookings(booking, shownBookings)}
|
||||
recurringBookings={page.recurringInfo}
|
||||
{...booking}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -70,6 +70,7 @@ export type FormValues = {
|
||||
periodCountCalendarDays: "1" | "0";
|
||||
periodDates: { startDate: Date; endDate: Date };
|
||||
seatsPerTimeSlot: number | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
minimumBookingNotice: number;
|
||||
beforeBufferTime: number;
|
||||
afterBufferTime: number;
|
||||
@@ -96,7 +97,8 @@ export type EventTypeSetupInfered = inferSSRProps<typeof getServerSideProps>;
|
||||
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { eventType, locationOptions, team, teamMembers } = props;
|
||||
const { eventType: dbEventType, locationOptions, team, teamMembers } = props;
|
||||
const [eventType, setEventType] = useState(dbEventType);
|
||||
const animationParentRef = useRef(null);
|
||||
const router = useRouter();
|
||||
const { tabName } = querySchema.parse(router.query);
|
||||
@@ -106,7 +108,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
}, [animationParentRef]);
|
||||
|
||||
const updateMutation = trpc.useMutation("viewer.eventTypes.update", {
|
||||
onSuccess: async ({ eventType }) => {
|
||||
onSuccess: async ({ eventType: newEventType }) => {
|
||||
setEventType({ ...eventType, slug: newEventType.slug });
|
||||
showToast(
|
||||
t("event_type_updated_successfully", {
|
||||
eventTypeTitle: eventType.title,
|
||||
@@ -153,6 +156,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
endDate: periodDates.endDate,
|
||||
},
|
||||
schedulingType: eventType.schedulingType,
|
||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -220,6 +224,9 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||
locations,
|
||||
blockchainId,
|
||||
smartContractAddress,
|
||||
// We don't need to send it to the backend
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
seatsPerTimeSlotEnabled,
|
||||
...input
|
||||
} = values;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserPlan } from "@prisma/client";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -7,6 +7,7 @@ import React, { Fragment, useEffect, useState } from "react";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { TRPCClientError } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Badge, Button, ButtonGroup, Dialog, EmptyScreen, showToast, Switch, Tooltip } from "@calcom/ui/v2";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
@@ -16,6 +17,7 @@ import Dropdown, {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/v2/core/Dropdown";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import CreateEventTypeButton from "@calcom/ui/v2/modules/event-types/CreateEventType";
|
||||
@@ -29,8 +31,6 @@ import Avatar from "@components/ui/Avatar";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import SkeletonLoader from "@components/v2/eventtype/SkeletonLoader";
|
||||
|
||||
import { TRPCClientError } from "@trpc/react";
|
||||
|
||||
type EventTypeGroups = inferQueryOutput<"viewer.eventTypes">["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
|
||||
@@ -52,7 +52,7 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Link href={`/event-types/${type.id}`}>
|
||||
<Link href={`/event-types/${type.id}?tabName=setup`}>
|
||||
<a
|
||||
className="flex-grow truncate text-sm"
|
||||
title={`${type.title} ${type.description ? `– ${type.description}` : ""}`}>
|
||||
@@ -82,6 +82,7 @@ const MemoizedItem = React.memo(Item);
|
||||
export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeListProps): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const [parent] = useAutoAnimate<HTMLUListElement>();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
|
||||
const utils = trpc.useContext();
|
||||
@@ -91,8 +92,8 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
await utils.cancelQuery(["viewer.eventTypes"]);
|
||||
await utils.invalidateQueries(["viewer.eventTypes"]);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.invalidateQueries(["viewer.eventTypes"]);
|
||||
onSettled: () => {
|
||||
utils.invalidateQueries(["viewer.eventTypes"]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,7 +108,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
},
|
||||
});
|
||||
|
||||
function moveEventType(index: number, increment: 1 | -1) {
|
||||
async function moveEventType(index: number, increment: 1 | -1) {
|
||||
const newList = [...types];
|
||||
|
||||
const type = types[index];
|
||||
@@ -117,24 +118,19 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
newList[index + increment] = type;
|
||||
}
|
||||
|
||||
utils.cancelQuery(["viewer.eventTypes"]);
|
||||
utils.setQueryData(["viewer.eventTypes"], (data) => {
|
||||
// tRPC is very strict with the return signature...
|
||||
if (!data)
|
||||
return {
|
||||
eventTypeGroups: [],
|
||||
profiles: [],
|
||||
viewer: { canAddEvents: true, plan: UserPlan.PRO },
|
||||
};
|
||||
return {
|
||||
...data,
|
||||
eventTypesGroups: [
|
||||
...data.eventTypeGroups.slice(0, groupIndex),
|
||||
await utils.cancelQuery(["viewer.eventTypes"]);
|
||||
|
||||
const previousValue = utils.getQueryData(["viewer.eventTypes"]);
|
||||
if (previousValue) {
|
||||
utils.setQueryData(["viewer.eventTypes"], {
|
||||
...previousValue,
|
||||
eventTypeGroups: [
|
||||
...previousValue.eventTypeGroups.slice(0, groupIndex),
|
||||
{ ...group, eventTypes: newList },
|
||||
...data.eventTypeGroups.slice(groupIndex + 1),
|
||||
...previousValue.eventTypeGroups.slice(groupIndex + 1),
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
mutation.mutate({
|
||||
ids: newList.map((type) => type.id),
|
||||
@@ -201,7 +197,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
const lastItem = types[types.length - 1];
|
||||
return (
|
||||
<div className="mb-16 flex overflow-hidden rounded-md border border-gray-200 bg-white">
|
||||
<ul className="w-full divide-y divide-neutral-200" data-testid="event-types">
|
||||
<ul ref={parent} className="!static w-full divide-y divide-neutral-200" data-testid="event-types">
|
||||
{types.map((type, index) => {
|
||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||
const calLink = `${CAL_URL}/${embedLink}`;
|
||||
@@ -296,8 +292,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
href={"/event-types/" + type.id}
|
||||
StartIcon={Icon.FiEdit2}>
|
||||
data-testid={"event-type-edit-" + type.id}
|
||||
StartIcon={Icon.FiEdit2}
|
||||
onClick={() => router.push("/event-types/" + type.id)}>
|
||||
{t("edit") as string}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
@@ -347,90 +344,92 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||
<Button type="button" size="icon" color="secondary" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent portalled>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Link href={calLink}>
|
||||
<a target="_blank">
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Link href={calLink}>
|
||||
<a target="_blank">
|
||||
<Button
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview") as string}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none text-left"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiClipboard}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}>
|
||||
{t("copy_link") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
{isNativeShare ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
StartIcon={Icon.FiExternalLink}
|
||||
className="w-full rounded-none">
|
||||
{t("preview") as string}
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiUpload}
|
||||
onClick={() => {
|
||||
navigator
|
||||
.share({
|
||||
title: t("share"),
|
||||
text: t("share_event"),
|
||||
url: calLink,
|
||||
})
|
||||
.then(() => showToast(t("link_shared"), "success"))
|
||||
.catch(() => showToast(t("failed"), "error"));
|
||||
}}>
|
||||
{t("share") as string}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none text-left"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiClipboard}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(calLink);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}>
|
||||
{t("copy_link") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
{isNativeShare ? (
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.push("/event-types/" + type.id)}
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
StartIcon={Icon.FiEdit}>
|
||||
{t("edit") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiUpload}
|
||||
onClick={() => {
|
||||
navigator
|
||||
.share({
|
||||
title: t("share"),
|
||||
text: t("share_event"),
|
||||
url: calLink,
|
||||
})
|
||||
.then(() => showToast(t("link_shared"), "success"))
|
||||
.catch(() => showToast(t("failed"), "error"));
|
||||
}}>
|
||||
{t("share") as string}
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={() => openModal(group, type)}>
|
||||
{t("duplicate") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
href={"/event-types/" + type.id}
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
StartIcon={Icon.FiEdit}>
|
||||
{t("edit") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full rounded-none"
|
||||
data-testid={"event-type-duplicate-" + type.id}
|
||||
StartIcon={Icon.FiCopy}
|
||||
onClick={() => openModal(group, type)}>
|
||||
{t("duplicate") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
color="destructive"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setDeleteDialogTypeId(type.id);
|
||||
}}
|
||||
color="destructive"
|
||||
StartIcon={Icon.FiTrash}
|
||||
className="w-full rounded-none">
|
||||
{t("delete") as string}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { HelpScout, useChat } from "react-live-chat-loader";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
|
||||
interface CtaRowProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CtaRow = ({ title, description, className, children }: CtaRowProps) => {
|
||||
return (
|
||||
<>
|
||||
<section className={classNames("flex flex-col sm:flex-row", className)}>
|
||||
<div>
|
||||
<h2 className="font-medium">{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 pt-3 sm:ml-auto sm:pt-0 sm:pl-3">{children}</div>
|
||||
</section>
|
||||
<hr className="border-neutral-200" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BillingView = () => {
|
||||
const { t } = useLocale();
|
||||
const { data: user } = trpc.useQuery(["viewer.me"]);
|
||||
const isPro = user?.plan === "PRO";
|
||||
const [, loadChat] = useChat();
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
|
||||
const onContactSupportClick = () => {
|
||||
setShowChat(true);
|
||||
loadChat({ open: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("billing")} description={t("manage_billing_description")} />
|
||||
<div className="space-y-6 text-sm sm:space-y-8">
|
||||
{!isPro && (
|
||||
<CtaRow title={t("billing_freeplan_title")} description={t("billing_freeplan_description")}>
|
||||
<form target="_blank" method="POST" action="/api/upgrade">
|
||||
<Button type="submit" EndIcon={Icon.FiExternalLink}>
|
||||
{t("billing_freeplan_cta")}
|
||||
</Button>
|
||||
</form>
|
||||
</CtaRow>
|
||||
)}
|
||||
|
||||
<CtaRow
|
||||
className={classNames(!isPro && "pointer-events-none opacity-30")}
|
||||
title={t("billing_manage_details_title")}
|
||||
description={t("billing_manage_details_description")}>
|
||||
<Button
|
||||
color={isPro ? "primary" : "secondary"}
|
||||
href="/api/integrations/stripepayment/portal"
|
||||
target="_blank"
|
||||
EndIcon={Icon.FiExternalLink}>
|
||||
{t("billing_portal")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
|
||||
<CtaRow title={t("billing_help_title")} description={t("billing_help_description")}>
|
||||
<Button color="secondary" onClick={onContactSupportClick}>
|
||||
{t("billing_help_cta")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
{showChat && <HelpScout color="#292929" icon="message" horizontalPosition="right" zIndex="1" />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BillingView.getLayout = getLayout;
|
||||
|
||||
export default BillingView;
|
||||
@@ -39,16 +39,16 @@ const ApiKeysView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="api_keys" description="webhooks_description" />
|
||||
<Meta title="API Keys" description="API keys allow other apps to communicate with Cal.com" />
|
||||
|
||||
<LicenseRequired>
|
||||
<>
|
||||
{isLoading && <SkeletonLoader />}
|
||||
<div>
|
||||
{data?.length ? (
|
||||
{isLoading ? null : data?.length ? (
|
||||
<>
|
||||
<div className="mt-6 mb-8 rounded-md border">
|
||||
{data?.map((apiKey, index) => (
|
||||
{data.map((apiKey, index) => (
|
||||
<ApiKeyListItem
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
|
||||
@@ -165,7 +165,7 @@ const AppearanceView = () => {
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Button color="primary" className="mt-8">
|
||||
<Button type="submit" color="primary" className="mt-8">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Alert } from "@calcom/ui/v2";
|
||||
import Badge from "@calcom/ui/v2/core/Badge";
|
||||
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
@@ -23,7 +24,7 @@ import { CalendarSwitch } from "@components/v2/settings/CalendarSwitch";
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="mt-6 mb-8 space-y-6 divide-y">
|
||||
<div className="mt-6 mb-8 space-y-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
@@ -89,7 +90,30 @@ const CalendarsView = () => {
|
||||
<List>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars && (
|
||||
{item.error && item.error.message && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
key={item.credentialId}
|
||||
title={t("calendar_connection_fail")}
|
||||
message={item.error.message}
|
||||
className="mb-4 mt-4"
|
||||
actions={
|
||||
<>
|
||||
{/* @TODO: add a reconnect button, that calls add api and delete old credential */}
|
||||
<DisconnectIntegration
|
||||
credentialId={item.credentialId}
|
||||
trashIcon
|
||||
onSuccess={() => query.refetch()}
|
||||
buttonProps={{
|
||||
className: "border border-gray-300 py-[2px]",
|
||||
color: "secondary",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item?.error === undefined && item.calendars && (
|
||||
<ListItem expanded className="flex-col">
|
||||
<div className="flex w-full flex-1 items-center space-x-3 pb-5 pl-1 pt-1 rtl:space-x-reverse">
|
||||
{
|
||||
@@ -132,6 +156,7 @@ const CalendarsView = () => {
|
||||
externalId={cal.externalId}
|
||||
title={cal.name || "Nameless calendar"}
|
||||
type={item.integration.type}
|
||||
isSelected={cal.isSelected}
|
||||
defaultSelected={cal.externalId === data?.destinationCalendar?.externalId}
|
||||
/>
|
||||
))}
|
||||
@@ -153,6 +178,22 @@ const CalendarsView = () => {
|
||||
/>
|
||||
);
|
||||
}}
|
||||
error={() => {
|
||||
return (
|
||||
<Alert
|
||||
message={
|
||||
<Trans i18nKey="fetching_calendars_error">
|
||||
An error ocurred while fetching your Calendars.
|
||||
<a className="cursor-pointer underline" onClick={() => query.refetch()}>
|
||||
try again
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
}
|
||||
severity="error"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ const ConferencingLayout = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white sm:mx-0 xl:mt-0">
|
||||
<Meta title="conferencing" description="conferencing_description" />
|
||||
<Meta title="Conferencing" description="Add your favourite video conferencing apps for your meetings" />
|
||||
<List roundContainer={true}>
|
||||
{apps?.items &&
|
||||
apps.items
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useMemo } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { setIs24hClockInLocalStorage } from "@calcom/lib/timeFormat";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/v2/core/Button";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import TimezoneSelect from "@calcom/ui/v2/core/TimezoneSelect";
|
||||
import Select from "@calcom/ui/v2/core/form/Select";
|
||||
import { Form, Label } from "@calcom/ui/v2/core/form/fields";
|
||||
import Select from "@calcom/ui/v2/core/form/select";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { SkeletonContainer, SkeletonText, SkeletonButton } from "@calcom/ui/v2/core/skeleton";
|
||||
@@ -33,25 +34,32 @@ const SkeletonLoader = () => {
|
||||
|
||||
interface GeneralViewProps {
|
||||
localeProp: string;
|
||||
user: inferQueryOutput<"viewer.me">;
|
||||
}
|
||||
|
||||
const WithQuery = withQuery(["viewer.public.i18n"], { context: { skipBatch: true } });
|
||||
const WithQuery = withQuery(["viewer.public.i18n"], { trpc: { context: { skipBatch: true } } });
|
||||
|
||||
const GeneralQueryView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: user, isLoading } = trpc.useQuery(["viewer.me"]);
|
||||
if (isLoading) return <SkeletonLoader />;
|
||||
if (!user) {
|
||||
throw new Error(t("something_went_wrong"));
|
||||
}
|
||||
return (
|
||||
<WithQuery
|
||||
success={({ data }) => <GeneralView localeProp={data.locale} />}
|
||||
success={({ data }) => <GeneralView user={user} localeProp={data.locale} />}
|
||||
customLoader={<SkeletonLoader />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const GeneralView = ({ localeProp }: GeneralViewProps) => {
|
||||
const GeneralView = ({ localeProp, user }: GeneralViewProps) => {
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: user, isLoading } = trpc.useQuery(["viewer.me"]);
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
onSuccess: () => {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
@@ -92,24 +100,24 @@ const GeneralView = ({ localeProp }: GeneralViewProps) => {
|
||||
value: localeProp || "",
|
||||
label: localeOptions.find((option) => option.value === localeProp)?.label || "",
|
||||
},
|
||||
timeZone: user?.timeZone || "",
|
||||
timeZone: user.timeZone || "",
|
||||
timeFormat: {
|
||||
value: user?.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === user?.timeFormat)?.label || 12,
|
||||
value: user.timeFormat || 12,
|
||||
label: timeFormatOptions.find((option) => option.value === user.timeFormat)?.label || 12,
|
||||
},
|
||||
weekStart: {
|
||||
value: user?.weekStart,
|
||||
label: nameOfDay(localeProp, user?.weekStart === "Sunday" ? 0 : 1),
|
||||
value: user.weekStart,
|
||||
label: nameOfDay(localeProp, user.weekStart === "Sunday" ? 0 : 1),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <SkeletonLoader />;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
setIs24hClockInLocalStorage(values.timeFormat.value === 24);
|
||||
|
||||
mutation.mutate({
|
||||
...values,
|
||||
locale: values.locale.value,
|
||||
@@ -188,7 +196,7 @@ const GeneralView = ({ localeProp }: GeneralViewProps) => {
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Button color="primary" className="mt-8">
|
||||
<Button color="primary" type="submit" className="mt-8">
|
||||
<>{t("update")}</>
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import crypto from "crypto";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRef, useState, BaseSyntheticEvent } from "react";
|
||||
import { useRef, useState, BaseSyntheticEvent, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/auth";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -21,6 +22,7 @@ import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { SkeletonContainer, SkeletonText, SkeletonButton, SkeletonAvatar } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
import TwoFactor from "@components/auth/TwoFactor";
|
||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
||||
|
||||
const SkeletonLoader = () => {
|
||||
return (
|
||||
@@ -47,6 +49,7 @@ interface DeleteAccountValues {
|
||||
const ProfileView = () => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: user, isLoading } = trpc.useQuery(["viewer.me"]);
|
||||
const mutation = trpc.useMutation("viewer.updateProfile", {
|
||||
@@ -58,10 +61,16 @@ const ProfileView = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false);
|
||||
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
|
||||
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
|
||||
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
|
||||
|
||||
const [currentUsername, setCurrentUsername] = useState<string | undefined>(user?.username || undefined);
|
||||
const [inputUsernameValue, setInputUsernameValue] = useState(currentUsername);
|
||||
useEffect(() => {
|
||||
if (user?.username) setCurrentUsername(user?.username);
|
||||
}, [user?.username]);
|
||||
const form = useForm<DeleteAccountValues>();
|
||||
|
||||
const emailMd5 = crypto
|
||||
@@ -81,6 +90,16 @@ const ProfileView = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPasswordMutation = trpc.useMutation("viewer.auth.verifyPassword", {
|
||||
onSuccess() {
|
||||
mutation.mutate(formMethods.getValues());
|
||||
setConfirmPasswordOpen(false);
|
||||
},
|
||||
onError() {
|
||||
setConfirmPasswordDeleteErrorMessage(t("incorrect_password"));
|
||||
},
|
||||
});
|
||||
|
||||
const onDeleteMeErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
|
||||
setHasDeleteErrors(true);
|
||||
setDeleteErrorMessage(errorMessages[error.message]);
|
||||
@@ -93,6 +112,12 @@ const ProfileView = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const onConfirmPassword = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
const password = passwordRef.current.value;
|
||||
confirmPasswordMutation.mutate({ passwordInput: password });
|
||||
};
|
||||
|
||||
const onConfirmButton = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
const totpCode = form.getValues("totpCode");
|
||||
@@ -105,15 +130,26 @@ const ProfileView = () => {
|
||||
deleteMeMutation.mutate({ password, totpCode });
|
||||
};
|
||||
|
||||
const formMethods = useForm({
|
||||
defaultValues: {
|
||||
avatar: user?.avatar || "",
|
||||
username: user?.username || "",
|
||||
name: user?.name || "",
|
||||
email: user?.email || "",
|
||||
bio: user?.bio || "",
|
||||
},
|
||||
});
|
||||
const formMethods = useForm<{
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
bio?: string;
|
||||
}>();
|
||||
|
||||
const { reset } = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
if (user)
|
||||
reset({
|
||||
avatar: user?.avatar || "",
|
||||
username: user?.username || "",
|
||||
name: user?.name || "",
|
||||
email: user?.email || "",
|
||||
bio: user?.bio || "",
|
||||
});
|
||||
}, [reset, user]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const passwordRef = useRef<HTMLInputElement>(null!);
|
||||
@@ -126,15 +162,27 @@ const ProfileView = () => {
|
||||
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
const onSuccessfulUsernameUpdate = async () => {
|
||||
showToast(t("settings_updated_successfully"), "success");
|
||||
await utils.invalidateQueries(["viewer.me"]);
|
||||
};
|
||||
|
||||
if (isLoading) return <SkeletonLoader />;
|
||||
const onErrorInUsernameUpdate = () => {
|
||||
showToast(t("error_updating_settings"), "error");
|
||||
};
|
||||
|
||||
if (isLoading || !user) return <SkeletonLoader />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
form={formMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate(values);
|
||||
if (values.email !== user?.email) {
|
||||
setConfirmPasswordOpen(true);
|
||||
} else {
|
||||
mutation.mutate(values);
|
||||
}
|
||||
}}>
|
||||
<Meta title="Profile" description="Manage settings for your cal profile" />
|
||||
<div className="flex items-center">
|
||||
@@ -159,73 +207,28 @@ const ProfileView = () => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="username"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
data-testid="username-input"
|
||||
name="username"
|
||||
label={t("personal_cal_url")}
|
||||
addOnLeading="https://cal.com/"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("username", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="name"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
label={t("full_name")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="email"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="email"
|
||||
label={t("email")}
|
||||
value={value}
|
||||
hint={t("change_email_hint")}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("email", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={formMethods.control}
|
||||
name="bio"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="mt-8">
|
||||
<TextField
|
||||
name="bio"
|
||||
label={t("about")}
|
||||
hint={t("bio_hint")}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
formMethods.setValue("bio", e?.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<UsernameAvailability
|
||||
currentUsername={currentUsername}
|
||||
setCurrentUsername={setCurrentUsername}
|
||||
inputUsernameValue={inputUsernameValue}
|
||||
usernameRef={usernameRef}
|
||||
setInputUsernameValue={setInputUsernameValue}
|
||||
onSuccessMutation={onSuccessfulUsernameUpdate}
|
||||
onErrorMutation={onErrorInUsernameUpdate}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<TextField label={t("full_name")} {...formMethods.register("name")} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<TextField label={t("about")} hint={t("bio_hint")} {...formMethods.register("bio")} />
|
||||
</div>
|
||||
|
||||
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
@@ -279,6 +282,32 @@ const ProfileView = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Form>
|
||||
|
||||
{/* If changing email, confirm password */}
|
||||
<Dialog open={confirmPasswordOpen} onOpenChange={setConfirmPasswordOpen}>
|
||||
<DialogContent
|
||||
title={t("confirm_password")}
|
||||
description={t("confirm_password_change_email")}
|
||||
type="creation"
|
||||
actionText={t("confirm")}
|
||||
Icon={Icon.FiAlertTriangle}
|
||||
actionOnClick={(e) => e && onConfirmPassword(e)}>
|
||||
<>
|
||||
<PasswordField
|
||||
data-testid="password"
|
||||
name="password"
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
label="Password"
|
||||
ref={passwordRef}
|
||||
/>
|
||||
|
||||
{confirmPasswordErrorMessage && <Alert severity="error" title={confirmPasswordErrorMessage} />}
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import Meta from "@calcom/ui/v2/core/Meta";
|
||||
import { Form, TextField } from "@calcom/ui/v2/core/form/fields";
|
||||
import { Form, PasswordField } from "@calcom/ui/v2/core/form/fields";
|
||||
import { getLayout } from "@calcom/ui/v2/core/layouts/SettingsLayout";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
|
||||
@@ -48,7 +48,7 @@ const PasswordView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title="Password" description="Manage settings for your account passwords" />
|
||||
<Meta title={t("password")} description={t("password_description")} />
|
||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||
<div>
|
||||
<div className="mt-6">
|
||||
@@ -65,22 +65,17 @@ const PasswordView = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Form<ChangePasswordFormValues> form={formMethods} handleSubmit={handleSubmit}>
|
||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||
<div className="max-w-[38rem] sm:flex sm:space-x-4">
|
||||
<div className="flex-grow">
|
||||
<TextField {...register("oldPassword")} label={t("old_password")} type="password" />
|
||||
<PasswordField {...register("oldPassword")} label={t("old_password")} />
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<TextField
|
||||
{...register("newPassword")}
|
||||
label={t("new_password")}
|
||||
type="password"
|
||||
placeholder={t("secure_password")}
|
||||
/>
|
||||
<PasswordField {...register("newPassword")} label={t("new_password")} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
<Trans i18nKey="valid_password">
|
||||
<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>
|
||||
@@ -89,8 +84,8 @@ const PasswordView = () => {
|
||||
<Button
|
||||
color="primary"
|
||||
className="mt-8"
|
||||
disabled={isSubmitting || mutation.isLoading}
|
||||
onClick={() => handleSubmit(formMethods.getValues())}>
|
||||
type="submit"
|
||||
disabled={isSubmitting || mutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
@@ -69,10 +69,9 @@ const CreateNewTeamPage = () => {
|
||||
data-testid="onboarding"
|
||||
key={router.asPath}>
|
||||
<Head>
|
||||
<title>{t("create_new_team")}</title>
|
||||
<title>Create a new Team</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="mx-auto px-4 py-24">
|
||||
<div className="relative">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]">
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma from "@calcom/prisma";
|
||||
import Button from "@calcom/ui/Button";
|
||||
@@ -201,7 +201,7 @@ export default function Success(props: SuccessProps) {
|
||||
// TODO: Add payment details
|
||||
});
|
||||
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
|
||||
setIs24h(!!getIs24hClockFromLocalStorage());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventType, needsConfirmation]);
|
||||
|
||||
@@ -263,7 +263,7 @@ export default function Success(props: SuccessProps) {
|
||||
{userIsOwner && !isEmbed && (
|
||||
<div className="mt-2 ml-4 -mb-4">
|
||||
<Link href={eventType.recurringEvent?.count ? "/bookings/recurring" : "/bookings/upcoming"}>
|
||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800">
|
||||
<a className="mt-2 inline-flex px-1 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-transparent dark:hover:text-white">
|
||||
<Icon.FiChevronLeft className="h-5 w-5" /> {t("back_to_bookings")}
|
||||
</a>
|
||||
</Link>
|
||||
@@ -411,7 +411,7 @@ export default function Success(props: SuccessProps) {
|
||||
!isCancelled &&
|
||||
(!isCancellationMode ? (
|
||||
<>
|
||||
<hr className="border-bookinglightest" />
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<div className="py-8 text-center last:pb-0">
|
||||
<span className="text-gray-900 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("need_to_make_a_change")}
|
||||
@@ -448,7 +448,7 @@ export default function Success(props: SuccessProps) {
|
||||
))}
|
||||
{userIsOwner && !needsConfirmation && !isCancellationMode && !isCancelled && (
|
||||
<>
|
||||
<hr className="border-bookinglightest" />
|
||||
<hr className="border-bookinglightest dark:border-darkgray-300" />
|
||||
<div className="text-bookingdark align-center flex flex-row justify-center pt-8">
|
||||
<span className="flex self-center font-medium text-gray-700 ltr:mr-2 rtl:ml-2 dark:text-gray-50">
|
||||
{t("add_to_calendar")}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Shell } from "@calcom/ui/v2";
|
||||
import { Alert } from "@calcom/ui/v2/core/Alert";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import EmptyScreen from "@calcom/ui/v2/core/EmptyScreen";
|
||||
|
||||
import SkeletonLoaderTeamList from "@components/team/SkeletonloaderTeamList";
|
||||
import TeamCreateModal from "@components/team/TeamCreateModal";
|
||||
import TeamList from "@components/team/TeamList";
|
||||
|
||||
function Teams() {
|
||||
const { t } = useLocale();
|
||||
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.teams.list"], {
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
const teams = data?.filter((m) => m.accepted) || [];
|
||||
const invites = data?.filter((m) => !m.accepted) || [];
|
||||
|
||||
return (
|
||||
<Shell
|
||||
heading={t("teams")}
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
CTA={
|
||||
<Button type="button" onClick={() => setShowCreateTeamModal(true)}>
|
||||
<Icon.FiPlus className="inline-block h-3.5 w-3.5 text-white group-hover:text-black ltr:mr-2 rtl:ml-2" />
|
||||
{t("new")}
|
||||
</Button>
|
||||
}>
|
||||
<>
|
||||
{!!errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||
{showCreateTeamModal && (
|
||||
<TeamCreateModal isOpen={showCreateTeamModal} onClose={() => setShowCreateTeamModal(false)} />
|
||||
)}
|
||||
{invites.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h1 className="mb-2 text-lg font-medium">{t("open_invitations")}</h1>
|
||||
<TeamList teams={invites} />
|
||||
</div>
|
||||
)}
|
||||
{isLoading && <SkeletonLoaderTeamList />}
|
||||
{!teams.length && !isLoading && (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiUsers}
|
||||
headline={t("no_teams")}
|
||||
description={t("no_teams_description")}
|
||||
buttonRaw={
|
||||
<Button color="secondary" onClick={() => setShowCreateTeamModal(true)}>
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
}
|
||||
buttonOnClick={() => setShowCreateTeamModal(true)}
|
||||
/>
|
||||
)}
|
||||
{teams.length > 0 && <TeamList teams={teams} />}
|
||||
</>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
Teams.requiresLicense = false;
|
||||
|
||||
export default Teams;
|
||||
@@ -25,7 +25,8 @@ test.describe("Change username on settings", () => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("User can change username", async ({ page, users, prisma }) => {
|
||||
/** TODO: Find out why it's timing out */
|
||||
test.fixme("User can change username", async ({ page, users, prisma }) => {
|
||||
const user = await users.create({ plan: UserPlan.TRIAL });
|
||||
|
||||
await user.login();
|
||||
@@ -35,10 +36,10 @@ test.describe("Change username on settings", () => {
|
||||
const usernameInput = page.locator("[data-testid=username-input]");
|
||||
|
||||
await usernameInput.fill("demousernamex");
|
||||
|
||||
await page.click("[data-testid=update-username-btn]");
|
||||
await Promise.all([
|
||||
page.click("[data-testid=save-username]"),
|
||||
page.waitForResponse("**/viewer.updateProfile*"),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
|
||||
const newUpdatedUser = await prisma.user.findUniqueOrThrow({
|
||||
|
||||
@@ -2,13 +2,8 @@ import { expect, Page } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
// these tests need rewrite
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip();
|
||||
|
||||
async function chooseEmbedType(page: Page, embedType: string) {
|
||||
const $embedTypeSelector = await page.locator(`[data-testid=${embedType}]`);
|
||||
$embedTypeSelector.click();
|
||||
function chooseEmbedType(page: Page, embedType: string) {
|
||||
page.locator(`[data-testid=${embedType}]`).click();
|
||||
}
|
||||
|
||||
async function gotToPreviewTab(page: Page) {
|
||||
@@ -111,7 +106,7 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
await chooseEmbedType(page, "inline");
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
@@ -119,19 +114,18 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
/*await expectToContainValidCode(page, { embedType: "inline" });
|
||||
await expectToContainValidCode(page, { embedType: "inline" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});*/
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose floating-popup for First Event Type", async ({ page, users }) => {
|
||||
const [pro] = users.get();
|
||||
|
||||
const embedUrl = await clickFirstEventTypeEmbedButton(page);
|
||||
|
||||
await expectToBeNavigatingToEmbedTypesDialog(page, {
|
||||
@@ -139,20 +133,20 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
await chooseEmbedType(page, "floating-popup");
|
||||
chooseEmbedType(page, "floating-popup");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "floating-popup",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
/*await expectToContainValidCode(page, { embedType: "floating-popup" });
|
||||
await expectToContainValidCode(page, { embedType: "floating-popup" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "floating-popup",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});*/
|
||||
});
|
||||
});
|
||||
|
||||
test("open Embed Dialog and choose element-click for First Event Type", async ({ page, users }) => {
|
||||
@@ -164,20 +158,20 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
basePage: "/event-types",
|
||||
});
|
||||
|
||||
await chooseEmbedType(page, "element-click");
|
||||
chooseEmbedType(page, "element-click");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
embedType: "element-click",
|
||||
basePage: "/event-types",
|
||||
});
|
||||
/*await expectToContainValidCode(page, { embedType: "element-click" });
|
||||
await expectToContainValidCode(page, { embedType: "element-click" });
|
||||
|
||||
await gotToPreviewTab(page);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "element-click",
|
||||
calLink: `${pro.username}/30-min`,
|
||||
});*/
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +194,7 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
basePage,
|
||||
});
|
||||
|
||||
await chooseEmbedType(page, "inline");
|
||||
chooseEmbedType(page, "inline");
|
||||
|
||||
await expectToBeNavigatingToEmbedCodeAndPreviewDialog(page, {
|
||||
embedUrl,
|
||||
@@ -208,7 +202,7 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
embedType: "inline",
|
||||
});
|
||||
|
||||
/*await expectToContainValidCode(page, {
|
||||
await expectToContainValidCode(page, {
|
||||
embedType: "inline",
|
||||
});
|
||||
|
||||
@@ -217,7 +211,7 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
calLink: decodeURIComponent(embedUrl),
|
||||
});*/
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { randomString } from "../lib/random";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
@@ -87,7 +89,7 @@ test.describe("Event Types tests", () => {
|
||||
);
|
||||
const href = await firstElement.getAttribute("href");
|
||||
if (!href) throw new Error("No href found for event type");
|
||||
const [eventTypeId] = href.split("/").reverse();
|
||||
const [eventTypeId] = new URL(WEBAPP_URL + href).pathname.split("/").reverse();
|
||||
const firstTitle = await page.locator(`[data-testid=event-type-title-${eventTypeId}]`).innerText();
|
||||
const firstFullSlug = await page.locator(`[data-testid=event-type-slug-${eventTypeId}]`).innerText();
|
||||
const firstSlug = firstFullSlug.split("/")[2];
|
||||
|
||||
@@ -3,6 +3,7 @@ import type Prisma from "@prisma/client";
|
||||
import { Prisma as PrismaType, UserPlan } from "@prisma/client";
|
||||
import { hash } from "bcryptjs";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
@@ -257,7 +258,7 @@ const createUser = async (
|
||||
password: await hashPassword(uname),
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
timeZone: opts?.timeZone ?? dayjs.tz.guess(),
|
||||
locale: opts?.locale ?? "en",
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
|
||||
@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, todo, waitFor } from "./lib/testUtils";
|
||||
import { todo } from "./lib/testUtils";
|
||||
|
||||
declare let global: {
|
||||
E2E_EMAILS?: ({ text: string } | Record<string, unknown>)[];
|
||||
@@ -182,7 +182,7 @@ test.fixme("Integrations", () => {
|
||||
},
|
||||
token: {
|
||||
url: "https://zoom.us/oauth/token",
|
||||
verify({ requestHeaders, code }) {
|
||||
verify({ requestHeaders }) {
|
||||
const authorization = requestHeaders.get("authorization").replace("Basic ", "");
|
||||
const clientPair = Buffer.from(authorization, "base64").toString();
|
||||
const [clientId, clientSecret] = clientPair.split(":");
|
||||
@@ -361,75 +361,4 @@ test.fixme("Integrations", () => {
|
||||
todo("Can add CalDav Calendar");
|
||||
|
||||
todo("Can add Apple Calendar");
|
||||
|
||||
test("add webhook & test that creating an event triggers a webhook call", async ({
|
||||
page,
|
||||
users,
|
||||
}, testInfo) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
const user = await users.create();
|
||||
const [eventType] = user.eventTypes;
|
||||
await user.login();
|
||||
await page.goto(`/settings/developer`);
|
||||
|
||||
// --- add webhook
|
||||
await page.click('[data-testid="new_webhook"]');
|
||||
|
||||
await expect(page.locator(`[data-testid='WebhookDialogForm']`)).toBeVisible();
|
||||
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
|
||||
await page.click("[type=submit]");
|
||||
|
||||
// dialog is closed
|
||||
await expect(page.locator(`[data-testid='WebhookDialogForm']`)).not.toBeVisible();
|
||||
// page contains the url
|
||||
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
||||
|
||||
// --- Book the first available day next month in the pro user's "30min"-event
|
||||
await page.goto(`/${user.username}/${eventType.slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// --- fill form
|
||||
await page.fill('[name="name"]', "Test Testson");
|
||||
await page.fill('[name="email"]', "test@example.com");
|
||||
await page.press('[name="email"]', "Enter");
|
||||
|
||||
// --- check that webhook was called
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body as any;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
body.payload.location = dynamic;
|
||||
for (const attendee of body.payload.attendees) {
|
||||
attendee.timeZone = dynamic;
|
||||
attendee.language = dynamic;
|
||||
}
|
||||
body.payload.organizer.email = dynamic;
|
||||
body.payload.organizer.timeZone = dynamic;
|
||||
body.payload.organizer.language = dynamic;
|
||||
body.payload.uid = dynamic;
|
||||
body.payload.bookingId = dynamic;
|
||||
body.payload.additionalInformation = dynamic;
|
||||
body.payload.requiresConfirmation = dynamic;
|
||||
body.payload.eventTypeId = dynamic;
|
||||
|
||||
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
||||
// console.log("BODY", body);
|
||||
// Text files shouldn't have platform specific suffixes
|
||||
testInfo.snapshotSuffix = "";
|
||||
expect(JSON.stringify(body)).toMatchSnapshot(`webhookResponse.txt`);
|
||||
|
||||
webhookReceiver.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth, waitFor } from "./lib/testUtils";
|
||||
|
||||
test("add webhook & test that creating an event triggers a webhook call", async ({
|
||||
page,
|
||||
users,
|
||||
}, testInfo) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
const user = await users.create();
|
||||
const [eventType] = user.eventTypes;
|
||||
await user.login();
|
||||
await page.goto(`/settings/developer/webhooks`);
|
||||
|
||||
// --- add webhook
|
||||
await page.click('[data-testid="new_webhook"]');
|
||||
|
||||
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
|
||||
|
||||
await page.fill('[name="secret"]', "secret");
|
||||
|
||||
await Promise.all([
|
||||
page.click("[type=submit]"),
|
||||
page.waitForNavigation({
|
||||
url: (url) => url.pathname.endsWith("/settings/developer/webhooks"),
|
||||
}),
|
||||
]);
|
||||
|
||||
// page contains the url
|
||||
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
||||
|
||||
// --- Book the first available day next month in the pro user's "30min"-event
|
||||
await page.goto(`/${user.username}/${eventType.slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// --- fill form
|
||||
await page.fill('[name="name"]', "Test Testson");
|
||||
await page.fill('[name="email"]', "test@example.com");
|
||||
await page.press('[name="email"]', "Enter");
|
||||
|
||||
// --- check that webhook was called
|
||||
await waitFor(() => {
|
||||
expect(webhookReceiver.requestList.length).toBe(1);
|
||||
});
|
||||
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body as any;
|
||||
|
||||
// remove dynamic properties that differs depending on where you run the tests
|
||||
const dynamic = "[redacted/dynamic]";
|
||||
body.createdAt = dynamic;
|
||||
body.payload.startTime = dynamic;
|
||||
body.payload.endTime = dynamic;
|
||||
body.payload.location = dynamic;
|
||||
for (const attendee of body.payload.attendees) {
|
||||
attendee.timeZone = dynamic;
|
||||
attendee.language = dynamic;
|
||||
}
|
||||
body.payload.organizer.email = dynamic;
|
||||
body.payload.organizer.timeZone = dynamic;
|
||||
body.payload.organizer.language = dynamic;
|
||||
body.payload.uid = dynamic;
|
||||
body.payload.bookingId = dynamic;
|
||||
body.payload.additionalInformation = dynamic;
|
||||
body.payload.requiresConfirmation = dynamic;
|
||||
body.payload.eventTypeId = dynamic;
|
||||
|
||||
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
||||
// console.log("BODY", body);
|
||||
// Text files shouldn't have platform specific suffixes
|
||||
testInfo.snapshotSuffix = "";
|
||||
expect(JSON.stringify(body)).toMatchSnapshot(`webhookResponse.txt`);
|
||||
|
||||
webhookReceiver.close();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user