Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33694196e1 | ||
|
|
b5569c6b1c | ||
|
|
4e74c0e27f | ||
|
|
5e8a80001d | ||
|
|
9921b76da0 | ||
|
|
73f607f27a | ||
|
|
fac4de1144 | ||
|
|
e5f8437282 | ||
|
|
1a20b0a0c6 | ||
|
|
ffc0f460a0 | ||
|
|
c9c21e6a67 | ||
|
|
9c94aadbf7 | ||
|
|
f8c036164c | ||
|
|
67bcbfd75a | ||
|
|
54be2a2ec1 | ||
|
|
4608b9d56d | ||
|
|
8d9c69916b | ||
|
|
643e64a0e4 | ||
|
|
20404611b0 | ||
|
|
f7fda47534 | ||
|
|
c48d0d6c34 | ||
|
|
861cfdfed0 | ||
|
|
fbc1df9a30 | ||
|
|
ac6275b906 | ||
|
|
d2b9e67424 | ||
|
|
70683a89b9 | ||
|
|
59d4d92b52 | ||
|
|
8b68263800 | ||
|
|
7739994f4e | ||
|
|
a61cb690af | ||
|
|
e24d8889fc | ||
|
|
f0abf47ecc | ||
|
|
1c0c3c7690 | ||
|
|
09c4040ce5 | ||
|
|
57eeb48a8e | ||
|
|
84d75cf693 | ||
|
|
bf46038474 | ||
|
|
e93b7d942a | ||
|
|
6e7359ae96 | ||
|
|
bd2a795d7a | ||
|
|
8a70ea66e9 | ||
|
|
46df4c048e | ||
|
|
592dcd36b3 | ||
|
|
3bb76a3a62 | ||
|
|
4537117624 | ||
|
|
9b5da1bca3 | ||
|
|
80bd7fd89b | ||
|
|
a66610d9c2 | ||
|
|
4cd7a4ce5b | ||
|
|
baa7e868bd | ||
|
|
445faa406a | ||
|
|
4be4a01968 | ||
|
|
bc46f4fbc4 | ||
|
|
9b583694a3 | ||
|
|
81e2ae1352 | ||
|
|
3e1fe30186 | ||
|
|
f91ed7837c | ||
|
|
1567feb75e | ||
|
|
43a721dce6 | ||
|
|
0f82427b1e | ||
|
|
3e5987abec | ||
|
|
3761a75b28 | ||
|
|
9d7dc09974 | ||
|
|
bab72f1514 | ||
|
|
fbbd7ea45a | ||
|
|
7bc7b241ac | ||
|
|
0dd72888a9 | ||
|
|
a6382cf07f | ||
|
|
39761c520e | ||
|
|
c9a8bd369c | ||
|
|
d95e26d55c | ||
|
|
3bc659af44 | ||
|
|
cbf528c33e | ||
|
|
38f762f7b2 | ||
|
|
94a10992d2 | ||
|
|
26e1194ef3 | ||
|
|
21103580f7 | ||
|
|
ca405743fb | ||
|
|
6b426b5386 | ||
|
|
c0c4cb53db | ||
|
|
c21f0c2d49 | ||
|
|
4ce879e5dc | ||
|
|
25372b3c9e | ||
|
|
a3bd226347 | ||
|
|
e6f71c81bb | ||
|
|
972402be2c | ||
|
|
5c5d9d3406 | ||
|
|
c2a60657d4 | ||
|
|
d2965627d0 | ||
|
|
5deea2c5f6 | ||
|
|
7e6d56ca1f | ||
|
|
725a7ec0f4 | ||
|
|
ad8ffd3de4 | ||
|
|
8e447ea4b5 | ||
|
|
8bbfc0c7f0 | ||
|
|
2abd7779ac | ||
|
|
b6518b9ce1 | ||
|
|
43c939e342 | ||
|
|
357e279dd8 | ||
|
|
8afcba23c8 | ||
|
|
c359ebe85c | ||
|
|
3587e1ac9c | ||
|
|
3ff99f7877 | ||
|
|
c1d90eb438 | ||
|
|
5902f78fb2 | ||
|
|
8617b2db65 | ||
|
|
850497ea80 | ||
|
|
1890d5daf7 | ||
|
|
8d4b3c1c2c | ||
|
|
22a6d6ee3b | ||
|
|
05fa1feab0 | ||
|
|
bbf96a2e1d | ||
|
|
3b00bc7508 | ||
|
|
878c8b8248 | ||
|
|
23127318dc | ||
|
|
db7711869f | ||
|
|
ec2acedf34 | ||
|
|
d76ef4a007 | ||
|
|
c43e6783a7 | ||
|
|
91f2c380c5 | ||
|
|
b11d81fdd2 | ||
|
|
6792e17c80 | ||
|
|
dd446abeec | ||
|
|
c109ab1e30 | ||
|
|
dc13c95644 | ||
|
|
c85f0650fe | ||
|
|
7e6628e3ac | ||
|
|
e8af9110a7 | ||
|
|
ec9c8bb35d | ||
|
|
22aa083883 | ||
|
|
8c1b69cc0f | ||
|
|
8d1d3fcc7a | ||
|
|
dd48749f42 | ||
|
|
bd51316c68 | ||
|
|
2430784142 | ||
|
|
2b51cd9c8d | ||
|
|
de3c4aa75a | ||
|
|
813eaa83b7 | ||
|
|
ec2d0a89ba | ||
|
|
80a2b6c068 | ||
|
|
7faa9508c4 | ||
|
|
5773d064c2 | ||
|
|
0a7233d452 | ||
|
|
529f3027cd | ||
|
|
20cbab1c15 | ||
|
|
98d3cb1915 | ||
|
|
8322e5c8d1 | ||
|
|
36767afbf5 | ||
|
|
396355e350 | ||
|
|
da2c825dad | ||
|
|
dab146a313 | ||
|
|
712266664e | ||
|
|
f8781e4d5f | ||
|
|
d8b3c42c28 | ||
|
|
24e36ad46a | ||
|
|
644b0f1b0e | ||
|
|
deb97fdab0 | ||
|
|
58f55f84e2 | ||
|
|
5b3dd02747 | ||
|
|
ecc960f0a3 | ||
|
|
dfb1b5602d | ||
|
|
d6dd13a9d8 | ||
|
|
e0d1b6b5ea | ||
|
|
ffdf0b9217 |
30
.env.example
30
.env.example
@@ -1,4 +1,4 @@
|
||||
# Set this value to 'agree' to accept our license:
|
||||
# Set this value to 'agree' to accept our license:
|
||||
# LICENSE: https://github.com/calendso/calendso/blob/main/LICENSE
|
||||
#
|
||||
# Summary of terms:
|
||||
@@ -8,14 +8,28 @@
|
||||
NEXT_PUBLIC_LICENSE_CONSENT=''
|
||||
|
||||
# DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
|
||||
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"
|
||||
|
||||
GOOGLE_API_CREDENTIALS='secret'
|
||||
# Needed to enable Google Calendar integrationa and Login with Google
|
||||
# @see https://github.com/calendso/calendso#obtaining-the-google-api-credentials
|
||||
GOOGLE_API_CREDENTIALS='{}'
|
||||
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
GOOGLE_LOGIN_ENABLED=false
|
||||
|
||||
BASE_URL='http://localhost:3000'
|
||||
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
||||
|
||||
JWT_SECRET='secret'
|
||||
# This is used so we can bypass emails in auth flows for E2E testing
|
||||
PLAYWRIGHT_SECRET=
|
||||
|
||||
# To enable SAML login, set both these variables
|
||||
# @see https://github.com/calendso/calendso/tree/main/ee#setting-up-saml-login
|
||||
# SAML_DATABASE_URL="postgresql://postgres:@localhost:5450/cal-saml"
|
||||
# SAML_ADMINS='pro@example.com'
|
||||
|
||||
# @see: https://github.com/calendso/calendso/issues/263
|
||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
||||
@@ -56,11 +70,11 @@ CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||
|
||||
# Stripe Config
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||
STRIPE_CLIENT_ID= # ca_...
|
||||
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,12 +2,10 @@
|
||||
name: Feature request
|
||||
about: Suggest a feature or idea
|
||||
title: ""
|
||||
labels: enhancement
|
||||
labels: feature
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
> Please check if your Feature Request has not been already raised in the [Discussions Tab](https://github.com/calendso/calendso/discussions), as we would like to reduce duplicates. If it has been already raised, simply upvote it 🔼.
|
||||
|
||||
### Is your proposal related to a problem?
|
||||
|
||||
<!--
|
||||
|
||||
31
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
31
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete options that are not relevant. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
|
||||
|
||||
- [ ] Test A
|
||||
- [ ] Test B
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code and corrected any misspellings
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
71
.github/workflows/build.yml
vendored
71
.github/workflows/build.yml
vendored
@@ -1,71 +0,0 @@
|
||||
name: Build
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
name: Build on Node ${{ matrix.node }} and ${{ matrix.os }}
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
NODE_ENV: test
|
||||
BASE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12.1
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: calendso
|
||||
ports:
|
||||
- 5432:5432
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install deps
|
||||
uses: bahmutov/npm-install@v1
|
||||
|
||||
- name: Next.js cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ github.workspace }}/.next/cache
|
||||
key: ${{ runner.os }}-nextjs
|
||||
|
||||
- run: yarn prisma migrate deploy
|
||||
- run: yarn test
|
||||
- run: yarn build
|
||||
|
||||
types:
|
||||
name: Check types
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install deps
|
||||
uses: bahmutov/npm-install@v1
|
||||
|
||||
- run: yarn check-changed-files
|
||||
30
.github/workflows/check-types.yml
vendored
Normal file
30
.github/workflows/check-types.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Check types
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
types:
|
||||
name: Check types
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x"]
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install deps
|
||||
uses: bahmutov/npm-install@v1
|
||||
|
||||
- run: yarn check-changed-files
|
||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@1.4.0
|
||||
uses: crowdin/github-action@1.4.2
|
||||
with:
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
|
||||
37
.github/workflows/e2e.yml
vendored
37
.github/workflows/e2e.yml
vendored
@@ -1,18 +1,29 @@
|
||||
name: E2E test
|
||||
on: [push]
|
||||
on:
|
||||
pull_request_target:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 10
|
||||
name: ${{ matrix.node }} and ${{ matrix.os }}
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
BASE_URL: http://localhost:3000
|
||||
JWT_SECRET: secret
|
||||
GOOGLE_API_CREDENTIALS: "{}"
|
||||
# GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
PLAYWRIGHT_SECRET: ${{ secrets.CI_PLAYWRIGHT_SECRET }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: true
|
||||
# CRON_API_KEY: xxx
|
||||
# CALENDSO_ENCRYPTION_KEY: xxx
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
PAYMENT_FEE_PERCENTAGE: 0.005
|
||||
PAYMENT_FEE_FIXED: 10
|
||||
SAML_DATABASE_URL: postgresql://postgres:@localhost:5432/calendso
|
||||
SAML_ADMINS: pro@example.com
|
||||
# NEXTAUTH_URL: xxx
|
||||
# EMAIL_FROM: xxx
|
||||
# EMAIL_SERVER_HOST: xxx
|
||||
@@ -39,6 +50,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v1
|
||||
@@ -51,14 +65,16 @@ jobs:
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ github.workspace }}/.next/cache
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-nextjs
|
||||
# Generate a new cache whenever packages or source files change.
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
|
||||
|
||||
- run: yarn test
|
||||
- run: yarn prisma migrate deploy
|
||||
- run: yarn db-seed
|
||||
- run: yarn test
|
||||
- run: yarn build
|
||||
- run: yarn start &
|
||||
- run: npx wait-port 3000 --timeout 10000
|
||||
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v2
|
||||
@@ -71,7 +87,7 @@ jobs:
|
||||
key: cache-playwright-${{ hashFiles('**/yarn.lock') }}
|
||||
- name: Install playwright deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: yarn playwright install-deps
|
||||
run: yarn playwright install --with-deps
|
||||
|
||||
- run: yarn test-playwright
|
||||
|
||||
@@ -83,3 +99,4 @@ jobs:
|
||||
path: |
|
||||
playwright/screenshots
|
||||
playwright/videos
|
||||
playwright/results
|
||||
|
||||
5
.github/workflows/lint.yml
vendored
5
.github/workflows/lint.yml
vendored
@@ -1,5 +1,8 @@
|
||||
name: Lint
|
||||
on: [push]
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,6 +14,8 @@
|
||||
.nyc_output
|
||||
playwright/videos
|
||||
playwright/screenshots
|
||||
playwright/artifacts
|
||||
playwright/results
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -36,6 +38,7 @@ yarn-error.log*
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -54,3 +57,5 @@ yarn-error.log*
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Typescript
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -6,8 +6,5 @@
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"eslint.run": "onSave",
|
||||
"workbench.colorCustomizations": {
|
||||
"titleBar.activeBackground": "#292929",
|
||||
"titleBar.inactiveBackground": "#888888"
|
||||
}
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
|
||||
257
@types/ical.d.ts
vendored
Normal file
257
@types/ical.d.ts
vendored
Normal file
@@ -0,0 +1,257 @@
|
||||
// SPDX-FileCopyrightText: © 2019 EteSync Authors
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// https://github.com/mozilla-comm/ical.js/issues/367#issuecomment-568493517
|
||||
declare module "ical.js" {
|
||||
function parse(input: string): any[];
|
||||
|
||||
export class helpers {
|
||||
public updateTimezones(vcal: Component): Component;
|
||||
}
|
||||
|
||||
class Component {
|
||||
public fromString(str: string): Component;
|
||||
|
||||
public name: string;
|
||||
|
||||
constructor(jCal: any[] | string, parent?: Component);
|
||||
|
||||
public toJSON(): any[];
|
||||
|
||||
public getFirstSubcomponent(name?: string): Component | null;
|
||||
public getAllSubcomponents(name?: string): Component[];
|
||||
|
||||
public getFirstPropertyValue<T = any>(name?: string): T;
|
||||
|
||||
public getFirstProperty(name?: string): Property;
|
||||
public getAllProperties(name?: string): Property[];
|
||||
|
||||
public addProperty(property: Property): Property;
|
||||
public addPropertyWithValue(name: string, value: string | number | Record<string, unknown>): Property;
|
||||
|
||||
public hasProperty(name?: string): boolean;
|
||||
|
||||
public updatePropertyWithValue(name: string, value: string | number | Record<string, unknown>): Property;
|
||||
|
||||
public removeAllProperties(name?: string): boolean;
|
||||
|
||||
public addSubcomponent(component: Component): Component;
|
||||
}
|
||||
|
||||
export class Event {
|
||||
public uid: string;
|
||||
public summary: string;
|
||||
public startDate: Time;
|
||||
public endDate: Time;
|
||||
public description: string;
|
||||
public location: string;
|
||||
public attendees: Property[];
|
||||
/**
|
||||
* The sequence value for this event. Used for scheduling.
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof Event
|
||||
*/
|
||||
public sequence: number;
|
||||
/**
|
||||
* The duration. This can be the result directly from the property, or the
|
||||
* duration calculated from start date and end date. Setting the property
|
||||
* will remove any `dtend` properties.
|
||||
*
|
||||
* @type {Duration}
|
||||
* @memberof Event
|
||||
*/
|
||||
public duration: Duration;
|
||||
/**
|
||||
* The organizer value as an uri. In most cases this is a mailto: uri,
|
||||
* but it can also be something else, like urn:uuid:...
|
||||
*/
|
||||
public organizer: string;
|
||||
/** The sequence value for this event. Used for scheduling */
|
||||
public sequence: number;
|
||||
/** The recurrence id for this event */
|
||||
public recurrenceId: Time;
|
||||
|
||||
public component: Component;
|
||||
|
||||
public constructor(
|
||||
component?: Component | null,
|
||||
options?: { strictExceptions: boolean; exepctions: Array<Component | Event> }
|
||||
);
|
||||
|
||||
public isRecurring(): boolean;
|
||||
public iterator(startTime?: Time): RecurExpansion;
|
||||
}
|
||||
|
||||
export class Property {
|
||||
public name: string;
|
||||
public type: string;
|
||||
|
||||
constructor(jCal: any[] | string, parent?: Component);
|
||||
|
||||
public getFirstValue<T = any>(): T;
|
||||
public getValues<T = any>(): T[];
|
||||
|
||||
public setParameter(name: string, value: string | string[]): void;
|
||||
public setValue(value: string | Record<string, unknown>): void;
|
||||
public setValues(values: (string | Record<string, unknown>)[]): void;
|
||||
public toJSON(): any;
|
||||
}
|
||||
|
||||
interface TimeJsonData {
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
hour?: number;
|
||||
minute?: number;
|
||||
second?: number;
|
||||
isDate?: boolean;
|
||||
}
|
||||
|
||||
export class Time {
|
||||
public fromString(str: string): Time;
|
||||
public fromJSDate(aDate: Date | null, useUTC: boolean): Time;
|
||||
public fromData(aData: TimeJsonData): Time;
|
||||
|
||||
public now(): Time;
|
||||
|
||||
public isDate: boolean;
|
||||
public timezone: string;
|
||||
public zone: Timezone;
|
||||
|
||||
public year: number;
|
||||
public month: number;
|
||||
public day: number;
|
||||
public hour: number;
|
||||
public minute: number;
|
||||
public second: number;
|
||||
|
||||
constructor(data?: TimeJsonData);
|
||||
public compare(aOther: Time): number;
|
||||
|
||||
public clone(): Time;
|
||||
public convertToZone(zone: Timezone): Time;
|
||||
|
||||
public adjust(
|
||||
aExtraDays: number,
|
||||
aExtraHours: number,
|
||||
aExtraMinutes: number,
|
||||
aExtraSeconds: number,
|
||||
aTimeopt?: Time
|
||||
): void;
|
||||
|
||||
public addDuration(aDuration: Duration): void;
|
||||
public subtractDateTz(aDate: Time): Duration;
|
||||
|
||||
public toUnixTime(): number;
|
||||
public toJSDate(): Date;
|
||||
public toJSON(): TimeJsonData;
|
||||
public get icaltype(): "date" | "date-time";
|
||||
}
|
||||
|
||||
export class Duration {
|
||||
public weeks: number;
|
||||
public days: number;
|
||||
public hours: number;
|
||||
public minutes: number;
|
||||
public seconds: number;
|
||||
public isNegative: boolean;
|
||||
public icalclass: string;
|
||||
public icaltype: string;
|
||||
}
|
||||
|
||||
export class RecurExpansion {
|
||||
public complete: boolean;
|
||||
public dtstart: Time;
|
||||
public last: Time;
|
||||
public next(): Time;
|
||||
public fromData(options);
|
||||
public toJSON();
|
||||
constructor(options: {
|
||||
/** Start time of the event */
|
||||
dtstart: Time;
|
||||
/** Component for expansion, required if not resuming. */
|
||||
component?: Component;
|
||||
});
|
||||
}
|
||||
|
||||
export class Timezone {
|
||||
public utcTimezone: Timezone;
|
||||
public localTimezone: Timezone;
|
||||
public convert_time(tt: Time, fromZone: Timezone, toZone: Timezone): Time;
|
||||
|
||||
public tzid: string;
|
||||
public component: Component;
|
||||
|
||||
constructor(
|
||||
data:
|
||||
| Component
|
||||
| {
|
||||
component: string | Component;
|
||||
tzid?: string;
|
||||
location?: string;
|
||||
tznames?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export class TimezoneService {
|
||||
public get(tzid: string): Timezone | null;
|
||||
public has(tzid: string): boolean;
|
||||
public register(tzid: string, zone: Timezone | Component);
|
||||
public remove(tzid: string): Timezone | null;
|
||||
}
|
||||
|
||||
export type FrequencyValues =
|
||||
| "YEARLY"
|
||||
| "MONTHLY"
|
||||
| "WEEKLY"
|
||||
| "DAILY"
|
||||
| "HOURLY"
|
||||
| "MINUTELY"
|
||||
| "SECONDLY";
|
||||
|
||||
export enum WeekDay {
|
||||
SU = 1,
|
||||
MO,
|
||||
TU,
|
||||
WE,
|
||||
TH,
|
||||
FR,
|
||||
SA,
|
||||
}
|
||||
|
||||
export class RecurData {
|
||||
public freq?: FrequencyValues;
|
||||
public interval?: number;
|
||||
public wkst?: WeekDay;
|
||||
public until?: Time;
|
||||
public count?: number;
|
||||
public bysecond?: number[] | number;
|
||||
public byminute?: number[] | number;
|
||||
public byhour?: number[] | number;
|
||||
public byday?: string[] | string;
|
||||
public bymonthday?: number[] | number;
|
||||
public byyearday?: number[] | number;
|
||||
public byweekno?: number[] | number;
|
||||
public bymonth?: number[] | number;
|
||||
public bysetpos?: number[] | number;
|
||||
}
|
||||
|
||||
export class RecurIterator {
|
||||
public next(): Time;
|
||||
}
|
||||
|
||||
export class Recur {
|
||||
constructor(data?: RecurData);
|
||||
public until: Time | null;
|
||||
public freq: FrequencyValues;
|
||||
public count: number | null;
|
||||
|
||||
public clone(): Recur;
|
||||
public toJSON(): Omit<RecurData, "until"> & { until?: string };
|
||||
public iterator(startTime?: Time): RecurIterator;
|
||||
public isByCount(): boolean;
|
||||
}
|
||||
}
|
||||
24
README.md
24
README.md
@@ -2,7 +2,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/calendso/calendso">
|
||||
<img src="https://user-images.githubusercontent.com/8019099/133430653-24422d2a-3c8d-4052-9ad6-0580597151ee.png" alt="Logo">
|
||||
|
||||
|
||||
</a>
|
||||
|
||||
<h3 align="center">Cal.com (formerly Calendso)</h3>
|
||||
@@ -29,7 +29,7 @@
|
||||
<a href="https://github.com/calendso/calendso/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a>
|
||||
<img src="https://img.shields.io/github/package-json/v/calendso/calendso">
|
||||
<a href="https://github.com/calendso/calendso/pulse"><img src="https://img.shields.io/github/commit-activity/m/calendso/calendso" 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-%2412%2Fmonth-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://cal.crowdin.com/Cal"><img src="https://badges.crowdin.net/e/5a55420475b48696779e30e0208a1899/localized.svg" alt="Translate Slack"></a>
|
||||
</p>
|
||||
|
||||
@@ -86,15 +86,17 @@ You will also need Google API credentials. You can get this from the [Google API
|
||||
## Development
|
||||
|
||||
### Setup
|
||||
|
||||
#### Quick start with `yarn dx`
|
||||
|
||||
> - **Requires Docker to be installed**
|
||||
> - **Requires Docker and Docker Compose to be installed**
|
||||
> - Will start a local Postgres instance with a few test users - the credentials will be logged in the console
|
||||
|
||||
```bash
|
||||
git clone git@github.com:calendso/calendso.git
|
||||
cd calendso
|
||||
yarn
|
||||
cp .env.example .env
|
||||
yarn dx
|
||||
```
|
||||
|
||||
@@ -156,7 +158,7 @@ yarn dx
|
||||
npx prisma studio
|
||||
```
|
||||
8. Click on the `User` model to add a new user record.
|
||||
9. Fill out the fields (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
|
||||
9. Fill out the fields `email`, `username`, `password`, and set `metadata` to empty `{}` (remembering to encrypt your password with [BCrypt](https://bcrypt-generator.com/)) and click `Save 1 Record` to create your first user.
|
||||
10. Open a browser to [http://localhost:3000](http://localhost:3000) and login with your just created, first user.
|
||||
11. Set a 32 character random string in your .env file for the CALENDSO_ENCRYPTION_KEY.
|
||||
|
||||
@@ -217,11 +219,10 @@ yarn test-playwright
|
||||
### Docker
|
||||
|
||||
The Docker configuration for Cal is an effort powered by people within the community. Cal.com, Inc. does not provide official support for Docker, but we will accept fixes and documentation. Use at your own risk.
|
||||
|
||||
|
||||
If you want to contribute to the Docker repository, [reply here](https://github.com/calendso/docker/discussions/32).
|
||||
|
||||
The Docker configuration can be found [in our docker repository](https://github.com/calendso/docker).
|
||||
|
||||
|
||||
### Railway
|
||||
|
||||
@@ -269,7 +270,7 @@ Contributions are what make the open source community such an amazing place to b
|
||||
3. Set **Who can use this application or access this API?** to **Accounts in any organizational directory (Any Azure AD directory - Multitenant)**
|
||||
4. Set the **Web** redirect URI to `<Cal.com URL>/api/integrations/office365calendar/callback` replacing Cal.com URL with the URI at which your application runs.
|
||||
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env
|
||||
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attriubte
|
||||
6. Click **Certificates & secrets** create a new client secret and use the value as the **MS_GRAPH_CLIENT_SECRET** attribute
|
||||
|
||||
## Obtaining Zoom Client ID and Secret
|
||||
|
||||
@@ -289,11 +290,10 @@ Contributions are what make the open source community such an amazing place to b
|
||||
|
||||
## Obtaining Daily API Credentials
|
||||
|
||||
1. Open [Daily](https://www.daily.co/) and sign into your account.
|
||||
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
3. Copy your API key.
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
5. If you have a [Daily Scale Plan](https://www.daily.co/pricing) can also enable the ability to record Daily video meetings. To do so, set the `DAILY_SCALE_PLAN` environment variable to `'true'`
|
||||
1. Open [Daily](https://www.daily.co/) and sign into your account.
|
||||
2. From within your dashboard, go to the [developers](https://dashboard.daily.co/developers) tab.
|
||||
3. Copy your API key.
|
||||
4. Now paste the API key to your .env file into the `DAILY_API_KEY` field in your .env file.
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ export default function AddToHomescreen() {
|
||||
}
|
||||
}
|
||||
return !closeBanner ? (
|
||||
<div className="fixed sm:hidden bottom-0 inset-x-0 pb-2 sm:pb-5">
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
|
||||
<div className="fixed inset-x-0 bottom-0 pb-2 sm:hidden sm:pb-5">
|
||||
<div className="px-2 mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||
<div className="p-2 rounded-lg shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
|
||||
<div className="flex items-center justify-between flex-wrap">
|
||||
<div className="w-0 flex-1 flex items-center">
|
||||
<span className="flex p-2 rounded-lg bg-opacity-30 bg-brand">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex items-center flex-1 w-0">
|
||||
<span className="flex p-2 rounded-lg bg-opacity-30 bg-brand text-brandcontrast">
|
||||
<svg
|
||||
className="h-7 w-7 text-indigo-500 fill-current"
|
||||
className="text-indigo-500 fill-current h-7 w-7"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 50 50"
|
||||
enableBackground="new 0 0 50 50">
|
||||
@@ -34,13 +34,13 @@ export default function AddToHomescreen() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="order-2 flex-shrink-0 sm:order-3 sm:ml-2">
|
||||
<div className="flex-shrink-0 order-2 sm:order-3 sm:ml-2">
|
||||
<button
|
||||
onClick={() => setCloseBanner(true)}
|
||||
type="button"
|
||||
className="-mr-1 flex p-2 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||
className="flex p-2 -mr-1 rounded-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-white">
|
||||
<span className="sr-only">{t("dismiss")}</span>
|
||||
<XIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
<XIcon className="w-6 h-6 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
function computeContrastRatio(a: number[], b: number[]) {
|
||||
const lum1 = computeLuminance(a[0], a[1], a[2]);
|
||||
const lum2 = computeLuminance(b[0], b[1], b[2]);
|
||||
const brightest = Math.max(lum1, lum2);
|
||||
const darkest = Math.min(lum1, lum2);
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
|
||||
function computeLuminance(r: number, g: number, b: number) {
|
||||
const a = [r, g, b].map((v) => {
|
||||
v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||
}
|
||||
|
||||
function hexToRGB(hex: string) {
|
||||
const color = hex.replace("#", "");
|
||||
return [parseInt(color.slice(0, 2), 16), parseInt(color.slice(2, 4), 16), parseInt(color.slice(4, 6), 16)];
|
||||
}
|
||||
|
||||
function getContrastingTextColor(bgColor: string | null): string {
|
||||
bgColor = bgColor == "" || bgColor == null ? "#292929" : bgColor;
|
||||
const rgb = hexToRGB(bgColor);
|
||||
const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]);
|
||||
const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929
|
||||
return whiteContrastRatio > blackContrastRatio ? "#ffffff" : "#292929";
|
||||
}
|
||||
|
||||
const BrandColor = ({ val = "#292929" }: { val: string | undefined | null }) => {
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty("--brand-color", val);
|
||||
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val));
|
||||
}, [val]);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ export function Dialog(props: DialogProps) {
|
||||
const { children, ...other } = props;
|
||||
return (
|
||||
<DialogPrimitive.Root {...other}>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" />
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
@@ -17,7 +17,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
|
||||
({ children, ...props }, forwardedRef) => (
|
||||
<DialogPrimitive.Content
|
||||
{...props}
|
||||
className="min-w-[360px] fixed left-1/2 top-1/2 p-6 text-left bg-white rounded shadow-xl overflow-hidden -translate-x-1/2 -translate-y-1/2 sm:align-middle sm:w-full sm:max-w-lg"
|
||||
className="min-w-[360px] fixed left-1/2 top-1/2 p-6 text-left bg-white rounded shadow-xl -translate-x-1/2 -translate-y-1/2 sm:align-middle sm:w-full sm:max-w-lg"
|
||||
ref={forwardedRef}>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
@@ -32,10 +32,10 @@ type DialogHeaderProps = {
|
||||
export function DialogHeader(props: DialogHeaderProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="font-cal text-gray-900 text-lg font-bold leading-6" id="modal-title">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900 font-cal" id="modal-title">
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.subtitle && <div className="text-gray-400 text-sm">{props.subtitle}</div>}
|
||||
{props.subtitle && <div className="text-sm text-gray-400">{props.subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export function DialogHeader(props: DialogHeaderProps) {
|
||||
export function DialogFooter(props: { children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-5 flex space-x-2 justify-end">{props.children}</div>
|
||||
<div className="flex justify-end mt-5 space-x-2">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function ImageUploader({
|
||||
(opened) => !opened && setFile(null) // unset file on close
|
||||
}>
|
||||
<DialogTrigger asChild>
|
||||
<div className="flex items-center px-3">
|
||||
<div className="flex items-center">
|
||||
<Button color="secondary" type="button" className="py-1 text-xs">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
@@ -125,9 +125,9 @@ export default function ImageUploader({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-col items-center justify-center p-8 mt-6 cropper bg-gray-50">
|
||||
<div className="flex flex-col items-center justify-center p-8 mt-6 cropper">
|
||||
{!result && (
|
||||
<div className="flex items-center justify-start w-20 h-20 bg-gray-500 rounded-full max-h-20">
|
||||
<div className="flex items-center justify-start w-20 h-20 bg-gray-50 rounded-full max-h-20">
|
||||
{!imageSrc && (
|
||||
<p className="w-full text-sm text-center text-white sm:text-xs">
|
||||
{t("no_target", { target })}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
export default function Logo({ small }: { small?: boolean }) {
|
||||
export default function Logo({ small, icon }: { small?: boolean; icon?: boolean }) {
|
||||
return (
|
||||
<h1 className="brand-logo inline">
|
||||
<h1 className="inline">
|
||||
<strong>
|
||||
<img
|
||||
className={small ? "h-4 w-auto" : "h-5 w-auto"}
|
||||
alt="Cal"
|
||||
title="Cal"
|
||||
src="/calendso-logo-white-word.svg"
|
||||
/>
|
||||
{icon ? (
|
||||
<img className="w-9 mx-auto" alt="Cal" title="Cal" src="/cal-com-icon-white.svg" />
|
||||
) : (
|
||||
<img
|
||||
className={small ? "h-4 w-auto" : "h-5 w-auto"}
|
||||
alt="Cal"
|
||||
title="Cal"
|
||||
src="/calendso-logo-white-word.svg"
|
||||
/>
|
||||
)}
|
||||
</strong>
|
||||
</h1>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { SelectorIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ArrowLeftIcon,
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
LogoutIcon,
|
||||
PuzzleIcon,
|
||||
MoonIcon,
|
||||
MapIcon,
|
||||
} from "@heroicons/react/solid";
|
||||
import { signOut, useSession } from "next-auth/client";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ReactNode, useEffect, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import LicenseBanner from "@ee/components/LicenseBanner";
|
||||
import TrialBanner from "@ee/components/TrialBanner";
|
||||
import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
@@ -36,8 +40,9 @@ import Dropdown, {
|
||||
|
||||
import { useViewerI18n } from "./I18nLanguageHandler";
|
||||
import Logo from "./Logo";
|
||||
import Button from "./ui/Button";
|
||||
|
||||
function useMeQuery() {
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.useQuery(["viewer.me"], {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
@@ -48,7 +53,8 @@ function useMeQuery() {
|
||||
}
|
||||
|
||||
function useRedirectToLoginIfUnauthenticated() {
|
||||
const [session, loading] = useSession();
|
||||
const { data: session, status } = useSession();
|
||||
const loading = status === "loading";
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,7 +112,7 @@ export function ShellSubHeading(props: {
|
||||
</h2>
|
||||
{props.subtitle && <p className="mr-4 text-sm text-neutral-500">{props.subtitle}</p>}
|
||||
</div>
|
||||
{props.actions && <div className="flex-shrink-0 mb-4">{props.actions}</div>}
|
||||
{props.actions && <div className="flex-shrink-0">{props.actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,6 +124,10 @@ export default function Shell(props: {
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
CTA?: ReactNode;
|
||||
HeadingLeftIcon?: ReactNode;
|
||||
backPath?: string; // renders back button to specified path
|
||||
// use when content needs to expand with flex
|
||||
flexChildrenContainer?: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -136,13 +146,13 @@ export default function Shell(props: {
|
||||
{
|
||||
name: t("bookings"),
|
||||
href: "/bookings/upcoming",
|
||||
icon: ClockIcon,
|
||||
icon: CalendarIcon,
|
||||
current: router.asPath.startsWith("/bookings"),
|
||||
},
|
||||
{
|
||||
name: t("availability"),
|
||||
href: "/availability",
|
||||
icon: CalendarIcon,
|
||||
icon: ClockIcon,
|
||||
current: router.asPath.startsWith("/availability"),
|
||||
},
|
||||
{
|
||||
@@ -195,17 +205,23 @@ export default function Shell(props: {
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-screen overflow-hidden bg-gray-100">
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex flex-col w-56">
|
||||
<div className="flex h-screen overflow-hidden bg-gray-100" data-testid="dashboard-shell">
|
||||
<div className="hidden md:flex lg:flex-shrink-0">
|
||||
<div className="flex flex-col w-14 lg:w-56">
|
||||
<div className="flex flex-col flex-1 h-0 bg-white border-r border-gray-200">
|
||||
<div className="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex flex-col flex-1 pt-3 pb-4 overflow-y-auto lg:pt-5">
|
||||
<Link href="/event-types">
|
||||
<a className="px-4">
|
||||
<a className="px-4 md:hidden lg:inline">
|
||||
<Logo small />
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="flex-1 px-2 mt-5 space-y-1 bg-white">
|
||||
{/* logo icon for tablet */}
|
||||
<Link href="/event-types">
|
||||
<a className="md:inline lg:hidden">
|
||||
<Logo small icon />
|
||||
</a>
|
||||
</Link>
|
||||
<nav className="flex-1 px-2 mt-2 space-y-1 bg-white lg:mt-5">
|
||||
{navigation.map((item) => (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a
|
||||
@@ -224,21 +240,31 @@ export default function Shell(props: {
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
<span className="hidden lg:inline">{item.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<TrialBanner />
|
||||
<div className="p-2 pt-2 pr-2 m-2 rounded-sm hover:bg-gray-100">
|
||||
<UserDropdown />
|
||||
<span className="hidden lg:inline">
|
||||
<UserDropdown />
|
||||
</span>
|
||||
<span className="hidden md:inline lg:hidden">
|
||||
<UserDropdown small />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 w-0 overflow-hidden">
|
||||
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]">
|
||||
<main
|
||||
className={classNames(
|
||||
"flex-1 relative z-0 overflow-y-auto focus:outline-none max-w-[1700px]",
|
||||
props.flexChildrenContainer && "flex flex-col"
|
||||
)}>
|
||||
{/* show top navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="flex items-center justify-between p-4 bg-white border-b border-gray-200 md:hidden">
|
||||
<Link href="/event-types">
|
||||
@@ -258,8 +284,24 @@ export default function Shell(props: {
|
||||
<UserDropdown small />
|
||||
</div>
|
||||
</nav>
|
||||
<div className={classNames(props.centered && "md:max-w-5xl mx-auto", "py-8")}>
|
||||
<div
|
||||
className={classNames(
|
||||
props.centered && "md:max-w-5xl mx-auto",
|
||||
props.flexChildrenContainer && "flex flex-col flex-1",
|
||||
"py-8"
|
||||
)}>
|
||||
{!!props.backPath && (
|
||||
<div className="mx-3 mb-8 sm:mx-8">
|
||||
<Button
|
||||
onClick={() => router.push(props.backPath as string)}
|
||||
StartIcon={ArrowLeftIcon}
|
||||
color="secondary">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="block sm:flex justify-between px-4 sm:px-6 md:px-8 min-h-[80px]">
|
||||
{props.HeadingLeftIcon && <div className="mr-4">{props.HeadingLeftIcon}</div>}
|
||||
<div className="w-full mb-8">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-wide text-gray-900 font-cal">
|
||||
{props.heading}
|
||||
@@ -268,7 +310,13 @@ export default function Shell(props: {
|
||||
</div>
|
||||
<div className="flex-shrink-0 mb-4">{props.CTA}</div>
|
||||
</div>
|
||||
<div className="px-4 sm:px-6 md:px-8">{props.children}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"px-4 sm:px-6 md:px-8",
|
||||
props.flexChildrenContainer && "flex flex-col flex-1"
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
{/* show bottom navigation for md and smaller (tablet and phones) */}
|
||||
<nav className="fixed bottom-0 flex w-full bg-white shadow bottom-nav md:hidden">
|
||||
{/* note(PeerRich): using flatMap instead of map to remove settings from bottom nav */}
|
||||
@@ -313,19 +361,33 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
const { t } = useLocale();
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
const mutation = trpc.useMutation("viewer.away", {
|
||||
onSettled() {
|
||||
utils.invalidateQueries("viewer.me");
|
||||
},
|
||||
});
|
||||
const utils = trpc.useContext();
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center space-x-2 cursor-pointer group">
|
||||
<Avatar
|
||||
imageSrc={user?.avatar || ""}
|
||||
alt={user?.username || "Nameless User"}
|
||||
className={classNames(small ? "w-8 h-8" : "w-10 h-10", "bg-gray-300 rounded-full flex-shrink-0")}
|
||||
/>
|
||||
<div className="flex items-center w-full space-x-2 cursor-pointer group">
|
||||
<span
|
||||
className={classNames(
|
||||
small ? "w-8 h-8" : "w-10 h-10",
|
||||
"bg-gray-300 rounded-full flex-shrink-0 relative"
|
||||
)}>
|
||||
<Avatar imageSrc={user?.avatar || ""} alt={user?.username || "Nameless User"} />
|
||||
{!user?.away && (
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-white rounded-full"></div>
|
||||
)}
|
||||
{user?.away && (
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 bg-yellow-500 border-2 border-white rounded-full"></div>
|
||||
)}
|
||||
</span>
|
||||
{!small && (
|
||||
<>
|
||||
<span className="flex-grow text-sm">
|
||||
<span className="flex items-center flex-grow truncate">
|
||||
<span className="flex-grow text-sm truncate">
|
||||
<span className="block font-medium text-gray-900 truncate">
|
||||
{user?.username || "Nameless User"}
|
||||
</span>
|
||||
@@ -337,11 +399,31 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
className="flex-shrink-0 w-5 h-5 text-gray-400 group-hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
onClick={() => {
|
||||
mutation.mutate({ away: !user?.away });
|
||||
utils.invalidateQueries("viewer.me");
|
||||
}}
|
||||
className="flex px-4 py-2 text-sm cursor-pointer hover:bg-gray-100 hover:text-gray-900">
|
||||
<MoonIcon
|
||||
className={classNames(
|
||||
user?.away
|
||||
? "text-purple-500 group-hover:text-purple-700"
|
||||
: "text-gray-500 group-hover:text-gray-700",
|
||||
"mr-2 flex-shrink-0 h-5 w-5"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{user?.away ? t("set_as_free") : t("set_as_away")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{user?.username && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
@@ -385,6 +467,15 @@ function UserDropdown({ small }: { small?: boolean }) {
|
||||
{t("join_our_slack")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://cal.com/roadmap"
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700">
|
||||
<MapIcon className="w-5 h-5 mr-3 text-gray-500" /> {t("visit_roadmap")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<HelpMenuItemDynamic />
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function Tooltip({
|
||||
onOpenChange={onOpenChange}>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
className="bg-brand text-xs -mt-2 text-white px-1 py-0.5 shadow-lg rounded-sm"
|
||||
className="bg-black text-xs -mt-2 text-white px-1 py-0.5 shadow-lg rounded-sm"
|
||||
side="top"
|
||||
align="center"
|
||||
{...props}>
|
||||
|
||||
@@ -3,23 +3,20 @@ import { SchedulingType } from "@prisma/client";
|
||||
import { Dayjs } from "dayjs";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { FC } from "react";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useSlots } from "@lib/hooks/useSlots";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
type AvailableTimesProps = {
|
||||
workingHours: {
|
||||
days: number[];
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}[];
|
||||
timeFormat: string;
|
||||
minimumBookingNotice: number;
|
||||
eventTypeId: number;
|
||||
eventLength: number;
|
||||
slotInterval: number | null;
|
||||
date: Dayjs;
|
||||
users: {
|
||||
username: string | null;
|
||||
@@ -31,38 +28,44 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
date,
|
||||
eventLength,
|
||||
eventTypeId,
|
||||
slotInterval,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
timeFormat,
|
||||
users,
|
||||
schedulingType,
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
|
||||
const { slots, loading, error } = useSlots({
|
||||
date,
|
||||
slotInterval,
|
||||
eventLength,
|
||||
schedulingType,
|
||||
workingHours,
|
||||
users,
|
||||
minimumBookingNotice,
|
||||
eventTypeId,
|
||||
});
|
||||
|
||||
const [brand, setBrand] = useState("#292929");
|
||||
|
||||
useEffect(() => {
|
||||
setBrand(getComputedStyle(document.documentElement).getPropertyValue("--brand-color").trim());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:-mb-5">
|
||||
<div className="text-gray-600 font-light text-lg mb-4 text-left">
|
||||
<span className="w-1/2 dark:text-white text-gray-600">
|
||||
<strong>{t(date.format("dddd").toLowerCase())}</strong>
|
||||
<div className="flex flex-col mt-8 text-center sm:pl-4 sm:mt-0 sm:w-1/3 md:-mb-5">
|
||||
<div className="mb-4 text-lg font-light text-left text-gray-600">
|
||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||
<strong>{date.toDate().toLocaleString(i18n.language, { weekday: "long" })}</strong>
|
||||
<span className="text-gray-500">
|
||||
{date.format(", DD ")}
|
||||
{t(date.format("MMMM").toLowerCase())}
|
||||
{date.format(", D ")}
|
||||
{date.toDate().toLocaleString(i18n.language, { month: "long" })}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="md:max-h-[364px] overflow-y-auto">
|
||||
<div className="flex-grow md:h-[364px] overflow-y-auto">
|
||||
{!loading &&
|
||||
slots?.length > 0 &&
|
||||
slots.map((slot) => {
|
||||
@@ -91,7 +94,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
<div key={slot.time.format()}>
|
||||
<Link href={bookingUrl}>
|
||||
<a
|
||||
className="block font-medium mb-2 bg-white dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border border-brand dark:border-transparent rounded-sm hover:text-white hover:bg-brand dark:hover:border-black py-4 dark:hover:bg-black"
|
||||
className={classNames(
|
||||
"block py-4 mb-2 font-medium bg-white border rounded-sm dark:bg-gray-600 text-primary-500 dark:text-neutral-200 dark:border-transparent hover:text-white hover:bg-brand hover:text-brandcontrast dark:hover:border-black dark:hover:bg-brand dark:hover:text-brandcontrast",
|
||||
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
|
||||
)}
|
||||
data-testid="time">
|
||||
{slot.time.format(timeFormat)}
|
||||
</a>
|
||||
@@ -100,7 +106,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
);
|
||||
})}
|
||||
{!loading && !error && !slots.length && (
|
||||
<div className="w-full h-full flex flex-col justify-center content-center items-center -mt-4">
|
||||
<div className="flex flex-col items-center content-center justify-center w-full h-full -mt-4">
|
||||
<h1 className="my-6 text-xl text-black dark:text-white">{t("all_booked_today")}</h1>
|
||||
</div>
|
||||
)}
|
||||
@@ -108,10 +114,10 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
|
||||
{loading && <Loader />}
|
||||
|
||||
{error && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4">
|
||||
<div className="p-4 border-l-4 border-yellow-400 bg-yellow-50">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />
|
||||
<ExclamationIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-yellow-700">{t("slots_load_fail")}</p>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||
|
||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">[number];
|
||||
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||
|
||||
function BookingListItem(booking: BookingItem) {
|
||||
const { t, i18n } = useLocale();
|
||||
@@ -73,20 +73,17 @@ function BookingListItem(booking: BookingItem) {
|
||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="hidden px-6 py-4 align-top sm:table-cell whitespace-nowrap">
|
||||
<tr className="flex">
|
||||
<td className="hidden py-4 pl-6 align-top sm:table-cell whitespace-nowrap">
|
||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
|
||||
</div>
|
||||
</td>
|
||||
<td className={"px-6 py-4" + (booking.rejected ? " line-through" : "")}>
|
||||
<td className={"pl-4 py-4 flex-1" + (booking.rejected ? " line-through" : "")}>
|
||||
<div className="sm:hidden">
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<span className="mb-2 inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t("unconfirmed")}
|
||||
</span>
|
||||
)}
|
||||
{!booking.confirmed && !booking.rejected && <Tag className="mb-2 mr-2">{t("unconfirmed")}</Tag>}
|
||||
{!!booking?.eventType?.price && !booking.paid && <Tag className="mb-2 mr-2">Pending payment</Tag>}
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{startTime}:{" "}
|
||||
<small className="text-sm text-gray-500">
|
||||
@@ -94,13 +91,16 @@ function BookingListItem(booking: BookingItem) {
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium leading-6 truncate text-neutral-900 max-w-52 md:max-w-96">
|
||||
<div
|
||||
title={booking.title}
|
||||
className="text-sm font-medium leading-6 truncate text-neutral-900 max-w-56 md:max-w-max">
|
||||
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
|
||||
{booking.title}
|
||||
{!!booking?.eventType?.price && !booking.paid && (
|
||||
<Tag className="hidden ml-2 sm:inline-flex">Pending payment</Tag>
|
||||
)}
|
||||
{!booking.confirmed && !booking.rejected && (
|
||||
<span className="ml-2 hidden sm:inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t("unconfirmed")}
|
||||
</span>
|
||||
<Tag className="hidden ml-2 sm:inline-flex">{t("unconfirmed")}</Tag>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
@@ -115,7 +115,7 @@ function BookingListItem(booking: BookingItem) {
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
|
||||
<td className="py-4 pr-4 text-sm font-medium text-right whitespace-nowrap">
|
||||
{isUpcoming && !isCancelled ? (
|
||||
<>
|
||||
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
|
||||
@@ -130,4 +130,13 @@ function BookingListItem(booking: BookingItem) {
|
||||
);
|
||||
}
|
||||
|
||||
const Tag = ({ children, className = "" }: React.PropsWithChildren<{ className?: string }>) => {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-800 ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItem;
|
||||
|
||||
@@ -1,66 +1,115 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { EventType, PeriodType } from "@prisma/client";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
// Then, include dayjs-business-time
|
||||
import dayjsBusinessTime from "dayjs-business-time";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import getSlots from "@lib/slots";
|
||||
import { WorkingHours } from "@lib/types/schedule";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
dayjs.extend(dayjsBusinessTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
// FIXME prop types
|
||||
type DatePickerProps = {
|
||||
weekStart: string;
|
||||
onDatePicked: (pickedDate: Dayjs) => void;
|
||||
workingHours: WorkingHours[];
|
||||
eventLength: number;
|
||||
date: Dayjs | null;
|
||||
periodType: PeriodType;
|
||||
periodStartDate: Date | null;
|
||||
periodEndDate: Date | null;
|
||||
periodDays: number | null;
|
||||
periodCountCalendarDays: boolean | null;
|
||||
minimumBookingNotice: number;
|
||||
};
|
||||
|
||||
function isOutOfBounds(
|
||||
time: dayjs.ConfigType,
|
||||
{
|
||||
periodType,
|
||||
periodDays,
|
||||
periodCountCalendarDays,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
}: Pick<
|
||||
EventType,
|
||||
"periodType" | "periodDays" | "periodCountCalendarDays" | "periodStartDate" | "periodEndDate"
|
||||
>
|
||||
) {
|
||||
const date = dayjs(time);
|
||||
|
||||
switch (periodType) {
|
||||
case PeriodType.ROLLING: {
|
||||
const periodRollingEndDay = periodCountCalendarDays
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
dayjs().utcOffset(date.utcOffset()).add(periodDays!, "days").endOf("day")
|
||||
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
dayjs().utcOffset(date.utcOffset()).addBusinessTime(periodDays!, "days").endOf("day");
|
||||
return date.endOf("day").isAfter(periodRollingEndDay);
|
||||
}
|
||||
|
||||
case PeriodType.RANGE: {
|
||||
const periodRangeStartDay = dayjs(periodStartDate).utcOffset(date.utcOffset()).endOf("day");
|
||||
const periodRangeEndDay = dayjs(periodEndDate).utcOffset(date.utcOffset()).endOf("day");
|
||||
return date.endOf("day").isBefore(periodRangeStartDay) || date.endOf("day").isAfter(periodRangeEndDay);
|
||||
}
|
||||
|
||||
case PeriodType.UNLIMITED:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function DatePicker({
|
||||
weekStart,
|
||||
onDatePicked,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
eventLength,
|
||||
date,
|
||||
periodType = "unlimited",
|
||||
periodType = PeriodType.UNLIMITED,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodDays,
|
||||
periodCountCalendarDays,
|
||||
minimumBookingNotice,
|
||||
}: any): JSX.Element {
|
||||
const { t } = useLocale();
|
||||
const [days, setDays] = useState<({ disabled: boolean; date: number } | null)[]>([]);
|
||||
}: DatePickerProps): JSX.Element {
|
||||
const { i18n } = useLocale();
|
||||
const [browsingDate, setBrowsingDate] = useState<Dayjs | null>(date);
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | null>(
|
||||
date
|
||||
? periodType === "range"
|
||||
? dayjs(periodStartDate).utcOffset(date.utcOffset()).month()
|
||||
: date.month()
|
||||
: dayjs().month() /* High chance server is going to have the same month */
|
||||
);
|
||||
const [month, setMonth] = useState<string>("");
|
||||
const [year, setYear] = useState<string>("");
|
||||
const [isFirstMonth, setIsFirstMonth] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dayjs().month() !== selectedMonth) {
|
||||
setSelectedMonth(dayjs().month());
|
||||
if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) {
|
||||
setBrowsingDate(date || dayjs().tz(timeZone()));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setSelectedMonth((selectedMonth ?? 0) + 1);
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setSelectedMonth((selectedMonth ?? 0) - 1);
|
||||
};
|
||||
|
||||
const inviteeDate = (): Dayjs => (date || dayjs()).month(selectedMonth);
|
||||
}, [date, browsingDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (browsingDate) {
|
||||
setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" }));
|
||||
setYear(browsingDate.format("YYYY"));
|
||||
setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs()));
|
||||
}
|
||||
}, [browsingDate, i18n.language]);
|
||||
|
||||
const days = useMemo(() => {
|
||||
if (!browsingDate) {
|
||||
return [];
|
||||
}
|
||||
// Create placeholder elements for empty days in first week
|
||||
let weekdayOfFirst = inviteeDate().date(1).day();
|
||||
let weekdayOfFirst = browsingDate.date(1).day();
|
||||
if (weekStart === "Monday") {
|
||||
weekdayOfFirst -= 1;
|
||||
if (weekdayOfFirst < 0) weekdayOfFirst = 6;
|
||||
@@ -69,65 +118,45 @@ function DatePicker({
|
||||
const days = Array(weekdayOfFirst).fill(null);
|
||||
|
||||
const isDisabled = (day: number) => {
|
||||
const date: Dayjs = inviteeDate().date(day);
|
||||
switch (periodType) {
|
||||
case "rolling": {
|
||||
const periodRollingEndDay = periodCountCalendarDays
|
||||
? dayjs().tz(organizerTimeZone).add(periodDays, "days").endOf("day")
|
||||
: dayjs().tz(organizerTimeZone).addBusinessTime(periodDays, "days").endOf("day");
|
||||
return (
|
||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||
date.endOf("day").isAfter(periodRollingEndDay) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
}).length
|
||||
);
|
||||
}
|
||||
|
||||
case "range": {
|
||||
const periodRangeStartDay = dayjs(periodStartDate).tz(organizerTimeZone).endOf("day");
|
||||
const periodRangeEndDay = dayjs(periodEndDate).tz(organizerTimeZone).endOf("day");
|
||||
return (
|
||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||
date.endOf("day").isBefore(periodRangeStartDay) ||
|
||||
date.endOf("day").isAfter(periodRangeEndDay) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
}).length
|
||||
);
|
||||
}
|
||||
|
||||
case "unlimited":
|
||||
default:
|
||||
return (
|
||||
date.endOf("day").isBefore(dayjs().utcOffset(date.utcOffset())) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
organizerTimeZone,
|
||||
}).length
|
||||
);
|
||||
}
|
||||
const date = browsingDate.startOf("day").date(day);
|
||||
return (
|
||||
isOutOfBounds(date, {
|
||||
periodType,
|
||||
periodStartDate,
|
||||
periodEndDate,
|
||||
periodCountCalendarDays,
|
||||
periodDays,
|
||||
}) ||
|
||||
!getSlots({
|
||||
inviteeDate: date,
|
||||
frequency: eventLength,
|
||||
minimumBookingNotice,
|
||||
workingHours,
|
||||
}).length
|
||||
);
|
||||
};
|
||||
|
||||
const daysInMonth = inviteeDate().daysInMonth();
|
||||
const daysInMonth = browsingDate.daysInMonth();
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({ disabled: isDisabled(i), date: i });
|
||||
}
|
||||
|
||||
setDays(days);
|
||||
return days;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedMonth]);
|
||||
}, [browsingDate]);
|
||||
|
||||
if (!browsingDate) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
// Handle month changes
|
||||
const incrementMonth = () => {
|
||||
setBrowsingDate(browsingDate?.add(1, "month"));
|
||||
};
|
||||
|
||||
const decrementMonth = () => {
|
||||
setBrowsingDate(browsingDate?.subtract(1, "month"));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -137,39 +166,30 @@ function DatePicker({
|
||||
? "w-full sm:w-1/2 md:w-1/3 sm:border-r sm:dark:border-gray-800 sm:pl-4 sm:pr-6 "
|
||||
: "w-full sm:pl-4")
|
||||
}>
|
||||
<div className="flex text-gray-600 font-light text-xl mb-4">
|
||||
<div className="flex mb-4 text-xl font-light text-gray-600">
|
||||
<span className="w-1/2 text-gray-600 dark:text-white">
|
||||
<strong className="text-gray-900 dark:text-white">
|
||||
{t(inviteeDate().format("MMMM").toLowerCase())}
|
||||
</strong>{" "}
|
||||
<span className="text-gray-500">{inviteeDate().format("YYYY")}</span>
|
||||
<strong className="text-gray-900 dark:text-white">{month}</strong>{" "}
|
||||
<span className="text-gray-500">{year}</span>
|
||||
</span>
|
||||
<div className="w-1/2 text-right text-gray-600 dark:text-gray-400">
|
||||
<button
|
||||
onClick={decrementMonth}
|
||||
className={classNames(
|
||||
"group mr-2 p-1",
|
||||
typeof selectedMonth === "number" &&
|
||||
selectedMonth <= dayjs().month() &&
|
||||
"text-gray-400 dark:text-gray-600"
|
||||
)}
|
||||
disabled={typeof selectedMonth === "number" && selectedMonth <= dayjs().month()}
|
||||
className={classNames("group mr-2 p-1", isFirstMonth && "text-gray-400 dark:text-gray-600")}
|
||||
disabled={isFirstMonth}
|
||||
data-testid="decrementMonth">
|
||||
<ChevronLeftIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
||||
<ChevronLeftIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
|
||||
</button>
|
||||
<button className="group p-1" onClick={incrementMonth} data-testid="incrementMonth">
|
||||
<ChevronRightIcon className="group-hover:text-black dark:group-hover:text-white w-5 h-5" />
|
||||
<button className="p-1 group" onClick={incrementMonth} data-testid="incrementMonth">
|
||||
<ChevronRightIcon className="w-5 h-5 group-hover:text-black dark:group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-4 text-center border-b border-t dark:border-gray-800 sm:border-0">
|
||||
{["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
.sort((a, b) => (weekStart.startsWith(a) ? -1 : weekStart.startsWith(b) ? 1 : 0))
|
||||
.map((weekDay) => (
|
||||
<div key={weekDay} className="uppercase text-gray-500 text-xs tracking-widest my-4">
|
||||
{t(weekDay.toLowerCase()).substring(0, 3)}
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-7 gap-4 text-center border-t border-b dark:border-gray-800 sm:border-0">
|
||||
{weekdayNames(i18n.language, weekStart === "Sunday" ? 0 : 1, "short").map((weekDay) => (
|
||||
<div key={weekDay} className="my-4 text-xs tracking-widest text-gray-500 uppercase">
|
||||
{weekDay}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2 text-center">
|
||||
{days.map((day, idx) => (
|
||||
@@ -178,23 +198,21 @@ function DatePicker({
|
||||
style={{
|
||||
paddingTop: "100%",
|
||||
}}
|
||||
className="w-full relative">
|
||||
className="relative w-full">
|
||||
{day === null ? (
|
||||
<div key={`e-${idx}`} />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onDatePicked(inviteeDate().date(day.date))}
|
||||
onClick={() => onDatePicked(browsingDate.date(day.date))}
|
||||
disabled={day.disabled}
|
||||
className={classNames(
|
||||
"absolute w-full top-0 left-0 right-0 bottom-0 rounded-sm text-center mx-auto",
|
||||
"hover:border hover:border-brand dark:hover:border-white",
|
||||
day.disabled
|
||||
? "text-gray-400 font-light hover:border-0 cursor-default"
|
||||
: "dark:text-white text-primary-500 font-medium",
|
||||
date && date.isSame(inviteeDate().date(day.date), "day")
|
||||
? "bg-brand text-white-important"
|
||||
day.disabled ? "text-gray-400 font-light hover:border-0 cursor-default" : "font-medium",
|
||||
date && date.isSame(browsingDate.date(day.date), "day")
|
||||
? "bg-brand text-brandcontrast"
|
||||
: !day.disabled
|
||||
? " bg-gray-100 dark:bg-gray-600"
|
||||
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
|
||||
: ""
|
||||
)}
|
||||
data-testid="day"
|
||||
|
||||
@@ -35,19 +35,19 @@ const TimeOptions: FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
return selectedTimeZone !== "" ? (
|
||||
<div className="absolute z-10 w-full max-w-80 rounded-sm border border-gray-200 dark:bg-gray-700 dark:border-0 bg-white px-4 py-2">
|
||||
<div className="absolute z-10 w-full px-4 py-2 bg-white border border-gray-200 rounded-sm max-w-80 dark:bg-gray-700 dark:border-0">
|
||||
<div className="flex mb-4">
|
||||
<div className="w-1/2 dark:text-white text-gray-600 font-medium">{t("time_options")}</div>
|
||||
<div className="w-1/2 font-medium text-gray-600 dark:text-white">{t("time_options")}</div>
|
||||
<div className="w-1/2">
|
||||
<Switch.Group as="div" className="flex items-center justify-end">
|
||||
<Switch.Label as="span" className="mr-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">{t("am_pm")}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-white">{t("am_pm")}</span>
|
||||
</Switch.Label>
|
||||
<Switch
|
||||
checked={is24hClock}
|
||||
onChange={handle24hClockToggle}
|
||||
className={classNames(
|
||||
is24hClock ? "bg-brand" : "dark:bg-gray-600 bg-gray-200",
|
||||
is24hClock ? "bg-brand text-brandcontrast" : "dark:bg-gray-600 bg-gray-200",
|
||||
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black"
|
||||
)}>
|
||||
<span className="sr-only">{t("use_setting")}</span>
|
||||
@@ -60,7 +60,7 @@ const TimeOptions: FC<Props> = (props) => {
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm dark:text-white text-gray-500">{t("24_h")}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-white">{t("24_h")}</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@ const TimeOptions: FC<Props> = (props) => {
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(tz: ITimezoneOption) => setSelectedTimeZone(tz.value)}
|
||||
className="mb-2 shadow-sm focus:ring-black focus:border-brand mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full mt-1 mb-2 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -61,7 +61,6 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
}, [telemetry]);
|
||||
|
||||
const changeDate = (newDate: Dayjs) => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()));
|
||||
router.replace(
|
||||
{
|
||||
query: {
|
||||
@@ -93,8 +92,8 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
<HeadSeo
|
||||
title={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title} | ${profile.name}`}
|
||||
description={`${rescheduleUid ? t("reschedule") : ""} ${eventType.title}`}
|
||||
name={profile.name}
|
||||
avatar={profile.image}
|
||||
name={profile.name || undefined}
|
||||
avatar={profile.image || undefined}
|
||||
/>
|
||||
<CustomBranding val={profile.brandColor} />
|
||||
<div>
|
||||
@@ -109,14 +108,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
<div className="block p-4 sm:p-8 md:hidden">
|
||||
<div className="flex items-center">
|
||||
<AvatarGroup
|
||||
items={[{ image: profile.image, alt: profile.name }].concat(
|
||||
eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
image: user.avatar,
|
||||
}))
|
||||
)}
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
image: user.avatar || undefined,
|
||||
alt: user.name || undefined,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
}
|
||||
size={9}
|
||||
truncateAfter={5}
|
||||
/>
|
||||
@@ -153,14 +156,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
(selectedDate ? "sm:w-1/3" : "sm:w-1/2")
|
||||
}>
|
||||
<AvatarGroup
|
||||
items={[{ image: profile.image, alt: profile.name }].concat(
|
||||
eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
image: user.avatar,
|
||||
}))
|
||||
)}
|
||||
items={
|
||||
[
|
||||
{ image: profile.image, alt: profile.name, title: profile.name },
|
||||
...eventType.users
|
||||
.filter((user) => user.name !== profile.name)
|
||||
.map((user) => ({
|
||||
title: user.name,
|
||||
alt: user.name,
|
||||
image: user.avatar,
|
||||
})),
|
||||
].filter((item) => !!item.image) as { image: string; alt?: string; title?: string }[]
|
||||
}
|
||||
size={10}
|
||||
truncateAfter={3}
|
||||
/>
|
||||
@@ -209,10 +216,10 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
|
||||
|
||||
{selectedDate && (
|
||||
<AvailableTimes
|
||||
workingHours={workingHours}
|
||||
timeFormat={timeFormat}
|
||||
minimumBookingNotice={eventType.minimumBookingNotice}
|
||||
eventTypeId={eventType.id}
|
||||
slotInterval={eventType.slotInterval}
|
||||
eventLength={eventType.length}
|
||||
date={selectedDate}
|
||||
users={eventType.users}
|
||||
|
||||
@@ -9,24 +9,27 @@ import { EventTypeCustomInputType } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { stringify } from "querystring";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } 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 { createPaymentLink } from "@ee/lib/stripe/client";
|
||||
|
||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||
import { timeZone } from "@lib/clock";
|
||||
import { ensureArray } from "@lib/ensureArray";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import useTheme from "@lib/hooks/useTheme";
|
||||
import { LocationType } from "@lib/location";
|
||||
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||
import { parseZone } from "@lib/parseZone";
|
||||
import slugify from "@lib/slugify";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||
import { BookingCreateBody } from "@lib/types/booking";
|
||||
|
||||
import CustomBranding from "@components/CustomBranding";
|
||||
import { EmailInput, Form } from "@components/form/fields";
|
||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||
import { Button } from "@components/ui/Button";
|
||||
import PhoneInput from "@components/ui/form/PhoneInput";
|
||||
@@ -39,31 +42,76 @@ type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||
const BookingPage = (props: BookingPageProps) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { rescheduleUid } = router.query;
|
||||
/*
|
||||
* This was too optimistic
|
||||
* I started, then I remembered what a beast book/event.ts is
|
||||
* Gave up shortly after. One day. Maybe.
|
||||
*
|
||||
const mutation = trpc.useMutation("viewer.bookEvent", {
|
||||
onSuccess: ({ booking }) => {
|
||||
// go to success page.
|
||||
},
|
||||
});*/
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async ({ attendees, paymentUid, ...responseData }) => {
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
paymentUid,
|
||||
date,
|
||||
name: attendees[0].name,
|
||||
absolute: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const location = (function humanReadableLocation(location) {
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
if (location.includes("integration")) {
|
||||
return t("web_conferencing_details_to_follow");
|
||||
}
|
||||
return location;
|
||||
})(responseData.location);
|
||||
|
||||
return router.push({
|
||||
pathname: "/success",
|
||||
query: {
|
||||
date,
|
||||
type: props.eventType.id,
|
||||
user: props.profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
location,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rescheduleUid = router.query.rescheduleUid as string;
|
||||
const { isReady } = useTheme(props.profile.theme);
|
||||
|
||||
const date = asStringOrNull(router.query.date);
|
||||
const timeFormat = asStringOrNull(router.query.clock) === "24h" ? "H:mm" : "h:mma";
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [guestToggle, setGuestToggle] = useState(false);
|
||||
const [guestEmails, setGuestEmails] = useState([]);
|
||||
const locations = props.eventType.locations || [];
|
||||
const [guestToggle, setGuestToggle] = useState(props.booking && props.booking.attendees.length > 1);
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationType>(
|
||||
locations.length === 1 ? locations[0].type : ""
|
||||
type Location = { type: LocationType; address?: string };
|
||||
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
|
||||
const locations: Location[] = useMemo(
|
||||
() => (props.eventType.locations as Location[]) || [],
|
||||
[props.eventType.locations]
|
||||
);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
useEffect(() => {
|
||||
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
|
||||
}, []);
|
||||
if (router.query.guest) {
|
||||
setGuestToggle(true);
|
||||
}
|
||||
}, [router.query.guest]);
|
||||
|
||||
function toggleGuestEmailInput() {
|
||||
setGuestToggle(!guestToggle);
|
||||
}
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const locationInfo = (type: LocationType) => locations.find((location) => location.type === type);
|
||||
|
||||
@@ -76,113 +124,124 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
[LocationType.Daily]: "Daily.co Video",
|
||||
};
|
||||
|
||||
const _bookingHandler = (event) => {
|
||||
const book = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
let notes = "";
|
||||
if (props.eventType.customInputs) {
|
||||
notes = props.eventType.customInputs
|
||||
.map((input) => {
|
||||
const data = event.target["custom_" + input.id];
|
||||
if (data) {
|
||||
if (input.type === EventTypeCustomInputType.BOOL) {
|
||||
return input.label + "\n" + (data.checked ? t("yes") : t("no"));
|
||||
} else {
|
||||
return input.label + "\n" + data.value;
|
||||
}
|
||||
}
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
if (!!notes && !!event.target.notes.value) {
|
||||
notes += `\n\n${t("additional_notes")}:\n` + event.target.notes.value;
|
||||
} else {
|
||||
notes += event.target.notes.value;
|
||||
}
|
||||
|
||||
const payload: BookingCreateBody = {
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(props.eventType.length, "minute").format(),
|
||||
name: event.target.name.value,
|
||||
email: event.target.email.value,
|
||||
notes: notes,
|
||||
guests: guestEmails,
|
||||
eventTypeId: props.eventType.id,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
};
|
||||
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
|
||||
if (typeof router.query.user === "string") payload.user = router.query.user;
|
||||
|
||||
if (selectedLocation) {
|
||||
switch (selectedLocation) {
|
||||
case LocationType.Phone:
|
||||
payload["location"] = event.target.phone.value;
|
||||
break;
|
||||
|
||||
case LocationType.InPerson:
|
||||
payload["location"] = locationInfo(selectedLocation).address;
|
||||
break;
|
||||
|
||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||
default:
|
||||
payload["location"] = selectedLocation;
|
||||
}
|
||||
}
|
||||
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||
);
|
||||
|
||||
const content = await createBooking(payload).catch((e) => {
|
||||
console.error(e.message);
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
});
|
||||
|
||||
if (content?.id) {
|
||||
const params: { [k: string]: any } = {
|
||||
date,
|
||||
type: props.eventType.id,
|
||||
user: props.profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
};
|
||||
|
||||
if (payload["location"]) {
|
||||
if (payload["location"].includes("integration")) {
|
||||
params.location = t("web_conferencing_details_to_follow");
|
||||
} else {
|
||||
params.location = payload["location"];
|
||||
}
|
||||
}
|
||||
|
||||
const query = stringify(params);
|
||||
let successUrl = `/success?${query}`;
|
||||
|
||||
if (content?.paymentUid) {
|
||||
successUrl = createPaymentLink({
|
||||
paymentUid: content?.paymentUid,
|
||||
name: payload.name,
|
||||
date,
|
||||
absolute: false,
|
||||
});
|
||||
}
|
||||
|
||||
await router.push(successUrl);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
}
|
||||
type BookingFormValues = {
|
||||
name: string;
|
||||
email: string;
|
||||
notes?: string;
|
||||
locationType?: LocationType;
|
||||
guests?: string[];
|
||||
phone?: string;
|
||||
customInputs?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
book();
|
||||
};
|
||||
|
||||
const bookingHandler = useCallback(_bookingHandler, [guestEmails]);
|
||||
const defaultValues = () => {
|
||||
if (!rescheduleUid) {
|
||||
return {
|
||||
name: (router.query.name as string) || "",
|
||||
email: (router.query.email as string) || "",
|
||||
notes: (router.query.notes as string) || "",
|
||||
guests: ensureArray(router.query.guest) as string[],
|
||||
customInputs: props.eventType.customInputs.reduce(
|
||||
(customInputs, input) => ({
|
||||
...customInputs,
|
||||
[input.id]: router.query[slugify(input.label)],
|
||||
}),
|
||||
{}
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!props.booking || !props.booking.attendees.length) {
|
||||
return {};
|
||||
}
|
||||
const primaryAttendee = props.booking.attendees[0];
|
||||
if (!primaryAttendee) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
name: primaryAttendee.name || "",
|
||||
email: primaryAttendee.email || "",
|
||||
guests: props.booking.attendees.slice(1).map((attendee) => attendee.email),
|
||||
};
|
||||
};
|
||||
|
||||
const bookingForm = useForm<BookingFormValues>({
|
||||
defaultValues: defaultValues(),
|
||||
});
|
||||
|
||||
const selectedLocation = useWatch({
|
||||
control: bookingForm.control,
|
||||
name: "locationType",
|
||||
defaultValue: ((): LocationType | undefined => {
|
||||
if (router.query.location) {
|
||||
return router.query.location as LocationType;
|
||||
}
|
||||
if (locations.length === 1) {
|
||||
return locations[0]?.type;
|
||||
}
|
||||
})(),
|
||||
});
|
||||
|
||||
const getLocationValue = (booking: Pick<BookingFormValues, "locationType" | "phone">) => {
|
||||
const { locationType } = booking;
|
||||
switch (locationType) {
|
||||
case LocationType.Phone: {
|
||||
return booking.phone || "";
|
||||
}
|
||||
case LocationType.InPerson: {
|
||||
return locationInfo(locationType)?.address || "";
|
||||
}
|
||||
// Catches all other location types, such as Google Meet, Zoom etc.
|
||||
default:
|
||||
return selectedLocation || "";
|
||||
}
|
||||
};
|
||||
|
||||
const parseDate = (date: string | null) => {
|
||||
if (!date) return "No date";
|
||||
const parsedZone = parseZone(date);
|
||||
if (!parsedZone?.isValid()) return "Invalid date";
|
||||
const formattedTime = parsedZone?.format(timeFormat);
|
||||
return formattedTime + ", " + dayjs(date).toDate().toLocaleString(i18n.language, { dateStyle: "full" });
|
||||
};
|
||||
|
||||
const bookEvent = (booking: BookingFormValues) => {
|
||||
telemetry.withJitsu((jitsu) =>
|
||||
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||
);
|
||||
|
||||
// "metadata" is a reserved key to allow for connecting external users without relying on the email address.
|
||||
// <...url>&metadata[user_id]=123 will be send as a custom input field as the hidden type.
|
||||
const metadata = Object.keys(router.query)
|
||||
.filter((key) => key.startsWith("metadata"))
|
||||
.reduce(
|
||||
(metadata, key) => ({
|
||||
...metadata,
|
||||
[key.substring("metadata[".length, key.length - 1)]: router.query[key],
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
mutation.mutate({
|
||||
...booking,
|
||||
start: dayjs(date).format(),
|
||||
end: dayjs(date).add(props.eventType.length, "minute").format(),
|
||||
eventTypeId: props.eventType.id,
|
||||
timeZone: timeZone(),
|
||||
language: i18n.language,
|
||||
rescheduleUid,
|
||||
user: router.query.user,
|
||||
location: getLocationValue(booking.locationType ? booking : { locationType: selectedLocation }),
|
||||
metadata,
|
||||
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
label: props.eventType.customInputs.find((input) => input.id === parseInt(inputId))!.label,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
value: booking.customInputs![inputId],
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -209,12 +268,12 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
<div className="sm:w-1/2 sm:border-r sm:dark:border-gray-800">
|
||||
<AvatarGroup
|
||||
size={14}
|
||||
items={[{ image: props.profile.image, alt: props.profile.name }].concat(
|
||||
items={[{ image: props.profile.image || "", alt: props.profile.name || "" }].concat(
|
||||
props.eventType.users
|
||||
.filter((user) => user.name !== props.profile.name)
|
||||
.map((user) => ({
|
||||
image: user.avatar,
|
||||
title: user.name,
|
||||
image: user.avatar || "",
|
||||
alt: user.name || "",
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
@@ -243,30 +302,30 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
{selectedLocation === LocationType.InPerson && (
|
||||
<p className="mb-2 text-gray-500">
|
||||
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{locationInfo(selectedLocation).address}
|
||||
{getLocationValue({ locationType: selectedLocation })}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-4 text-green-500">
|
||||
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||
{parseZone(date).format(timeFormat + ", dddd DD MMMM YYYY")}
|
||||
{parseDate(date)}
|
||||
</p>
|
||||
<p className="mb-8 text-gray-600 dark:text-white">{props.eventType.description}</p>
|
||||
</div>
|
||||
<div className="sm:w-1/2 sm:pl-8 sm:pr-4">
|
||||
<form onSubmit={bookingHandler}>
|
||||
<Form form={bookingForm} handleSubmit={bookEvent}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("your_name")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
{...bookingForm.register("name")}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
required
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="John Doe"
|
||||
defaultValue={props.booking ? props.booking.attendees[0].name : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,15 +336,11 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
{t("email_address")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
inputMode="email"
|
||||
<EmailInput
|
||||
{...bookingForm.register("email")}
|
||||
required
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
defaultValue={props.booking ? props.booking.attendees[0].email : ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,16 +349,14 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
<span className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("location")}
|
||||
</span>
|
||||
{locations.map((location) => (
|
||||
<label key={location.type} className="block">
|
||||
{locations.map((location, i) => (
|
||||
<label key={i} className="block">
|
||||
<input
|
||||
type="radio"
|
||||
required
|
||||
onChange={(e) => setSelectedLocation(e.target.value)}
|
||||
className="w-4 h-4 mr-2 text-black border-gray-300 location focus:ring-black"
|
||||
name="location"
|
||||
{...bookingForm.register("locationType", { required: true })}
|
||||
value={location.type}
|
||||
checked={selectedLocation === location.type}
|
||||
defaultChecked={selectedLocation === location.type}
|
||||
/>
|
||||
<span className="ml-2 text-sm dark:text-gray-500">
|
||||
{locationLabels[location.type]}
|
||||
@@ -324,74 +377,78 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{props.eventType.customInputs &&
|
||||
props.eventType.customInputs
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((input) => (
|
||||
<div className="mb-4" key={"input-" + input.label.toLowerCase}>
|
||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||
{props.eventType.customInputs
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map((input) => (
|
||||
<div className="mb-4" key={input.id}>
|
||||
{input.type !== EventTypeCustomInputType.BOOL && (
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
||||
<textarea
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
rows={3}
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXT && (
|
||||
<input
|
||||
type="text"
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.NUMBER && (
|
||||
<input
|
||||
type="number"
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.BOOL && (
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...bookingForm.register(`customInputs.${input.id}`, {
|
||||
required: input.required,
|
||||
})}
|
||||
id={"custom_" + input.id}
|
||||
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
|
||||
placeholder=""
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXTLONG && (
|
||||
<textarea
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
rows={3}
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.TEXT && (
|
||||
<input
|
||||
type="text"
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={input.placeholder}
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.NUMBER && (
|
||||
<input
|
||||
type="number"
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
required={input.required}
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder=""
|
||||
/>
|
||||
)}
|
||||
{input.type === EventTypeCustomInputType.BOOL && (
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={"custom_" + input.id}
|
||||
id={"custom_" + input.id}
|
||||
className="w-4 h-4 mr-2 text-black border-gray-300 rounded focus:ring-black"
|
||||
placeholder=""
|
||||
required={input.required}
|
||||
/>
|
||||
<label
|
||||
htmlFor={"custom_" + input.id}
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{input.label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!props.eventType.disableGuests && (
|
||||
<div className="mb-4">
|
||||
{!guestToggle && (
|
||||
<label
|
||||
onClick={toggleGuestEmailInput}
|
||||
onClick={() => setGuestToggle(!guestToggle)}
|
||||
htmlFor="guests"
|
||||
className="block mb-1 text-sm font-medium text-blue-500 dark:text-white hover:cursor-pointer">
|
||||
className="block mb-1 text-sm font-medium dark:text-white hover:cursor-pointer">
|
||||
{/*<UserAddIcon className="inline-block w-5 h-5 mr-1 -mt-1" />*/}
|
||||
{t("additional_guests")}
|
||||
</label>
|
||||
)}
|
||||
@@ -402,27 +459,31 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
className="block mb-1 text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("guests")}
|
||||
</label>
|
||||
<ReactMultiEmail
|
||||
className="relative"
|
||||
placeholder="guest@example.com"
|
||||
emails={guestEmails}
|
||||
onChange={(_emails: string[]) => {
|
||||
setGuestEmails(_emails);
|
||||
}}
|
||||
getLabel={(
|
||||
email: string,
|
||||
index: number,
|
||||
removeEmail: (index: number) => void
|
||||
) => {
|
||||
return (
|
||||
<div data-tag key={index}>
|
||||
{email}
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
<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}>
|
||||
{email}
|
||||
<span data-tag-handle onClick={() => removeEmail(index)}>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -435,25 +496,23 @@ const BookingPage = (props: BookingPageProps) => {
|
||||
{t("additional_notes")}
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
{...bookingForm.register("notes")}
|
||||
id="notes"
|
||||
rows={3}
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder={t("share_additional_notes")}
|
||||
defaultValue={props.booking ? props.booking.description : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start space-x-2">
|
||||
{/* TODO: add styling props to <Button variant="" color="" /> and get rid of btn-primary */}
|
||||
<Button type="submit" loading={loading}>
|
||||
<Button type="submit" loading={mutation.isLoading}>
|
||||
{rescheduleUid ? t("reschedule") : t("confirm")}
|
||||
</Button>
|
||||
<Button color="secondary" type="button" onClick={() => router.back()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{error && (
|
||||
</Form>
|
||||
{mutation.isError && (
|
||||
<div className="p-4 mt-2 border-l-4 border-yellow-400 bg-yellow-50">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ExclamationIcon } from "@heroicons/react/outline";
|
||||
import { CheckIcon } from "@heroicons/react/solid";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import React, { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DialogClose, DialogContent } from "@components/Dialog";
|
||||
import { Button } from "@components/ui/Button";
|
||||
|
||||
export type ConfirmationDialogContentProps = {
|
||||
confirmBtn?: ReactNode;
|
||||
confirmBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
@@ -21,6 +22,7 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
const {
|
||||
title,
|
||||
variety,
|
||||
confirmBtn = null,
|
||||
confirmBtnText = t("confirm"),
|
||||
cancelBtnText = t("cancel"),
|
||||
onConfirm,
|
||||
@@ -33,34 +35,34 @@ export default function ConfirmationDialogContent(props: PropsWithChildren<Confi
|
||||
{variety && (
|
||||
<div className="mr-3 mt-0.5">
|
||||
{variety === "danger" && (
|
||||
<div className="text-center p-2 rounded-full mx-auto bg-red-100">
|
||||
<div className="p-2 mx-auto text-center bg-red-100 rounded-full">
|
||||
<ExclamationIcon className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "warning" && (
|
||||
<div className="text-center p-2 rounded-full mx-auto bg-orange-100">
|
||||
<div className="p-2 mx-auto text-center bg-orange-100 rounded-full">
|
||||
<ExclamationIcon className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "success" && (
|
||||
<div className="text-center p-2 rounded-full mx-auto bg-green-100">
|
||||
<div className="p-2 mx-auto text-center bg-green-100 rounded-full">
|
||||
<CheckIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DialogPrimitive.Title className="font-cal text-xl font-bold text-gray-900">
|
||||
<DialogPrimitive.Title className="text-xl font-bold text-gray-900 font-cal">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-neutral-500 text-sm">
|
||||
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
||||
{children}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-8 sm:flex sm:flex-row-reverse gap-x-2">
|
||||
<DialogClose onClick={onConfirm} asChild>
|
||||
<Button color="primary">{confirmBtnText}</Button>
|
||||
{confirmBtn || <Button color="primary">{confirmBtnText}</Button>}
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{cancelBtnText}</Button>
|
||||
|
||||
244
components/eventtype/CreateEventType.tsx
Normal file
244
components/eventtype/CreateEventType.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/solid";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
import createEventType from "@lib/mutations/event-types/create-event-type";
|
||||
import showToast from "@lib/notification";
|
||||
import { CreateEventType } from "@lib/types/event-type";
|
||||
|
||||
import { Dialog, DialogClose, DialogContent } from "@components/Dialog";
|
||||
import { TextField, InputLeading, TextAreaField, Form } from "@components/form/fields";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { Button } from "@components/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/ui/Dropdown";
|
||||
import * as RadioArea from "@components/ui/form/radio-area";
|
||||
|
||||
// this describes the uniform data needed to create a new event type on Profile or Team
|
||||
interface EventTypeParent {
|
||||
teamId: number | null | undefined; // if undefined, then it's a profile
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// set true for use on the team settings page
|
||||
canAddEvents: boolean;
|
||||
// set true when in use on the team settings page
|
||||
isIndividualTeam?: boolean;
|
||||
// EventTypeParent can be a profile (as first option) or a team for the rest.
|
||||
options: EventTypeParent[];
|
||||
}
|
||||
|
||||
export default function CreateEventTypeButton(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const modalOpen = useToggleQuery("new");
|
||||
|
||||
const form = useForm<CreateEventType>({
|
||||
defaultValues: { length: 15 },
|
||||
});
|
||||
const { setValue, watch, register } = form;
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name, type }) => {
|
||||
if (name === "title" && type === "change") {
|
||||
if (value.title) setValue("slug", value.title.replace(/\s+/g, "-").toLowerCase());
|
||||
else setValue("slug", "");
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, setValue]);
|
||||
|
||||
// URL encoded params
|
||||
const teamId: number | null = Number(router.query.teamId) || null;
|
||||
const pageSlug = router.query.eventPage || props.options[0].slug;
|
||||
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
|
||||
const createMutation = useMutation(createEventType, {
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.push("/event-types/" + eventType.id);
|
||||
showToast(t("event_type_created_successfully", { eventTypeTitle: eventType.title }), "success");
|
||||
},
|
||||
onError: (err: HttpError) => {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openModal = (option: EventTypeParent) => {
|
||||
// setTimeout fixes a bug where the url query params are removed immediately after opening the modal
|
||||
setTimeout(() => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
new: "1",
|
||||
eventPage: option.slug,
|
||||
...(option.teamId
|
||||
? {
|
||||
teamId: option.teamId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// remove url params after close modal to reset state
|
||||
const closeModal = () => {
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { id: router.query.id },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={modalOpen.isOn}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) closeModal();
|
||||
}}>
|
||||
{!hasTeams || props.isIndividualTeam ? (
|
||||
<Button
|
||||
onClick={() => openModal(props.options[0])}
|
||||
data-testid="new-event-type"
|
||||
StartIcon={PlusIcon}
|
||||
{...(props.canAddEvents
|
||||
? {
|
||||
href: modalOpen.hrefOn,
|
||||
}
|
||||
: {
|
||||
disabled: true,
|
||||
})}>
|
||||
{t("new_event_type_btn")}
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button EndIcon={ChevronDownIcon}>{t("new_event_type_btn")}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("new_event_subtitle")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{props.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.slug}
|
||||
className="px-3 py-2 cursor-pointer hover:bg-neutral-100 focus:outline-none"
|
||||
onSelect={() => openModal(option)}>
|
||||
<Avatar alt={option.name || ""} imageSrc={option.image} size={6} className="inline mr-2" />
|
||||
{option.name ? option.name : option.slug}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<DialogContent>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
|
||||
{teamId ? t("add_new_team_event_type") : t("add_new_event_type")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
const payload: CreateEventType = {
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
description: values.description,
|
||||
length: values.length,
|
||||
};
|
||||
if (router.query.teamId) {
|
||||
payload.teamId = parseInt(`${router.query.teamId}`, 10);
|
||||
payload.schedulingType = values.schedulingType as SchedulingType;
|
||||
}
|
||||
|
||||
createMutation.mutate(payload);
|
||||
}}>
|
||||
<div className="mt-3 space-y-4">
|
||||
<TextField label={t("title")} placeholder={t("quick_chat")} {...register("title")} />
|
||||
|
||||
<TextField
|
||||
label={t("url")}
|
||||
required
|
||||
addOnLeading={
|
||||
<InputLeading>
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{pageSlug}/
|
||||
</InputLeading>
|
||||
}
|
||||
{...register("slug")}
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
label={t("description")}
|
||||
placeholder={t("quick_video_meeting")}
|
||||
{...register("description")}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<TextField
|
||||
type="number"
|
||||
required
|
||||
placeholder="15"
|
||||
defaultValue={15}
|
||||
label={t("length")}
|
||||
className="pr-20"
|
||||
{...register("length")}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pt-4 mt-1.5 pr-3 text-sm text-gray-400">
|
||||
{t("minutes")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{teamId && (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="schedulingType" className="block text-sm font-bold text-gray-700">
|
||||
{t("scheduling_type")}
|
||||
</label>
|
||||
<RadioArea.Group
|
||||
{...register("schedulingType")}
|
||||
onChange={(val) => form.setValue("schedulingType", val as SchedulingType)}
|
||||
className="relative flex mt-1 space-x-6 rounded-sm shadow-sm">
|
||||
<RadioArea.Item value={SchedulingType.COLLECTIVE} className="w-1/2 text-sm">
|
||||
<strong className="block mb-1">{t("collective")}</strong>
|
||||
<p>{t("collective_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<RadioArea.Item value={SchedulingType.ROUND_ROBIN} className="w-1/2 text-sm">
|
||||
<strong className="block mb-1">{t("round_robin")}</strong>
|
||||
<p>{t("round_robin_description")}</p>
|
||||
</RadioArea.Item>
|
||||
</RadioArea.Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse mt-8 gap-x-2">
|
||||
<Button type="submit" loading={createMutation.isLoading}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button color="secondary">{t("cancel")}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,9 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||
<>
|
||||
<div className={classNames("text-neutral-500 dark:text-white", className)}>
|
||||
{eventType.description && (
|
||||
<h2 className="opacity-60 truncate max-w-[280px] sm:max-w-[500px]">
|
||||
<h2 className="opacity-60 text-ellipsis overflow-hidden max-w-[280px] sm:max-w-[500px]">
|
||||
{eventType.description.substring(0, 100)}
|
||||
{eventType.description.length > 100 && "..."}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex mt-2 space-x-4 ">
|
||||
|
||||
@@ -10,13 +10,14 @@ import showToast from "@lib/notification";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
|
||||
type InputProps = Omit<JSX.IntrinsicElements["input"], "name"> & { name: string };
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm",
|
||||
"mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-1 focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
@@ -31,6 +32,14 @@ export function Label(props: JSX.IntrinsicElements["label"]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function InputLeading(props: JSX.IntrinsicElements["div"]) {
|
||||
return (
|
||||
<span className="inline-flex items-center flex-shrink-0 px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type InputFieldProps = {
|
||||
label?: ReactNode;
|
||||
addOnLeading?: ReactNode;
|
||||
@@ -50,26 +59,28 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
|
||||
: "",
|
||||
className,
|
||||
addOnLeading,
|
||||
...passThroughToInput
|
||||
...passThrough
|
||||
} = props;
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
{!!props.name && (
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{addOnLeading ? (
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
{addOnLeading}
|
||||
<Input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
className={classNames(className, "mt-0")}
|
||||
{...passThroughToInput}
|
||||
className={classNames(className, "mt-0", props.addOnLeading && "rounded-l-none")}
|
||||
{...passThrough}
|
||||
ref={ref}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Input id={id} placeholder={placeholder} className={className} {...passThroughToInput} ref={ref} />
|
||||
<Input id={id} placeholder={placeholder} className={className} {...passThrough} ref={ref} />
|
||||
)}
|
||||
{methods?.formState?.errors[props.name] && (
|
||||
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
|
||||
@@ -89,8 +100,76 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
|
||||
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
export const EmailInput = forwardRef<HTMLInputElement, JSX.IntrinsicElements["input"]>(function EmailInput(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
inputMode="email"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const EmailField = forwardRef<HTMLInputElement, InputFieldProps>(function EmailField(props, ref) {
|
||||
return <InputField type="email" inputMode="email" ref={ref} {...props} />;
|
||||
return <EmailInput ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
type TextAreaProps = Omit<JSX.IntrinsicElements["textarea"], "name"> & { name: string };
|
||||
|
||||
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextAreaInput(props, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={classNames(
|
||||
"block w-full font-mono border-gray-300 rounded-sm shadow-sm focus:ring-neutral-900 focus:border-neutral-900 sm:text-sm",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type TextAreaFieldProps = {
|
||||
label?: ReactNode;
|
||||
} & React.ComponentProps<typeof TextArea> & {
|
||||
labelProps?: React.ComponentProps<typeof Label>;
|
||||
};
|
||||
|
||||
export const TextAreaField = forwardRef<HTMLTextAreaElement, TextAreaFieldProps>(function TextField(
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
const id = useId();
|
||||
const { t } = useLocale();
|
||||
const methods = useFormContext();
|
||||
const {
|
||||
label = t(props.name as string),
|
||||
labelProps,
|
||||
placeholder = t(props.name + "_placeholder") !== props.name + "_placeholder"
|
||||
? t(props.name + "_placeholder")
|
||||
: "",
|
||||
...passThrough
|
||||
} = props;
|
||||
return (
|
||||
<div>
|
||||
{!!props.name && (
|
||||
<Label htmlFor={id} {...labelProps}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<TextArea ref={ref} placeholder={placeholder} {...passThrough} />
|
||||
{methods?.formState?.errors[props.name] && (
|
||||
<Alert className="mt-1" severity="error" message={methods.formState.errors[props.name].message} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type FormProps<T> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { Fragment } from "react";
|
||||
import React, { Fragment, useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import Select from "react-select";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
@@ -98,63 +99,138 @@ function ConnectedCalendarsList(props: Props) {
|
||||
<QueryCell
|
||||
query={query}
|
||||
empty={() => null}
|
||||
success={({ data }) => (
|
||||
<List>
|
||||
{data.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<IntegrationListItem
|
||||
{...item.integration}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn">
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={props.onChanged}
|
||||
/>
|
||||
}>
|
||||
<ul className="p-4 space-y-2">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId as string}
|
||||
title={cal.name as string}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
success={({ data }) => {
|
||||
if (!data.connectedCalendars.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<List>
|
||||
{data.connectedCalendars.map((item) => (
|
||||
<Fragment key={item.credentialId}>
|
||||
{item.calendars ? (
|
||||
<IntegrationListItem
|
||||
{...item.integration}
|
||||
description={item.primary?.externalId || "No external Id"}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={props.onChanged}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</IntegrationListItem>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
message={item.error?.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn">
|
||||
{t("disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
}>
|
||||
<ul className="p-4 space-y-2">
|
||||
{item.calendars.map((cal) => (
|
||||
<CalendarSwitch
|
||||
key={cal.externalId}
|
||||
externalId={cal.externalId as string}
|
||||
title={cal.name as string}
|
||||
type={item.integration.type}
|
||||
defaultSelected={cal.isSelected}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</IntegrationListItem>
|
||||
) : (
|
||||
<Alert
|
||||
severity="warning"
|
||||
title="Something went wrong"
|
||||
message={item.error?.message}
|
||||
actions={
|
||||
<DisconnectIntegration
|
||||
id={item.credentialId}
|
||||
render={(btnProps) => (
|
||||
<Button {...btnProps} color="warn" data-testid="integration-connection-button">
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
onOpenChange={() => props.onChanged()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PrimaryCalendarSelector() {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.connectedCalendars"], {
|
||||
suspense: true,
|
||||
});
|
||||
const [selectedOption, setSelectedOption] = useState(() => {
|
||||
const selected = query.data?.connectedCalendars
|
||||
.map((connected) => connected.calendars ?? [])
|
||||
.flat()
|
||||
.find((cal) => cal.externalId === query.data.destinationCalendar?.externalId);
|
||||
|
||||
if (!selected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
value: `${selected.integration}:${selected.externalId}`,
|
||||
label: selected.name,
|
||||
};
|
||||
});
|
||||
|
||||
const mutation = trpc.useMutation("viewer.setUserDestinationCalendar");
|
||||
|
||||
if (!query.data?.connectedCalendars.length) {
|
||||
return null;
|
||||
}
|
||||
const options =
|
||||
query.data.connectedCalendars.map((selectedCalendar) => ({
|
||||
key: selectedCalendar.credentialId,
|
||||
label: `${selectedCalendar.integration.title} (${selectedCalendar.primary?.name})`,
|
||||
options: (selectedCalendar.calendars ?? []).map((cal) => ({
|
||||
label: cal.name || "",
|
||||
value: `${cal.integration}:${cal.externalId}`,
|
||||
})),
|
||||
})) ?? [];
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* There's no easy way to customize the displayed value for a Select, so we fake it. */}
|
||||
<div className="absolute z-10 pointer-events-none">
|
||||
<Button size="sm" color="secondary" className="border-transparent m-[1px] rounded-sm">
|
||||
{t("select_destination_calendar")}: {selectedOption?.label || ""}
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
name={"primarySelectedCalendar"}
|
||||
placeholder={`${t("select_destination_calendar")}:`}
|
||||
options={options}
|
||||
isSearchable={false}
|
||||
className="flex-1 block w-full min-w-0 mt-1 mb-2 border-gray-300 rounded-none focus:ring-primary-500 focus:border-primary-500 rounded-r-md sm:text-sm"
|
||||
onChange={(option) => {
|
||||
setSelectedOption(option);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Split only the first `:`, since Apple uses the full URL as externalId */
|
||||
const [integration, externalId] = option.value.split(/:(.+)/);
|
||||
|
||||
mutation.mutate({
|
||||
integration,
|
||||
externalId,
|
||||
});
|
||||
}}
|
||||
isLoading={mutation.isLoading}
|
||||
value={selectedOption}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarList(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const query = trpc.useQuery(["viewer.integrations"]);
|
||||
@@ -172,7 +248,7 @@ function CalendarList(props: Props) {
|
||||
<ConnectIntegration
|
||||
type={item.type}
|
||||
render={(btnProps) => (
|
||||
<Button color="secondary" {...btnProps}>
|
||||
<Button color="secondary" {...btnProps} data-testid="integration-connection-button">
|
||||
{t("connect")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -200,13 +276,23 @@ export function CalendarListContainer(props: { heading?: false }) {
|
||||
<>
|
||||
{heading && (
|
||||
<ShellSubHeading
|
||||
className="mt-10"
|
||||
title={<SubHeadingTitleWithConnections title={t("calendar")} numConnections={query.data?.length} />}
|
||||
className="mt-10 mb-0"
|
||||
title={
|
||||
<SubHeadingTitleWithConnections
|
||||
title="Calendars"
|
||||
numConnections={query.data?.connectedCalendars.length}
|
||||
/>
|
||||
}
|
||||
subtitle={t("configure_how_your_event_types_interact")}
|
||||
actions={
|
||||
<div className="block max-w-full sm:min-w-80">
|
||||
<PrimaryCalendarSelector />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ConnectedCalendarsList onChanged={onChanged} />
|
||||
{!!query.data?.length && (
|
||||
{!!query.data?.connectedCalendars.length && (
|
||||
<ShellSubHeading
|
||||
className="mt-6"
|
||||
title={<SubHeadingTitleWithConnections title={t("connect_an_additional_calendar")} />}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
|
||||
import { AddAppleIntegrationModal } from "@lib/integrations/Apple/components/AddAppleIntegration";
|
||||
import { AddCalDavIntegrationModal } from "@lib/integrations/CalDav/components/AddCalDavIntegration";
|
||||
import { AddAppleIntegrationModal } from "@lib/integrations/calendar/components/AddAppleIntegration";
|
||||
import { AddCalDavIntegrationModal } from "@lib/integrations/calendar/components/AddCalDavIntegration";
|
||||
|
||||
import { ButtonBaseProps } from "@components/ui/Button";
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import Select, { OptionTypeBase } from "react-select";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
interface Props {
|
||||
onSubmit: SubmitHandler<IFormInput>;
|
||||
onCancel: () => void;
|
||||
@@ -82,7 +84,7 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
<input
|
||||
type="text"
|
||||
id="placeholder"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-sm"
|
||||
className="block w-full border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
defaultValue={selectedCustomInput?.placeholder}
|
||||
{...register("placeholder")}
|
||||
/>
|
||||
@@ -114,12 +116,10 @@ const CustomInputTypeForm: FC<Props> = (props) => {
|
||||
{...register("id", { valueAsNumber: true })}
|
||||
/>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("save")}
|
||||
</button>
|
||||
<button onClick={onCancel} type="button" className="mr-2 btn btn-white">
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
<Button onClick={onCancel} type="button" color="secondary" className="mr-2">
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ErrorCode } from "@lib/auth";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
const ChangePasswordSection = () => {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
@@ -97,11 +99,7 @@ const ChangePasswordSection = () => {
|
||||
</div>
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
<div className="py-8 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
{t("save")}
|
||||
</button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,11 @@ import React from "react";
|
||||
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
|
||||
return (
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-brand text-brandcontrast bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ShieldCheckIcon className="w-6 h-6 text-black" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900 font-cal" id="modal-title">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">{description}</p>
|
||||
|
||||
@@ -10,8 +10,8 @@ export type HeadSeoProps = {
|
||||
description: string;
|
||||
siteName?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
url?: string;
|
||||
username?: string;
|
||||
canonical?: string;
|
||||
nextSeoProps?: NextSeoProps;
|
||||
};
|
||||
@@ -39,9 +39,6 @@ const buildSeoMeta = (pageProps: {
|
||||
images: [
|
||||
{
|
||||
url: image,
|
||||
//width: 1077,
|
||||
//height: 565,
|
||||
//alt: "Alt image"
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -66,11 +63,14 @@ const buildSeoMeta = (pageProps: {
|
||||
};
|
||||
};
|
||||
|
||||
const constructImage = (name: string, avatar: string, description: string): string => {
|
||||
const constructImage = (name: string, description: string, username: string): string => {
|
||||
return (
|
||||
encodeURIComponent("Meet **" + name + "** <br>" + description).replace(/'/g, "%27") +
|
||||
".png?md=1&images=https%3A%2F%2Fcal.com%2Flogo-white.svg&images=" +
|
||||
encodeURIComponent(avatar)
|
||||
(process.env.NEXT_PUBLIC_APP_URL || process.env.BASE_URL) +
|
||||
"/" +
|
||||
username +
|
||||
"/avatar.png"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,18 +82,31 @@ export const HeadSeo: React.FC<HeadSeoProps & { children?: never }> = (props) =>
|
||||
title,
|
||||
description,
|
||||
name = null,
|
||||
avatar = null,
|
||||
username = null,
|
||||
siteName,
|
||||
canonical = defaultUrl,
|
||||
nextSeoProps = {},
|
||||
} = props;
|
||||
|
||||
const truncatedDescription = description.length > 24 ? description.substring(0, 23) + "..." : description;
|
||||
const pageTitle = title + " | Cal.com";
|
||||
let seoObject = buildSeoMeta({ title: pageTitle, image, description, canonical, siteName });
|
||||
let seoObject = buildSeoMeta({
|
||||
title: pageTitle,
|
||||
image,
|
||||
description: truncatedDescription,
|
||||
canonical,
|
||||
siteName,
|
||||
});
|
||||
|
||||
if (name && avatar) {
|
||||
const pageImage = getSeoImage("ogImage") + constructImage(name, avatar, description);
|
||||
seoObject = buildSeoMeta({ title: pageTitle, description, image: pageImage, canonical, siteName });
|
||||
if (name && username) {
|
||||
const pageImage = getSeoImage("ogImage") + constructImage(name, truncatedDescription, username);
|
||||
seoObject = buildSeoMeta({
|
||||
title: pageTitle,
|
||||
description: truncatedDescription,
|
||||
image: pageImage,
|
||||
canonical,
|
||||
siteName,
|
||||
});
|
||||
}
|
||||
|
||||
const seoProps: NextSeoProps = merge(nextSeoProps, seoObject);
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import showToast from "@lib/notification";
|
||||
import { Team } from "@lib/team";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import MemberInvitationModal from "@components/team/MemberInvitationModal";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import { UsernameInput } from "@components/ui/UsernameInput";
|
||||
import ErrorAlert from "@components/ui/alerts/Error";
|
||||
|
||||
import MemberList from "./MemberList";
|
||||
|
||||
export default function EditTeam(props: { team: Team | undefined | null; onCloseEdit: () => void }) {
|
||||
const [members, setMembers] = useState([]);
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
|
||||
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const [inviteModalTeam, setInviteModalTeam] = useState<Team | null | undefined>();
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
|
||||
const loadMembers = () =>
|
||||
fetch("/api/teams/" + props.team?.id + "/membership")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMembers(data.members));
|
||||
|
||||
useEffect(() => {
|
||||
loadMembers();
|
||||
}, []);
|
||||
|
||||
const deleteTeam = () => {
|
||||
return fetch("/api/teams/" + props.team?.id, {
|
||||
method: "DELETE",
|
||||
}).then(props.onCloseEdit());
|
||||
};
|
||||
|
||||
const onRemoveMember = (member: Member) => {
|
||||
return fetch("/api/teams/" + props.team?.id + "/membership", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ userId: member.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(loadMembers);
|
||||
};
|
||||
|
||||
const onInviteMember = (team: Team | null | undefined) => {
|
||||
setShowMemberInvitationModal(true);
|
||||
setInviteModalTeam(team);
|
||||
};
|
||||
|
||||
const handleError = async (resp: Response) => {
|
||||
if (!resp.ok) {
|
||||
const error = await resp.json();
|
||||
throw new Error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
async function updateTeamHandler(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const enteredUsername = teamUrlRef?.current?.value.toLowerCase();
|
||||
const enteredName = nameRef?.current?.value;
|
||||
const enteredDescription = descriptionRef?.current?.value;
|
||||
const enteredLogo = logoRef?.current?.value;
|
||||
const enteredHideBranding = hideBrandingRef?.current?.checked;
|
||||
|
||||
// TODO: Add validation
|
||||
|
||||
await fetch("/api/teams/" + props.team?.id + "/profile", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
username: enteredUsername,
|
||||
name: enteredName,
|
||||
description: enteredDescription,
|
||||
logo: enteredLogo,
|
||||
hideBranding: enteredHideBranding,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleError)
|
||||
.then(() => {
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
})
|
||||
.catch((err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
const onMemberInvitationModalExit = () => {
|
||||
loadMembers();
|
||||
setShowMemberInvitationModal(false);
|
||||
};
|
||||
|
||||
const handleLogoChange = (newLogo: string) => {
|
||||
logoRef.current.value = newLogo;
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement?.prototype, "value").set;
|
||||
nativeInputValueSetter?.call(logoRef.current, newLogo);
|
||||
const ev2 = new Event("input", { bubbles: true });
|
||||
logoRef?.current?.dispatchEvent(ev2);
|
||||
updateTeamHandler(ev2);
|
||||
setImageSrc(newLogo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => props.onCloseEdit()}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-5 pr-4 sm:pb-6">
|
||||
<h3 className="text-lg font-bold leading-6 text-gray-900">{props.team?.name}</h3>
|
||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||
<p>{t("manage_your_team")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-2" />
|
||||
<h3 className="font-cal font-bold leading-6 text-gray-900 mt-7 text-md">{t("profile")}</h3>
|
||||
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateTeamHandler}>
|
||||
{hasErrors && <ErrorAlert message={errorMessage} />}
|
||||
<div className="py-6 lg:pb-8">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<div className="block sm:flex">
|
||||
<div className="w-full mb-6 sm:w-1/2 sm:mr-2">
|
||||
<UsernameInput
|
||||
ref={teamUrlRef}
|
||||
defaultValue={props.team?.slug}
|
||||
label={t("my_team_url")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-1/2 sm:ml-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("team_name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
defaultValue={props.team?.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
|
||||
{t("about")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
id="about"
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={props.team?.bio}
|
||||
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"></textarea>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex mt-1">
|
||||
<Avatar
|
||||
className="relative w-10 h-10 rounded-full"
|
||||
imageSrc={imageSrc ? imageSrc : props.team?.logo}
|
||||
displayName="Logo"
|
||||
/>
|
||||
<input
|
||||
ref={logoRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
defaultValue={imageSrc ?? props.team?.logo}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={imageSrc !== "" ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={handleLogoChange}
|
||||
imageSrc={imageSrc ?? props.team?.logo}
|
||||
/>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<div className="flex justify-between mt-7">
|
||||
<h3 className="font-cal font-bold leading-6 text-gray-900 text-md">{t("members")}</h3>
|
||||
<div className="relative flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon={PlusIcon}
|
||||
onClick={() => onInviteMember(props.team)}>
|
||||
{t("new_member")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{!!members.length && (
|
||||
<MemberList members={members} onRemoveMember={onRemoveMember} onChange={loadMembers} />
|
||||
)}
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="hide-branding"
|
||||
name="hide-branding"
|
||||
type="checkbox"
|
||||
ref={hideBrandingRef}
|
||||
defaultChecked={props.team?.hideBranding}
|
||||
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
<h3 className="font-bold leading-6 text-gray-900 mt-7 text-md">{t("danger_zone")}</h3>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn-sm btn-white">
|
||||
<TrashIcon className="group-hover:text-red text-gray-700 w-3.5 h-3.5 mr-2 inline-block" />
|
||||
{t("disband_team")}
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => deleteTeam()}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-8" />
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{showMemberInvitationModal && (
|
||||
<MemberInvitationModal team={inviteModalTeam} onExit={onMemberInvitationModalExit} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
components/team/MemberChangeRoleModal.tsx
Normal file
86
components/team/MemberChangeRoleModal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
export default function MemberChangeRoleModal(props: {
|
||||
memberId: number;
|
||||
teamId: number;
|
||||
initialRole: MembershipRole;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
const [role, setRole] = useState(props.initialRole || MembershipRole.MEMBER);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const changeRoleMutation = trpc.useMutation("viewer.teams.changeMemberRole", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function changeRole(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
changeRoleMutation.mutate({
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalContainer>
|
||||
<>
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("change_member_role")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={changeRole}>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2 text-sm font-medium tracking-wide text-gray-700" htmlFor="role">
|
||||
{t("role")}
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as MembershipRole)}
|
||||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
{/*<option value="OWNER">{t("owner")}</option> - needs dialog to confirm change of ownership */}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-red-700">
|
||||
<span className="font-bold">Error: </span>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" color="primary" className="ml-2">
|
||||
{t("save")}
|
||||
</Button>
|
||||
<Button type="button" color="secondary" onClick={props.onExit}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
</ModalContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,50 @@
|
||||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { UserIcon } from "@heroicons/react/outline";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { useState } from "react";
|
||||
import React, { SyntheticEvent } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Team } from "@lib/team";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { EmailInput } from "@components/form/fields";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function MemberInvitationModal(props: { team: Team | undefined | null; onExit: () => void }) {
|
||||
export default function MemberInvitationModal(props: { team: TeamWithMembers | null; onExit: () => void }) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { t, i18n } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const handleError = async (res: Response) => {
|
||||
const responseData = await res.json();
|
||||
const inviteMemberMutation = trpc.useMutation("viewer.teams.inviteMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
props.onExit();
|
||||
},
|
||||
async onError(err) {
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (res.ok === false) {
|
||||
setErrorMessage(responseData.message);
|
||||
throw new Error(responseData.message);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
};
|
||||
|
||||
const inviteMember = (e: SyntheticEvent) => {
|
||||
function inviteMember(e: SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!props.team) return;
|
||||
|
||||
const target = e.target as typeof e.target & {
|
||||
elements: {
|
||||
role: { value: string };
|
||||
role: { value: MembershipRole };
|
||||
inviteUser: { value: string };
|
||||
sendInviteEmail: { checked: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
const payload = {
|
||||
inviteMemberMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
language: i18n.language,
|
||||
role: target.elements["role"].value,
|
||||
usernameOrEmail: target.elements["inviteUser"].value,
|
||||
sendEmailInvitation: target.elements["sendInviteEmail"].checked,
|
||||
};
|
||||
|
||||
return fetch("/api/teams/" + props?.team?.id + "/invite", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleError)
|
||||
.then(props.onExit)
|
||||
.catch(() => {
|
||||
// do nothing.
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -71,8 +63,8 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
|
||||
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-brand rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="w-6 h-6 text-black" />
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-brand text-brandcontrast bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UserIcon className="w-6 h-6 text-brandcontrast" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
@@ -89,7 +81,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
<label htmlFor="inviteUser" className="block text-sm font-medium text-gray-700">
|
||||
{t("email_or_username")}
|
||||
</label>
|
||||
<input
|
||||
<EmailInput
|
||||
type="text"
|
||||
name="inviteUser"
|
||||
id="inviteUser"
|
||||
@@ -106,7 +98,7 @@ export default function MemberInvitationModal(props: { team: Team | undefined |
|
||||
id="role"
|
||||
className="block w-full mt-1 border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm">
|
||||
<option value="MEMBER">{t("member")}</option>
|
||||
<option value="OWNER">{t("owner")}</option>
|
||||
<option value="ADMIN">{t("admin")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import { Member } from "@lib/member";
|
||||
import { inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import MemberListItem from "./MemberListItem";
|
||||
|
||||
export default function MemberList(props: {
|
||||
members: Member[];
|
||||
onRemoveMember: (text: Member) => void;
|
||||
onChange: (text: string) => void;
|
||||
}) {
|
||||
const selectAction = (action: string, member: Member) => {
|
||||
switch (action) {
|
||||
case "remove":
|
||||
props.onRemoveMember(member);
|
||||
break;
|
||||
}
|
||||
};
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
members: inferQueryOutput<"viewer.teams.get">["members"];
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
if (!props.members.length) return <></>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="px-6 mb-2 -mx-6 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
|
||||
{props.members.map((member) => (
|
||||
<MemberListItem
|
||||
onChange={props.onChange}
|
||||
key={member.id}
|
||||
member={member}
|
||||
onActionSelect={(action: string) => selectAction(action, member)}
|
||||
/>
|
||||
<ul className="px-4 mb-2 -mx-4 bg-white border divide-y divide-gray-200 rounded sm:px-4 sm:mx-0">
|
||||
{props.members?.map((member) => (
|
||||
<MemberListItem key={member.id} member={member} team={props.team} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,104 +1,177 @@
|
||||
import { DotsHorizontalIcon, UserRemoveIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
import { UserRemoveIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { ClockIcon, ExternalLinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import TeamAvailabilityModal from "@ee/components/team/availability/TeamAvailabilityModal";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { Member } from "@lib/member";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import ModalContainer from "@components/ui/ModalContainer";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/Dropdown";
|
||||
import MemberChangeRoleModal from "./MemberChangeRoleModal";
|
||||
import TeamRole from "./TeamRole";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
export default function MemberListItem(props: {
|
||||
member: Member;
|
||||
onActionSelect: (text: string) => void;
|
||||
onChange: (text: string) => void;
|
||||
}) {
|
||||
const [member] = useState(props.member);
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.get">;
|
||||
member: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
export default function MemberListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
|
||||
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
|
||||
|
||||
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("success"), "success");
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const name =
|
||||
props.member.name ||
|
||||
(() => {
|
||||
const emailName = props.member.email.split("@")[0];
|
||||
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
|
||||
})();
|
||||
|
||||
const removeMember = () =>
|
||||
removeMemberMutation.mutate({ teamId: props.team?.id, memberId: props.member.id });
|
||||
|
||||
return (
|
||||
member && (
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={
|
||||
props.member.avatar
|
||||
? props.member.avatar
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.member.name || "")
|
||||
}
|
||||
alt={props.member.name || ""}
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{props.member.name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{props.member.role === "INVITEE" && (
|
||||
<>
|
||||
<span className="self-center h-6 px-3 py-1 mr-2 text-xs text-yellow-700 capitalize rounded-md bg-yellow-50">
|
||||
{t("pending")}
|
||||
</span>
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
{t("member")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{props.member.role === "MEMBER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-pink-700 capitalize rounded-md bg-pink-50">
|
||||
{t("member")}
|
||||
</span>
|
||||
)}
|
||||
{props.member.role === "OWNER" && (
|
||||
<span className="self-center h-6 px-3 py-1 mr-4 text-xs text-blue-700 capitalize rounded-md bg-blue-50">
|
||||
{t("owner")}
|
||||
</span>
|
||||
)}
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex flex-col justify-between w-full sm:flex-row">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, name)}
|
||||
alt={name || ""}
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">{props.member.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{/* <div className="flex flex-col-reverse"> */}
|
||||
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger>
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={UserRemoveIcon}
|
||||
className="w-full">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={() => props.onActionSelect("remove")}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{/* </div> */}
|
||||
<div className="flex mt-2 mr-2 sm:mt-0 sm:justify-center">
|
||||
{!props.member.accepted && <TeamRole invitePending />}
|
||||
<TeamRole role={props.member.role} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
<div className="flex">
|
||||
<Tooltip content={t("team_view_user_availability")}>
|
||||
<Button
|
||||
// Disabled buttons don't trigger Tooltips
|
||||
title={
|
||||
props.member.accepted
|
||||
? t("team_view_user_availability")
|
||||
: t("team_view_user_availability_disabled")
|
||||
}
|
||||
disabled={!props.member.accepted}
|
||||
onClick={() => (props.member.accepted ? setShowTeamAvailabilityModal(true) : null)}
|
||||
color="minimal"
|
||||
className="hidden w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white sm:block">
|
||||
<ClockIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 hover:bg-white">
|
||||
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/" + props.member.username}>
|
||||
<a target="_blank">
|
||||
<Button color="minimal" StartIcon={ExternalLinkIcon} className="w-full font-normal">
|
||||
{t("view_public_page")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{(props.team.membership.role === MembershipRole.OWNER ||
|
||||
props.team.membership.role === MembershipRole.ADMIN) && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => setShowChangeMemberRoleModal(true)}
|
||||
color="minimal"
|
||||
StartIcon={PencilIcon}
|
||||
className="flex-shrink-0 w-full font-normal">
|
||||
{t("edit_role")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={UserRemoveIcon}
|
||||
className="w-full font-normal">
|
||||
{t("remove_member")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("remove_member")}
|
||||
confirmBtnText={t("confirm_remove_member")}
|
||||
onConfirm={removeMember}>
|
||||
{t("remove_member_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
{showChangeMemberRoleModal && (
|
||||
<MemberChangeRoleModal
|
||||
teamId={props.team?.id}
|
||||
memberId={props.member.id}
|
||||
initialRole={props.member.role as MembershipRole}
|
||||
onExit={() => setShowChangeMemberRoleModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showTeamAvailabilityModal && (
|
||||
<ModalContainer wide noPadding>
|
||||
<TeamAvailabilityModal team={props.team} member={props.member} />
|
||||
<div className="p-5 space-x-2 border-t">
|
||||
<Button onClick={() => setShowTeamAvailabilityModal(false)}>{t("done")}</Button>
|
||||
{props.team.membership.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<Button color="secondary">{t("Open Team Availability")}</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</ModalContainer>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
86
components/team/TeamCreateModal.tsx
Normal file
86
components/team/TeamCreateModal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { UsersIcon } from "@heroicons/react/outline";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TeamCreate(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
const createTeamMutation = trpc.useMutation("viewer.teams.create", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
props.onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const createTeam = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
createTeamMutation.mutate({ name: nameRef?.current?.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-sm shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<UsersIcon className="w-6 h-6 text-neutral-900" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("create_new_team")}
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("create_new_team_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={createTeam}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Acme Inc."
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("create_team")}
|
||||
</button>
|
||||
<button onClick={props.onClose} type="button" className="mr-2 btn btn-white">
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,44 @@
|
||||
import { Team } from "@lib/team";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import TeamListItem from "./TeamListItem";
|
||||
|
||||
export default function TeamList(props: {
|
||||
teams: Team[];
|
||||
onChange: () => void;
|
||||
onEditTeam: (text: Team) => void;
|
||||
}) {
|
||||
const selectAction = (action: string, team: Team) => {
|
||||
interface Props {
|
||||
teams: inferQueryOutput<"viewer.teams.list">;
|
||||
}
|
||||
|
||||
export default function TeamList(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
|
||||
function selectAction(action: string, teamId: number) {
|
||||
switch (action) {
|
||||
case "edit":
|
||||
props.onEditTeam(team);
|
||||
break;
|
||||
case "disband":
|
||||
deleteTeam(team);
|
||||
deleteTeam(teamId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const deleteTeam = async (team: Team) => {
|
||||
await fetch("/api/teams/" + team.id, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return props.onChange();
|
||||
};
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
async onError(err) {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam(teamId: number) {
|
||||
deleteTeamMutation.mutate({ teamId });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="px-4 mb-2 bg-white border divide-y divide-gray-200 rounded">
|
||||
{props.teams.map((team: Team) => (
|
||||
<ul className="mb-2 bg-white border divide-y rounded divide-neutral-200">
|
||||
{props.teams.map((team) => (
|
||||
<TeamListItem
|
||||
onChange={props.onChange}
|
||||
key={team.id}
|
||||
key={team?.id as number}
|
||||
team={team}
|
||||
onActionSelect={(action: string) => selectAction(action, team)}></TeamListItem>
|
||||
onActionSelect={(action: string) => selectAction(action, team?.id as number)}></TeamListItem>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,173 +1,212 @@
|
||||
import {
|
||||
DotsHorizontalIcon,
|
||||
ExternalLinkIcon,
|
||||
LinkIcon,
|
||||
PencilAltIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ExternalLinkIcon, TrashIcon, LogoutIcon, PencilIcon } from "@heroicons/react/outline";
|
||||
import { LinkIcon, DotsHorizontalIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import { Tooltip } from "@components/Tooltip";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import Dropdown, {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@components/ui/Dropdown";
|
||||
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/Dropdown";
|
||||
import TeamRole from "./TeamRole";
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
interface Team {
|
||||
id: number;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
logo: string | null;
|
||||
bio: string | null;
|
||||
role: string | null;
|
||||
hideBranding: boolean;
|
||||
prevState: null;
|
||||
interface Props {
|
||||
team: inferQueryOutput<"viewer.teams.list">[number];
|
||||
key: number;
|
||||
onActionSelect: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function TeamListItem(props: {
|
||||
onChange: () => void;
|
||||
key: number;
|
||||
team: Team;
|
||||
onActionSelect: (text: string) => void;
|
||||
}) {
|
||||
const [team, setTeam] = useState<Team | null>(props.team);
|
||||
export default function TeamListItem(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const team = props.team;
|
||||
|
||||
const acceptInvite = () => invitationResponse(true);
|
||||
const declineInvite = () => invitationResponse(false);
|
||||
|
||||
const invitationResponse = (accept: boolean) =>
|
||||
fetch("/api/user/membership", {
|
||||
method: accept ? "PATCH" : "DELETE",
|
||||
body: JSON.stringify({ teamId: props.team.id }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}).then(() => {
|
||||
// success
|
||||
setTeam(null);
|
||||
props.onChange();
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
},
|
||||
});
|
||||
function acceptOrLeave(accept: boolean) {
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: team?.id as number,
|
||||
accept,
|
||||
});
|
||||
}
|
||||
const acceptInvite = () => acceptOrLeave(true);
|
||||
const declineInvite = () => acceptOrLeave(false);
|
||||
|
||||
const isOwner = props.team.role === MembershipRole.OWNER;
|
||||
const isInvitee = !props.team.accepted;
|
||||
const isAdmin = props.team.role === MembershipRole.OWNER || props.team.role === MembershipRole.ADMIN;
|
||||
|
||||
if (!team) return <></>;
|
||||
|
||||
const teamInfo = (
|
||||
<div className="flex px-5 py-5">
|
||||
<Avatar
|
||||
size={9}
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="rounded-full w-9 h-9 min-w-9 min-h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{team.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
team && (
|
||||
<li className="divide-y">
|
||||
<div className="flex justify-between my-4">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
size={9}
|
||||
imageSrc={
|
||||
props.team.logo
|
||||
? props.team.logo
|
||||
: "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" +
|
||||
encodeURIComponent(props.team.name || "")
|
||||
}
|
||||
alt="Team Logo"
|
||||
className="rounded-full w-9 h-9"
|
||||
/>
|
||||
<div className="inline-block ml-3">
|
||||
<span className="text-sm font-bold text-neutral-700">{props.team.name}</span>
|
||||
<span className="block -mt-1 text-xs text-gray-400">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/team/{props.team.slug}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{props.team.role === "INVITEE" && (
|
||||
<div>
|
||||
<li className="divide-y">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex justify-between items-center",
|
||||
!isInvitee && "group hover:bg-neutral-50"
|
||||
)}>
|
||||
{!isInvitee ? (
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a className="flex-grow text-sm truncate cursor-pointer" title={`${team.name}`}>
|
||||
{teamInfo}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
teamInfo
|
||||
)}
|
||||
<div className="px-5 py-5">
|
||||
{isInvitee && (
|
||||
<>
|
||||
<Button type="button" color="secondary" onClick={declineInvite}>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button type="button" color="primary" className="ml-1" onClick={acceptInvite}>
|
||||
<Button type="button" color="primary" className="ml-2" onClick={acceptInvite}>
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{props.team.role === "MEMBER" && (
|
||||
<div>
|
||||
<Button type="button" color="primary" onClick={declineInvite}>
|
||||
{t("leave")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{props.team.role === "OWNER" && (
|
||||
<div className="flex space-x-4">
|
||||
<span className="self-center h-6 px-3 py-1 text-xs text-gray-700 capitalize rounded-md bg-gray-50">
|
||||
{t("owner")}
|
||||
</span>
|
||||
<Tooltip content={t("copy_link")}>
|
||||
{!isInvitee && (
|
||||
<div className="flex space-x-2">
|
||||
<TeamRole role={team.role as MembershipRole} />
|
||||
|
||||
<Tooltip content={t("copy_link_team")}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
process.env.NEXT_PUBLIC_APP_URL + "/team/" + props.team.slug
|
||||
);
|
||||
navigator.clipboard.writeText(process.env.NEXT_PUBLIC_APP_URL + "/team/" + team.slug);
|
||||
showToast(t("link_copied"), "success");
|
||||
}}
|
||||
className="w-10 h-10 transition-none"
|
||||
size="icon"
|
||||
color="minimal"
|
||||
StartIcon={LinkIcon}
|
||||
type="button"
|
||||
/>
|
||||
type="button">
|
||||
<LinkIcon className="w-5 h-5 group-hover:text-gray-600" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="group w-10 h-10 p-0 border border-transparent text-neutral-400 hover:border-gray-200">
|
||||
<DotsHorizontalIcon className="w-5 h-5" />
|
||||
<DropdownMenuTrigger className="w-10 h-10 p-0 border border-transparent group text-neutral-400 hover:border-gray-200 ">
|
||||
<DotsHorizontalIcon className="w-5 h-5 group-hover:text-gray-800" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem>
|
||||
<Link href={"/settings/teams/" + team.id}>
|
||||
<a>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full font-normal"
|
||||
StartIcon={PencilIcon}>
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && <DropdownMenuSeparator className="h-px bg-gray-200" />}
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full"
|
||||
onClick={() => props.onActionSelect("edit")}
|
||||
StartIcon={PencilAltIcon}>
|
||||
{" "}
|
||||
{t("edit_team")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team.slug}`} passHref={true}>
|
||||
<Link href={`${process.env.NEXT_PUBLIC_APP_URL}/team/${team.slug}`} passHref={true}>
|
||||
<a target="_blank">
|
||||
<Button type="button" color="minimal" className="w-full" StartIcon={ExternalLinkIcon}>
|
||||
<Button
|
||||
type="button"
|
||||
color="minimal"
|
||||
className="w-full font-normal"
|
||||
StartIcon={ExternalLinkIcon}>
|
||||
{" "}
|
||||
{t("preview_team")}
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="h-px bg-gray-200" />
|
||||
{isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
color="warn"
|
||||
StartIcon={TrashIcon}
|
||||
className="w-full font-normal">
|
||||
{t("disband_team")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={() => props.onActionSelect("disband")}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{!isOwner && (
|
||||
<DropdownMenuItem>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="warn"
|
||||
StartIcon={LogoutIcon}
|
||||
className="w-full"
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
37
components/team/TeamRole.tsx
Normal file
37
components/team/TeamRole.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
interface Props {
|
||||
role?: MembershipRole;
|
||||
invitePending?: boolean;
|
||||
}
|
||||
|
||||
export default function TeamRole(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames("self-center px-3 py-1 mr-2 text-xs capitalize border rounded-md", {
|
||||
"bg-blue-50 border-blue-200 text-blue-700": props.role === "MEMBER",
|
||||
"bg-gray-50 border-gray-200 text-gray-700": props.role === "OWNER",
|
||||
"bg-red-50 border-red-200 text-red-700": props.role === "ADMIN",
|
||||
"bg-yellow-50 border-yellow-200 text-yellow-700": props.invitePending,
|
||||
})}>
|
||||
{(() => {
|
||||
if (props.invitePending) return t("invitee");
|
||||
switch (props.role) {
|
||||
case "OWNER":
|
||||
return t("owner");
|
||||
case "ADMIN":
|
||||
return t("admin");
|
||||
case "MEMBER":
|
||||
return t("member");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
210
components/team/TeamSettings.tsx
Normal file
210
components/team/TeamSettings.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { HashtagIcon, InformationCircleIcon, LinkIcon, PhotographIcon } from "@heroicons/react/solid";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import ImageUploader from "@components/ImageUploader";
|
||||
import { TextField } from "@components/form/fields";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Button from "@components/ui/Button";
|
||||
import SettingInputContainer from "@components/ui/SettingInputContainer";
|
||||
|
||||
interface Props {
|
||||
team: TeamWithMembers | null | undefined;
|
||||
}
|
||||
|
||||
export default function TeamSettings(props: Props) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const team = props.team;
|
||||
const hasLogo = !!team?.logo;
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const mutation = trpc.useMutation("viewer.teams.update", {
|
||||
onError: (err) => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(err.message);
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
setHasErrors(false);
|
||||
},
|
||||
});
|
||||
|
||||
const nameRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const teamUrlRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
|
||||
const hideBrandingRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
const logoRef = useRef<HTMLInputElement>() as React.MutableRefObject<HTMLInputElement>;
|
||||
|
||||
function updateTeamData() {
|
||||
if (!team) return;
|
||||
const variables = {
|
||||
name: nameRef.current?.value,
|
||||
slug: teamUrlRef.current?.value,
|
||||
bio: descriptionRef.current?.value,
|
||||
hideBranding: hideBrandingRef.current?.checked,
|
||||
};
|
||||
// remove unchanged variables
|
||||
for (const key in variables) {
|
||||
//@ts-expect-error will fix types
|
||||
if (variables[key] === team?.[key]) delete variables[key];
|
||||
}
|
||||
mutation.mutate({ id: team.id, ...variables });
|
||||
}
|
||||
|
||||
function updateLogo(newLogo: string) {
|
||||
if (!team) return;
|
||||
logoRef.current.value = newLogo;
|
||||
mutation.mutate({ id: team.id, logo: newLogo });
|
||||
}
|
||||
|
||||
const removeLogo = () => updateLogo("");
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 lg:col-span-9">
|
||||
<div className="">
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
<form
|
||||
className="divide-y divide-gray-200 lg:col-span-9"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
updateTeamData();
|
||||
}}>
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="flex-grow space-y-6">
|
||||
<SettingInputContainer
|
||||
Icon={LinkIcon}
|
||||
label="Team URL"
|
||||
htmlFor="team-url"
|
||||
Input={
|
||||
<TextField
|
||||
name="" // typescript requires name but we don't want component to render name label
|
||||
id="team-url"
|
||||
addOnLeading={
|
||||
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{"team/"}
|
||||
</span>
|
||||
}
|
||||
ref={teamUrlRef}
|
||||
defaultValue={team?.slug as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingInputContainer
|
||||
Icon={HashtagIcon}
|
||||
label="Team Name"
|
||||
htmlFor="name"
|
||||
Input={
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder={t("your_team_name")}
|
||||
required
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
defaultValue={team?.name as string}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<hr />
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={InformationCircleIcon}
|
||||
label={t("about")}
|
||||
htmlFor="about"
|
||||
Input={
|
||||
<>
|
||||
<textarea
|
||||
ref={descriptionRef}
|
||||
id="about"
|
||||
name="about"
|
||||
rows={3}
|
||||
defaultValue={team?.bio as string}
|
||||
className="block w-full mt-1 border-gray-300 rounded-sm shadow-sm focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"></textarea>
|
||||
<p className="mt-2 text-sm text-gray-500">{t("team_description")}</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SettingInputContainer
|
||||
Icon={PhotographIcon}
|
||||
label={"Logo"}
|
||||
htmlFor="avatar"
|
||||
Input={
|
||||
<>
|
||||
<div className="flex mt-1">
|
||||
<input
|
||||
ref={logoRef}
|
||||
type="hidden"
|
||||
name="avatar"
|
||||
id="avatar"
|
||||
placeholder="URL"
|
||||
className="block w-full px-3 py-2 mt-1 border border-gray-300 rounded-sm shadow-sm focus:outline-none focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
defaultValue={team?.logo ?? undefined}
|
||||
/>
|
||||
<ImageUploader
|
||||
target="logo"
|
||||
id="logo-upload"
|
||||
buttonMsg={hasLogo ? t("edit_logo") : t("upload_a_logo")}
|
||||
handleAvatarChange={updateLogo}
|
||||
imageSrc={team?.logo ?? undefined}
|
||||
/>
|
||||
{hasLogo && (
|
||||
<Button
|
||||
onClick={removeLogo}
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="py-1 ml-1 text-xs">
|
||||
{t("remove_logo")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="mt-6" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="hide-branding"
|
||||
name="hide-branding"
|
||||
type="checkbox"
|
||||
ref={hideBrandingRef}
|
||||
defaultChecked={team?.hideBranding}
|
||||
className="w-4 h-4 border-gray-300 rounded-sm focus:ring-neutral-500 text-neutral-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
||||
{t("disable_cal_branding")}
|
||||
</label>
|
||||
<p className="text-gray-500">{t("disable_cal_branding_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end py-4">
|
||||
<Button type="submit" color="primary">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
components/team/TeamSettingsRightSidebar.tsx
Normal file
130
components/team/TeamSettingsRightSidebar.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { ClockIcon, ExternalLinkIcon, LinkIcon, LogoutIcon, TrashIcon } from "@heroicons/react/solid";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { TeamWithMembers } from "@lib/queries/teams";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import CreateEventTypeButton from "@components/eventtype/CreateEventType";
|
||||
import LinkIconButton from "@components/ui/LinkIconButton";
|
||||
|
||||
import { MembershipRole } from ".prisma/client";
|
||||
|
||||
// import Switch from "@components/ui/Switch";
|
||||
|
||||
export default function TeamSettingsRightSidebar(props: { team: TeamWithMembers; role: MembershipRole }) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const router = useRouter();
|
||||
|
||||
const permalink = `${process.env.NEXT_PUBLIC_APP_URL}/team/${props.team?.slug}`;
|
||||
|
||||
const deleteTeamMutation = trpc.useMutation("viewer.teams.delete", {
|
||||
async onSuccess() {
|
||||
await utils.invalidateQueries(["viewer.teams.get"]);
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
const acceptOrLeaveMutation = trpc.useMutation("viewer.teams.acceptOrLeave", {
|
||||
onSuccess: () => {
|
||||
utils.invalidateQueries(["viewer.teams.list"]);
|
||||
router.push(`/settings/teams`);
|
||||
},
|
||||
});
|
||||
|
||||
function deleteTeam() {
|
||||
if (props.team?.id) deleteTeamMutation.mutate({ teamId: props.team.id });
|
||||
}
|
||||
function leaveTeam() {
|
||||
if (props.team?.id)
|
||||
acceptOrLeaveMutation.mutate({
|
||||
teamId: props.team.id,
|
||||
accept: false,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 space-y-6">
|
||||
<CreateEventTypeButton
|
||||
isIndividualTeam
|
||||
canAddEvents={true}
|
||||
options={[
|
||||
{ teamId: props.team?.id, name: props.team?.name, slug: props.team?.slug, image: props.team?.logo },
|
||||
]}
|
||||
/>
|
||||
{/* <Switch
|
||||
name="isHidden"
|
||||
defaultChecked={hidden}
|
||||
onCheckedChange={setHidden}
|
||||
label={"Hide team from view"}
|
||||
/> */}
|
||||
<div className="space-y-1">
|
||||
<Link href={permalink} passHref={true}>
|
||||
<a target="_blank">
|
||||
<LinkIconButton Icon={ExternalLinkIcon}>{t("preview")}</LinkIconButton>
|
||||
</a>
|
||||
</Link>
|
||||
<LinkIconButton
|
||||
Icon={LinkIcon}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(permalink);
|
||||
showToast("Copied to clipboard", "success");
|
||||
}}>
|
||||
{t("copy_link_team")}
|
||||
</LinkIconButton>
|
||||
{props.role === "OWNER" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
Icon={TrashIcon}>
|
||||
{t("disband_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("disband_team")}
|
||||
confirmBtnText={t("confirm_disband_team")}
|
||||
onConfirm={deleteTeam}>
|
||||
{t("disband_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<LinkIconButton
|
||||
Icon={LogoutIcon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("leave_team")}
|
||||
</LinkIconButton>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("leave_team")}
|
||||
confirmBtnText={t("confirm_leave_team")}
|
||||
onConfirm={leaveTeam}>
|
||||
{t("leave_team_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
{props.team?.id && props.role !== MembershipRole.MEMBER && (
|
||||
<Link href={`/settings/teams/${props.team.id}/availability`}>
|
||||
<div className="hidden mt-5 space-y-1 sm:block">
|
||||
<LinkIconButton Icon={ClockIcon}>{"View Availability"}</LinkIconButton>
|
||||
<p className="mt-2 text-sm text-gray-500">See your team members availability at a glance.</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,24 +2,31 @@ import { ArrowRightIcon } from "@heroicons/react/outline";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/solid";
|
||||
import classnames from "classnames";
|
||||
import Link from "next/link";
|
||||
import { TeamPageProps } from "pages/team/[slug]";
|
||||
import React from "react";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import Button from "@components/ui/Button";
|
||||
import Text from "@components/ui/Text";
|
||||
|
||||
const Team = ({ team }) => {
|
||||
type TeamType = TeamPageProps["team"];
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = MembersType[number];
|
||||
|
||||
const Team = ({ team }: TeamPageProps) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const Member = ({ member }) => {
|
||||
const Member = ({ member }: { member: MemberType }) => {
|
||||
const classes = classnames(
|
||||
"group",
|
||||
"relative",
|
||||
"flex flex-col",
|
||||
"space-y-4",
|
||||
"p-4",
|
||||
"min-w-full sm:min-w-64 sm:max-w-64",
|
||||
"bg-white dark:bg-neutral-900 dark:border-0 dark:bg-opacity-8",
|
||||
"border border-neutral-200",
|
||||
"hover:cursor-pointer",
|
||||
@@ -29,7 +36,7 @@ const Team = ({ team }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={`/${member.user.username}`}>
|
||||
<Link key={member.id} href={`/${member.username}`}>
|
||||
<div className={classes}>
|
||||
<ArrowRightIcon
|
||||
className={classnames(
|
||||
@@ -42,11 +49,15 @@ const Team = ({ team }) => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Avatar displayName={member.user.name} imageSrc={member.user.avatar} className="w-12 h-12" />
|
||||
<section className="space-y-2">
|
||||
<Text variant="title">{member.user.name}</Text>
|
||||
<Text variant="subtitle" className="w-6/8">
|
||||
{member.user.bio}
|
||||
<Avatar
|
||||
alt={member.name || ""}
|
||||
imageSrc={getPlaceholderAvatar(member.avatar, member.username)}
|
||||
className="w-12 h-12 -mt-4"
|
||||
/>
|
||||
<section className="w-full mt-2 space-y-1">
|
||||
<Text variant="title">{member.name}</Text>
|
||||
<Text variant="subtitle" className="">
|
||||
{member.bio || t("user_from_team", { user: member.name, team: team.name })}
|
||||
</Text>
|
||||
</section>
|
||||
</div>
|
||||
@@ -55,15 +66,15 @@ const Team = ({ team }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Members = ({ members }) => {
|
||||
const Members = ({ members }: { members: MembersType }) => {
|
||||
if (!members || members.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto min-w-full lg:min-w-lg max-w-5xl flex flex-wrap gap-x-12 gap-y-6 justify-center">
|
||||
<section className="flex flex-wrap justify-center max-w-5xl min-w-full mx-auto lg:min-w-lg gap-x-6 gap-y-6">
|
||||
{members.map((member) => {
|
||||
return member.user.username !== null && <Member key={member.id} member={member} />;
|
||||
return member.username !== null && <Member key={member.id} member={member} />;
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
@@ -73,7 +84,7 @@ const Team = ({ team }) => {
|
||||
<div>
|
||||
<Members members={team.members} />
|
||||
{team.eventTypes.length > 0 && (
|
||||
<aside className="text-center dark:text-white mt-8">
|
||||
<aside className="mt-8 text-center dark:text-white">
|
||||
<Button color="secondary" href={`/team/${team.slug}`} shallow={true} StartIcon={ArrowLeftIcon}>
|
||||
{t("go_back")}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function Alert(props: AlertProps) {
|
||||
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3 flex-grow">
|
||||
<div className="flex-grow ml-3">
|
||||
<h3 className="text-sm font-medium">{props.title}</h3>
|
||||
<div className="text-sm">{props.message}</div>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function Avatar(props: AvatarProps) {
|
||||
return title ? (
|
||||
<Tooltip.Tooltip delayDuration={300}>
|
||||
<Tooltip.TooltipTrigger className="cursor-default">{avatar}</Tooltip.TooltipTrigger>
|
||||
<Tooltip.Content className="p-2 rounded-sm text-sm bg-brand text-white shadow-sm">
|
||||
<Tooltip.Content className="p-2 text-sm rounded-sm shadow-sm bg-brand text-brandcontrast">
|
||||
<Tooltip.Arrow />
|
||||
{title}
|
||||
</Tooltip.Content>
|
||||
|
||||
@@ -12,7 +12,7 @@ export type AvatarGroupProps = {
|
||||
items: {
|
||||
image: string;
|
||||
title?: string;
|
||||
alt: string;
|
||||
alt?: string;
|
||||
}[];
|
||||
className?: string;
|
||||
};
|
||||
@@ -28,19 +28,23 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
|
||||
return (
|
||||
<ul className={classNames("flex -space-x-2 overflow-hidden", props.className)}>
|
||||
{props.items.slice(0, props.truncateAfter).map((item, idx) => (
|
||||
<li key={idx} className="inline-block">
|
||||
<Avatar imageSrc={item.image} title={item.title} alt={item.alt} size={props.size} />
|
||||
</li>
|
||||
))}
|
||||
{props.items.slice(0, props.truncateAfter).map((item, idx) => {
|
||||
if (item.image != null) {
|
||||
return (
|
||||
<li key={idx} className="inline-block">
|
||||
<Avatar imageSrc={item.image} title={item.title} alt={item.alt || ""} size={props.size} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{/*props.items.length > props.truncateAfter && (
|
||||
<li className="inline-block relative">
|
||||
<li className="relative inline-block">
|
||||
<Tooltip.Tooltip delayDuration="300">
|
||||
<Tooltip.TooltipTrigger className="cursor-default">
|
||||
<span className="w-16 absolute bottom-1.5 border-2 border-gray-300 flex-inline items-center text-white pt-4 text-2xl top-0 rounded-full block bg-neutral-600">+1</span>
|
||||
</Tooltip.TooltipTrigger>
|
||||
{truncatedAvatars.length !== 0 && (
|
||||
<Tooltip.Content className="p-2 rounded-sm text-sm bg-brand text-white shadow-sm">
|
||||
<Tooltip.Content className="p-2 text-sm text-white rounded-sm shadow-sm bg-brand">
|
||||
<Tooltip.Arrow />
|
||||
<ul>
|
||||
{truncatedAvatars.map((title) => (
|
||||
|
||||
@@ -64,7 +64,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||
color === "primary" &&
|
||||
(disabled
|
||||
? "border border-transparent bg-gray-400 text-white"
|
||||
: "border border-transparent dark:text-black text-white bg-brand dark:bg-white hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
|
||||
: "border border-transparent dark:text-brandcontrast text-brandcontrast bg-brand dark:bg-brand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
|
||||
color === "secondary" &&
|
||||
(disabled
|
||||
? "border border-gray-200 text-gray-400 bg-white"
|
||||
@@ -90,12 +90,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
|
||||
},
|
||||
<>
|
||||
{StartIcon && (
|
||||
<StartIcon
|
||||
className={classNames(
|
||||
"inline",
|
||||
size === "icon" ? "w-5 h-5 group-hover:text-black" : "w-5 h-5 mr-2 -ml-1"
|
||||
)}
|
||||
/>
|
||||
<StartIcon className={classNames("inline", size === "icon" ? "w-5 h-5 " : "w-5 h-5 mr-2 -ml-1")} />
|
||||
)}
|
||||
{props.children}
|
||||
{loading && (
|
||||
|
||||
@@ -26,7 +26,7 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
|
||||
return (
|
||||
<DropdownMenuPrimitive.Content
|
||||
{...props}
|
||||
className="z-10 mt-1 text-sm origin-top-right bg-white rounded-sm shadow-lg w-44 ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
className="z-10 w-48 mt-1 text-sm origin-top-right bg-white rounded-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
ref={forwardedRef}>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.Content>
|
||||
|
||||
21
components/ui/LinkIconButton.tsx
Normal file
21
components/ui/LinkIconButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
import { SVGComponent } from "@lib/types/SVGComponent";
|
||||
|
||||
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
Icon: SVGComponent;
|
||||
}
|
||||
|
||||
export default function LinkIconButton(props: LinkIconButtonProps) {
|
||||
return (
|
||||
<div className="-ml-2">
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className="flex items-center px-2 py-1 text-sm font-medium text-gray-700 rounded-sm text-md hover:text-gray-900 hover:bg-gray-200">
|
||||
<props.Icon className="w-4 h-4 mr-2 text-neutral-500" />
|
||||
{props.children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/ui/ModalContainer.tsx
Normal file
39
components/ui/ModalContainer.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import classNames from "classnames";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends React.PropsWithChildren<any> {
|
||||
wide?: boolean;
|
||||
scroll?: boolean;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
export default function ModalContainer(props: Props) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
|
||||
aria-hidden="true"></div>
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<div
|
||||
className={classNames(
|
||||
"inline-block min-w-96 px-4 pt-5 pb-4 text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:p-6",
|
||||
{
|
||||
"sm:max-w-lg sm:w-full ": !props.wide,
|
||||
"sm:max-w-4xl sm:w-4xl": props.wide,
|
||||
"overflow-scroll": props.scroll,
|
||||
"!p-0": props.noPadding,
|
||||
}
|
||||
)}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import React, { useEffect, useState } from "react";
|
||||
import TimezoneSelect, { ITimezoneOption } from "react-timezone-select";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import { OpeningHours, DateOverride } from "@lib/types/event-type";
|
||||
import { WorkingHours } from "@lib/types/schedule";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
import { WeekdaySelect } from "./WeekdaySelect";
|
||||
import SetTimesModal from "./modal/SetTimesModal";
|
||||
@@ -19,7 +21,7 @@ type Props = {
|
||||
timeZone: string;
|
||||
availability: Availability[];
|
||||
setTimeZone: (timeZone: string) => void;
|
||||
setAvailability: (schedule: { openingHours: OpeningHours[]; dateOverrides: DateOverride[] }) => void;
|
||||
setAvailability: (schedule: { openingHours: WorkingHours[]; dateOverrides: WorkingHours[] }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,9 +105,9 @@ export const Scheduler = ({ availability, setAvailability, timeZone, setTimeZone
|
||||
<OpeningHours key={idx} idx={idx} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
<button type="button" onClick={addNewSchedule} className="mt-2 btn-white btn-sm">
|
||||
<Button type="button" onClick={addNewSchedule} className="mt-2" color="secondary" size="sm">
|
||||
{t("add_another")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{editSchedule >= 0 && (
|
||||
|
||||
25
components/ui/SettingInputContainer.tsx
Normal file
25
components/ui/SettingInputContainer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
export default function SettingInputContainer({
|
||||
Input,
|
||||
Icon,
|
||||
label,
|
||||
htmlFor,
|
||||
}: {
|
||||
Input: React.ReactNode;
|
||||
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element;
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="block sm:flex">
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={htmlFor} className="flex mt-1 text-sm font-medium text-neutral-700">
|
||||
<Icon className="w-4 h-4 mr-2 mt-0.5 text-neutral-500" />
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-grow w-full">{Input}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface UsernameInputProps extends React.ComponentPropsWithRef<"input"> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use <TextField addOnLeading={}> to achieve the same effect.
|
||||
*/
|
||||
const UsernameInput = React.forwardRef<HTMLInputElement, UsernameInputProps>((props, ref) => (
|
||||
// todo, check if username is already taken here?
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
{props.label ? props.label : "Username"}
|
||||
</label>
|
||||
<div className="flex mt-1 rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 text-gray-500 border border-r-0 border-gray-300 rounded-l-sm bg-gray-50 sm:text-sm">
|
||||
{process.env.NEXT_PUBLIC_APP_URL}/{props.label && "team/"}
|
||||
</span>
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
{...props}
|
||||
className="flex-grow block w-full min-w-0 lowercase border-gray-300 rounded-none rounded-r-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
UsernameInput.displayName = "UsernameInput";
|
||||
|
||||
export { UsernameInput };
|
||||
@@ -34,7 +34,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
|
||||
}}
|
||||
className={`
|
||||
w-10 h-10
|
||||
bg-brand text-white focus:outline-none px-3 py-1 rounded
|
||||
bg-brand text-brandcontrast focus:outline-none px-3 py-1 rounded
|
||||
${activeDays[idx + 1] ? "rounded-r-none" : ""}
|
||||
${activeDays[idx - 1] ? "rounded-l-none" : ""}
|
||||
${idx === 0 ? "rounded-l" : ""}
|
||||
|
||||
28
components/ui/form/DatePicker.tsx
Normal file
28
components/ui/form/DatePicker.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CalendarIcon } from "@heroicons/react/solid";
|
||||
import React from "react";
|
||||
import "react-calendar/dist/Calendar.css";
|
||||
import "react-date-picker/dist/DatePicker.css";
|
||||
import PrimitiveDatePicker from "react-date-picker/dist/entry.nostyle";
|
||||
|
||||
import classNames from "@lib/classNames";
|
||||
|
||||
type Props = {
|
||||
date: Date;
|
||||
onDatesChange?: ((date: Date) => void) | undefined;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const DatePicker = ({ date, onDatesChange, className }: Props) => {
|
||||
return (
|
||||
<PrimitiveDatePicker
|
||||
className={classNames(
|
||||
"p-1 pl-2 border border-gray-300 rounded-sm shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
|
||||
className
|
||||
)}
|
||||
clearIcon={null}
|
||||
calendarIcon={<CalendarIcon className="w-5 h-5 text-gray-500" />}
|
||||
value={date}
|
||||
onChange={onDatesChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,30 @@
|
||||
import classNames from "classnames";
|
||||
import React, { forwardRef, InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: ReactNode;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
const MinutesField = forwardRef<HTMLInputElement, Props>(({ label, ...rest }, ref) => {
|
||||
return (
|
||||
<div className="block sm:flex">
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={rest.id} className="flex text-sm font-medium text-neutral-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
{!!label && (
|
||||
<div className="mb-4 min-w-48 sm:mb-0">
|
||||
<label htmlFor={rest.id} className="flex items-center h-full text-sm font-medium text-neutral-700">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="relative rounded-sm shadow-sm">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="number"
|
||||
className="block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
className={classNames(
|
||||
"block w-full pl-2 pr-12 border-gray-300 rounded-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm",
|
||||
rest.className
|
||||
)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm" id="duration">
|
||||
|
||||
@@ -8,7 +8,7 @@ export const PhoneInput = (props: PhoneInputProps) => (
|
||||
<BasePhoneInput
|
||||
{...props}
|
||||
className={classNames(
|
||||
"shadow-sm rounded-md block w-full py-px px-3 border border-1 border-gray-300 ring-black focus-within:ring-1 focus-within:border-brand dark:border-gray-900 dark:text-white dark:bg-brand",
|
||||
"shadow-sm rounded-sm block w-full py-px px-3 border border-1 border-gray-300 ring-black focus-within:ring-1 focus-within:border-brand dark:border-black dark:text-white dark:bg-black",
|
||||
props.className
|
||||
)}
|
||||
onChange={() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Controller, useFieldArray } from "react-hook-form";
|
||||
|
||||
@@ -11,6 +13,9 @@ import { TimeRange } from "@lib/types/schedule";
|
||||
import Button from "@components/ui/Button";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/** Begin Time Increments For Select */
|
||||
const increment = 15;
|
||||
/**
|
||||
@@ -43,16 +48,17 @@ type TimeRangeFieldProps = {
|
||||
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
|
||||
const getOption = (time: Date) => ({
|
||||
value: time.valueOf(),
|
||||
label: time.toLocaleTimeString("nl-NL", { minute: "numeric", hour: "numeric" }),
|
||||
// const { i18n } = useLocale();
|
||||
const getOption = (time: ConfigType) => ({
|
||||
value: dayjs(time).toDate().valueOf(),
|
||||
label: dayjs(time).utc().format("HH:mm"),
|
||||
// .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }),
|
||||
});
|
||||
|
||||
const timeOptions = useCallback((offsetOrLimit: { offset?: number; limit?: number } = {}) => {
|
||||
const { limit, offset } = offsetOrLimit;
|
||||
return TIMES.filter((time) => (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset))).map(
|
||||
(t) => getOption(t.toDate())
|
||||
(t) => getOption(t)
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -115,7 +121,7 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="flex justify-between py-5 min-h-[86px]">
|
||||
<fieldset className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row justify-between py-5 min-h-[86px]">
|
||||
<div className="w-1/3">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useRef } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function SetTimesModal(props) {
|
||||
const { t } = useLocale();
|
||||
const [startHours, startMinutes] = [Math.floor(props.startTime / 60), props.startTime % 60];
|
||||
@@ -31,26 +33,26 @@ export default function SetTimesModal(props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 inset-0 overflow-y-auto"
|
||||
className="fixed inset-0 z-50 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 z-0 bg-opacity-75 transition-opacity"
|
||||
className="fixed inset-0 z-0 transition-opacity bg-gray-500 bg-opacity-75"
|
||||
aria-hidden="true"></div>
|
||||
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="sm:flex sm:items-start mb-4">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ClockIcon className="h-6 w-6 text-black" />
|
||||
<div className="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||
<div className="mb-4 sm:flex sm:items-start">
|
||||
<div className="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-blue-100 rounded-full sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ClockIcon className="w-6 h-6 text-black" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
{t("change_bookings_availability")}
|
||||
</h3>
|
||||
<div>
|
||||
@@ -59,7 +61,7 @@ export default function SetTimesModal(props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mb-4">
|
||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("start_time")}</label>
|
||||
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("start_time")}</label>
|
||||
<div>
|
||||
<label htmlFor="startHours" className="sr-only">
|
||||
{t("hours")}
|
||||
@@ -72,12 +74,12 @@ export default function SetTimesModal(props) {
|
||||
maxLength="2"
|
||||
name="hours"
|
||||
id="startHours"
|
||||
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="9"
|
||||
defaultValue={startHours}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<span className="pt-1 mx-2">:</span>
|
||||
<div>
|
||||
<label htmlFor="startMinutes" className="sr-only">
|
||||
{t("minutes")}
|
||||
@@ -91,14 +93,14 @@ export default function SetTimesModal(props) {
|
||||
maxLength="2"
|
||||
name="minutes"
|
||||
id="startMinutes"
|
||||
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="30"
|
||||
defaultValue={startMinutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">{t("end_time")}</label>
|
||||
<label className="block w-1/4 pt-2 text-sm font-medium text-gray-700">{t("end_time")}</label>
|
||||
<div>
|
||||
<label htmlFor="endHours" className="sr-only">
|
||||
{t("hours")}
|
||||
@@ -111,12 +113,12 @@ export default function SetTimesModal(props) {
|
||||
maxLength="2"
|
||||
name="hours"
|
||||
id="endHours"
|
||||
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="17"
|
||||
defaultValue={endHours}
|
||||
/>
|
||||
</div>
|
||||
<span className="mx-2 pt-1">:</span>
|
||||
<span className="pt-1 mx-2">:</span>
|
||||
<div>
|
||||
<label htmlFor="endMinutes" className="sr-only">
|
||||
{t("minutes")}
|
||||
@@ -130,19 +132,19 @@ export default function SetTimesModal(props) {
|
||||
step="15"
|
||||
name="minutes"
|
||||
id="endMinutes"
|
||||
className="shadow-sm focus:ring-black focus:border-brand block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-black focus:border-brand sm:text-sm"
|
||||
placeholder="30"
|
||||
defaultValue={endMinutes}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<button onClick={updateStartEndTimesHandler} type="submit" className="btn btn-primary">
|
||||
<Button onClick={updateStartEndTimesHandler} type="submit">
|
||||
{t("save")}
|
||||
</button>
|
||||
<button onClick={props.onExit} type="button" className="btn btn-white mr-2">
|
||||
</Button>
|
||||
<Button onClick={props.onExit} type="button" color="secondary" className="mr-2">
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: "cal-saml"
|
||||
POSTGRES_PASSWORD: ""
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
volumes:
|
||||
|
||||
27
docs/saml-setup.md
Normal file
27
docs/saml-setup.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# SAML Registration with Identity Providers
|
||||
|
||||
This guide explains the settings you’d need to use to configure SAML with your Identity Provider. Once this is set up you should get an XML metadata file that should then be uploaded on your Cal.com self-hosted instance.
|
||||
|
||||
> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below.
|
||||
|
||||
**Assertion consumer service URL / Single Sign-On URL / Destination URL:** [http://localhost:3000/api/auth/saml/callback](http://localhost:3000/api/auth/saml/callback) [Replace this with the URL for your self-hosted Cal instance]
|
||||
|
||||
**Entity ID / Identifier / Audience URI / Audience Restriction:** https://saml.cal.com
|
||||
|
||||
**Response:** Signed
|
||||
|
||||
**Assertion Signature:** Signed
|
||||
|
||||
**Signature Algorithm:** RSA-SHA256
|
||||
|
||||
**Assertion Encryption:** Unencrypted
|
||||
|
||||
**Mapping Attributes / Attribute Statements:**
|
||||
|
||||
id -> user.id
|
||||
|
||||
email -> user.email
|
||||
|
||||
firstName -> user.firstName
|
||||
|
||||
lastName -> user.lastName
|
||||
11
ee/README.md
11
ee/README.md
@@ -25,3 +25,14 @@ The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the pl
|
||||
6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `<CALENDSO URL>/api/integrations/stripepayment/webhook` as webhook for connected applications.
|
||||
7. Select all `payment_intent` events for the webhook.
|
||||
8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file.
|
||||
|
||||
## Setting up SAML login
|
||||
|
||||
1. Set SAML_DATABASE_URL to a postgres database. Please use a different database than the main Cal instance since the migrations are separate for this database. For example `postgresql://postgres:@localhost:5450/cal-saml`
|
||||
2. Set SAML_ADMINS to a comma separated list of admin emails from where the SAML metadata can be uploaded and configured.
|
||||
3. Create a SAML application with your Identity Provider (IdP) using the instructions here - [SAML Setup](../docs/saml-setup.md)
|
||||
4. Remember to configure access to the IdP SAML app for all your users (who need access to Cal).
|
||||
5. You will need the XML metadata from your IdP later, so keep it accessible.
|
||||
6. Log in to one of the admin accounts configured in SAML_ADMINS and then navigate to Settings -> Security.
|
||||
7. You should see a SAML configuration section, copy and paste the XML metadata from step 5 and click on Save.
|
||||
8. Your provisioned users can now log into Cal using SAML.
|
||||
|
||||
35
ee/components/TrialBanner.tsx
Normal file
35
ee/components/TrialBanner.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { TRIAL_LIMIT_DAYS } from "@lib/config/constants";
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import { useMeQuery } from "@components/Shell";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
const TrialBanner = () => {
|
||||
const { t } = useLocale();
|
||||
const query = useMeQuery();
|
||||
const user = query.data;
|
||||
|
||||
if (!user || user.plan !== "TRIAL") return null;
|
||||
|
||||
const trialDaysLeft = dayjs(user.createdDate)
|
||||
.add(TRIAL_LIMIT_DAYS + 1, "day")
|
||||
.diff(dayjs(), "day");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden p-4 m-4 text-sm font-medium text-center text-gray-600 bg-yellow-200 rounded-md sm:block"
|
||||
data-testid="trial-banner">
|
||||
<div className="mb-2 text-left">{t("trial_days_left", { days: trialDaysLeft })}</div>
|
||||
<Button
|
||||
href="/api/upgrade"
|
||||
color="minimal"
|
||||
className="justify-center w-full border-2 border-gray-600 hover:bg-yellow-100">
|
||||
{t("upgrade_now")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialBanner;
|
||||
162
ee/components/saml/Configuration.tsx
Normal file
162
ee/components/saml/Configuration.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
import { useLocale } from "@lib/hooks/useLocale";
|
||||
import showToast from "@lib/notification";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Badge from "@components/ui/Badge";
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
export default function SAMLConfiguration({
|
||||
teamsView,
|
||||
teamId,
|
||||
}: {
|
||||
teamsView: boolean;
|
||||
teamId: null | undefined | number;
|
||||
}) {
|
||||
const [isSAMLLoginEnabled, setIsSAMLLoginEnabled] = useState(false);
|
||||
const [samlConfig, setSAMLConfig] = useState<string | null>(null);
|
||||
|
||||
const query = trpc.useQuery(["viewer.showSAMLView", { teamsView, teamId }]);
|
||||
|
||||
useEffect(() => {
|
||||
const data = query.data;
|
||||
setIsSAMLLoginEnabled(data?.isSAMLLoginEnabled ?? false);
|
||||
setSAMLConfig(data?.provider ?? null);
|
||||
}, [query.data]);
|
||||
|
||||
const mutation = trpc.useMutation("viewer.updateSAMLConfig", {
|
||||
onSuccess: (data: { provider: string | undefined }) => {
|
||||
showToast(t("saml_config_updated_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
setSAMLConfig(data?.provider ?? null);
|
||||
samlConfigRef.current.value = "";
|
||||
},
|
||||
onError: () => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(t("saml_configuration_update_failed"));
|
||||
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.useMutation("viewer.deleteSAMLConfig", {
|
||||
onSuccess: () => {
|
||||
showToast(t("saml_config_deleted_successfully"), "success");
|
||||
setHasErrors(false); // dismiss any open errors
|
||||
setSAMLConfig(null);
|
||||
samlConfigRef.current.value = "";
|
||||
},
|
||||
onError: () => {
|
||||
setHasErrors(true);
|
||||
setErrorMessage(t("saml_configuration_delete_failed"));
|
||||
document?.getElementsByTagName("main")[0]?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
},
|
||||
});
|
||||
|
||||
const samlConfigRef = useRef<HTMLTextAreaElement>() as React.MutableRefObject<HTMLTextAreaElement>;
|
||||
|
||||
const [hasErrors, setHasErrors] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
async function updateSAMLConfigHandler(event: React.FormEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const rawMetadata = samlConfigRef.current.value;
|
||||
|
||||
mutation.mutate({
|
||||
rawMetadata: rawMetadata,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSAMLConfigHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
|
||||
event.preventDefault();
|
||||
|
||||
deleteMutation.mutate({
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<hr className="mt-8" />
|
||||
|
||||
{isSAMLLoginEnabled ? (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-lg leading-6 font-medium text-gray-900">
|
||||
{t("saml_configuration")}
|
||||
<Badge className="text-xs ml-2" variant={samlConfig ? "success" : "gray"}>
|
||||
{samlConfig ? t("enabled") : t("disabled")}
|
||||
</Badge>
|
||||
{samlConfig ? (
|
||||
<>
|
||||
<Badge className="text-xs ml-2" variant={"success"}>
|
||||
{samlConfig ? samlConfig : ""}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{samlConfig ? (
|
||||
<div className="mt-2 flex">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
color="warn"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
{t("delete_saml_configuration")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ConfirmationDialogContent
|
||||
variety="danger"
|
||||
title={t("delete_saml_configuration")}
|
||||
confirmBtnText={t("confirm_delete_saml_configuration")}
|
||||
cancelBtnText={t("cancel")}
|
||||
onConfirm={deleteSAMLConfigHandler}>
|
||||
{t("delete_saml_configuration_confirmation_message")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-gray-500">{!samlConfig ? t("saml_not_configured_yet") : ""}</p>
|
||||
)}
|
||||
|
||||
<p className="mt-1 text-sm text-gray-500">{t("saml_configuration_description")}</p>
|
||||
|
||||
<form className="mt-3 divide-y divide-gray-200 lg:col-span-9" onSubmit={updateSAMLConfigHandler}>
|
||||
{hasErrors && <Alert severity="error" title={errorMessage} />}
|
||||
|
||||
<textarea
|
||||
data-testid="saml_config"
|
||||
ref={samlConfigRef}
|
||||
name="saml_config"
|
||||
id="saml_config"
|
||||
required={true}
|
||||
rows={10}
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black sm:text-sm"
|
||||
placeholder={t("saml_configuration_placeholder")}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end py-8">
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black">
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
<hr className="mt-4" />
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
import { StripeCardElementChangeEvent } from "@stripe/stripe-js";
|
||||
import { CardElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import stripejs, { StripeCardElementChangeEvent, StripeElementLocale } from "@stripe/stripe-js";
|
||||
import { useRouter } from "next/router";
|
||||
import { stringify } from "querystring";
|
||||
import React, { useState } from "react";
|
||||
import { SyntheticEvent } from "react";
|
||||
import React, { SyntheticEvent, useEffect, useState } from "react";
|
||||
|
||||
import { PaymentData } from "@ee/lib/stripe/server";
|
||||
|
||||
@@ -12,10 +11,10 @@ import { useLocale } from "@lib/hooks/useLocale";
|
||||
|
||||
import Button from "@components/ui/Button";
|
||||
|
||||
const CARD_OPTIONS = {
|
||||
const CARD_OPTIONS: stripejs.StripeCardElementOptions = {
|
||||
iconStyle: "solid" as const,
|
||||
classes: {
|
||||
base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-brand dark:text-white dark:border-gray-900 focus-within:ring-black focus-within:border-brand sm:text-sm",
|
||||
base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-black focus-within:ring-black focus-within:border-black sm:text-sm",
|
||||
},
|
||||
style: {
|
||||
base: {
|
||||
@@ -29,7 +28,7 @@ const CARD_OPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
type Props = {
|
||||
payment: {
|
||||
@@ -47,18 +46,23 @@ type States =
|
||||
| { status: "ok" };
|
||||
|
||||
export default function PaymentComponent(props: Props) {
|
||||
const { t } = useLocale();
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const { name, date } = router.query;
|
||||
const [state, setState] = useState<States>({ status: "idle" });
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const { isDarkMode } = useDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
elements?.update({ locale: i18n.language as StripeElementLocale });
|
||||
}, [elements, i18n.language]);
|
||||
|
||||
if (isDarkMode) {
|
||||
CARD_OPTIONS.style.base.color = "#fff";
|
||||
CARD_OPTIONS.style.base.iconColor = "#fff";
|
||||
CARD_OPTIONS.style.base["::placeholder"].color = "#fff";
|
||||
CARD_OPTIONS.style!.base!.color = "#fff";
|
||||
CARD_OPTIONS.style!.base!.iconColor = "#fff";
|
||||
CARD_OPTIONS.style!.base!["::placeholder"]!.color = "#fff";
|
||||
}
|
||||
|
||||
const handleChange = async (event: StripeCardElementChangeEvent) => {
|
||||
|
||||
95
ee/components/team/availability/TeamAvailabilityModal.tsx
Normal file
95
ee/components/team/availability/TeamAvailabilityModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
interface Props {
|
||||
team?: inferQueryOutput<"viewer.teams.get">;
|
||||
member?: inferQueryOutput<"viewer.teams.get">["members"][number];
|
||||
}
|
||||
|
||||
export default function TeamAvailabilityModal(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
|
||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||
);
|
||||
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
|
||||
|
||||
useEffect(() => {
|
||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||
}, [utils, selectedTimeZone, selectedDate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row max-h-[500px] min-h-[500px] space-x-8">
|
||||
<div className="w-64 p-5 pr-0 space-y-5 min-w-64">
|
||||
<div className="flex">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(props.member?.avatar, props.member?.name as string)}
|
||||
alt={props.member?.name || ""}
|
||||
className="rounded-full w-14 h-14"
|
||||
/>
|
||||
<div className="inline-block pt-1 ml-3">
|
||||
<span className="text-lg font-bold text-neutral-700">{props.member?.name}</span>
|
||||
<span className="block -mt-1 text-sm text-gray-400">{props.member?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-gray-600">Date</span>
|
||||
<DatePicker
|
||||
date={selectedDate.toDate()}
|
||||
onDatesChange={(newDate) => {
|
||||
setSelectedDate(dayjs(newDate));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-gray-600">Timezone</span>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
|
||||
classNamePrefix="react-select"
|
||||
className="block w-full mt-1 border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-gray-600">Slot Length</span>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 15, label: "15 minutes" },
|
||||
{ value: 30, label: "30 minutes" },
|
||||
{ value: 60, label: "60 minutes" },
|
||||
]}
|
||||
isSearchable={false}
|
||||
classNamePrefix="react-select"
|
||||
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
value={{ value: frequency, label: `${frequency} minutes` }}
|
||||
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.team && props.member && (
|
||||
<TeamAvailabilityTimes
|
||||
className="overflow-scroll"
|
||||
teamId={props.team.id}
|
||||
memberId={props.member.id}
|
||||
frequency={frequency}
|
||||
selectedDate={selectedDate}
|
||||
selectedTimeZone={selectedTimeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
ee/components/team/availability/TeamAvailabilityScreen.tsx
Normal file
119
ee/components/team/availability/TeamAvailabilityScreen.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import dayjs from "dayjs";
|
||||
import React, { useState, useEffect, CSSProperties } from "react";
|
||||
import TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc, inferQueryOutput } from "@lib/trpc";
|
||||
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
import { DatePicker } from "@components/ui/form/DatePicker";
|
||||
import Select from "@components/ui/form/Select";
|
||||
|
||||
import TeamAvailabilityTimes from "./TeamAvailabilityTimes";
|
||||
|
||||
interface Props {
|
||||
team?: inferQueryOutput<"viewer.teams.get">;
|
||||
}
|
||||
|
||||
export default function TeamAvailabilityScreen(props: Props) {
|
||||
const utils = trpc.useContext();
|
||||
const [selectedDate, setSelectedDate] = useState(dayjs());
|
||||
const [selectedTimeZone, setSelectedTimeZone] = useState<ITimezone>(
|
||||
localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()
|
||||
);
|
||||
const [frequency, setFrequency] = useState<15 | 30 | 60>(30);
|
||||
|
||||
useEffect(() => {
|
||||
utils.invalidateQueries(["viewer.teams.getMemberAvailability"]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTimeZone, selectedDate]);
|
||||
|
||||
const Item = ({ index, style }: { index: number; style: CSSProperties }) => {
|
||||
const member = props.team?.members?.[index];
|
||||
if (!member) return <></>;
|
||||
|
||||
return (
|
||||
<div key={member.id} style={style} className="flex pl-4 border-r border-gray-200 ">
|
||||
<TeamAvailabilityTimes
|
||||
teamId={props.team?.id as number}
|
||||
memberId={member.id}
|
||||
frequency={frequency}
|
||||
selectedDate={selectedDate}
|
||||
selectedTimeZone={selectedTimeZone}
|
||||
HeaderComponent={
|
||||
<div className="flex items-center mb-6">
|
||||
<Avatar
|
||||
imageSrc={getPlaceholderAvatar(member?.avatar, member?.name as string)}
|
||||
alt={member?.name || ""}
|
||||
className="w-10 h-10 mt-1 rounded-full min-w-10 min-h-10"
|
||||
/>
|
||||
<div className="inline-block pt-1 ml-3 overflow-hidden">
|
||||
<span className="text-lg font-bold truncate text-neutral-700">{member?.name}</span>
|
||||
<span className="block -mt-1 text-sm text-gray-400 truncate">{member?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 bg-white border rounded-sm border-neutral-200">
|
||||
<div className="flex w-full p-4 space-x-5 border-b border-gray-200">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-700">Date</span>
|
||||
<DatePicker
|
||||
date={selectedDate.toDate()}
|
||||
className="p-1.5"
|
||||
onDatesChange={(newDate) => {
|
||||
setSelectedDate(dayjs(newDate));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-neutral-700">Timezone</span>
|
||||
<TimezoneSelect
|
||||
id="timeZone"
|
||||
value={selectedTimeZone}
|
||||
onChange={(timezone) => setSelectedTimeZone(timezone.value)}
|
||||
classNamePrefix="react-select"
|
||||
className="w-full border border-gray-300 rounded-sm shadow-sm react-select-container focus:ring-neutral-800 focus:border-neutral-800 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<span className="text-sm font-medium text-neutral-700">Slot Length</span>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 15, label: "15 minutes" },
|
||||
{ value: 30, label: "30 minutes" },
|
||||
{ value: 60, label: "60 minutes" },
|
||||
]}
|
||||
isSearchable={false}
|
||||
classNamePrefix="react-select"
|
||||
className="flex-1 block w-full min-w-0 border border-gray-300 rounded-sm react-select-container focus:ring-primary-500 focus:border-primary-500 sm:text-sm"
|
||||
value={{ value: frequency, label: `${frequency} minutes` }}
|
||||
onChange={(newFrequency) => setFrequency(newFrequency?.value ?? 30)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 h-full">
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<List
|
||||
itemSize={240}
|
||||
itemCount={props.team?.members?.length ?? 0}
|
||||
className="List"
|
||||
height={height}
|
||||
layout="horizontal"
|
||||
width={width}>
|
||||
{Item}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
ee/components/team/availability/TeamAvailabilityTimes.tsx
Normal file
70
ee/components/team/availability/TeamAvailabilityTimes.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import classNames from "classnames";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import React from "react";
|
||||
import { ITimezone } from "react-timezone-select";
|
||||
|
||||
import getSlots from "@lib/slots";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
|
||||
interface Props {
|
||||
teamId: number;
|
||||
memberId: number;
|
||||
selectedDate: Dayjs;
|
||||
selectedTimeZone: ITimezone;
|
||||
frequency: number;
|
||||
HeaderComponent?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
export default function TeamAvailabilityTimes(props: Props) {
|
||||
const { data, isLoading } = trpc.useQuery(
|
||||
[
|
||||
"viewer.teams.getMemberAvailability",
|
||||
{
|
||||
teamId: props.teamId,
|
||||
memberId: props.memberId,
|
||||
dateFrom: props.selectedDate.toString(),
|
||||
dateTo: props.selectedDate.add(1, "day").toString(),
|
||||
timezone: `${props.selectedTimeZone.toString()}`,
|
||||
},
|
||||
],
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const times = !isLoading
|
||||
? getSlots({
|
||||
frequency: props.frequency,
|
||||
inviteeDate: props.selectedDate,
|
||||
workingHours: data?.workingHours || [],
|
||||
minimumBookingNotice: 0,
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={classNames("flex-grow p-5 pl-0 min-w-60", props.className)}>
|
||||
{props.HeaderComponent}
|
||||
{isLoading && times.length === 0 && <Loader />}
|
||||
{!isLoading && times.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center pt-4">
|
||||
<span className="text-sm text-gray-500">No Available Slots</span>
|
||||
</div>
|
||||
)}
|
||||
{times.map((time) => (
|
||||
<div key={time.format()} className="flex flex-row items-center">
|
||||
<a
|
||||
className="flex-grow block py-2 mb-2 mr-3 font-medium text-center bg-white border rounded-sm min-w-48 dark:bg-gray-600 text-primary-500 dark:text-neutral-200 border-brand dark:border-transparent hover:bg-brand hover:text-brandcontrast dark:hover:border-black dark:hover:text-white dark:hover:bg-black"
|
||||
data-testid="time">
|
||||
{time.format("HH:mm")}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadStripe, Stripe } from "@stripe/stripe-js";
|
||||
import { Stripe } from "@stripe/stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js/pure";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { Maybe } from "@trpc/server";
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { PaymentType } from "@prisma/client";
|
||||
import { PaymentType, Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { JsonValue } from "type-fest";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { CalendarEvent } from "@lib/calendarClient";
|
||||
import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail";
|
||||
import EventPaymentMail from "@lib/emails/EventPaymentMail";
|
||||
import { sendAwaitingPaymentEmail, sendOrganizerPaymentRefundFailedEmail } from "@lib/emails/email-manager";
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
import { createPaymentLink } from "./client";
|
||||
|
||||
export type PaymentInfo = {
|
||||
link?: string | null;
|
||||
reason?: string | null;
|
||||
id?: string | null;
|
||||
};
|
||||
|
||||
export type PaymentData = Stripe.Response<Stripe.PaymentIntent> & {
|
||||
stripe_publishable_key: string;
|
||||
stripeAccount: string;
|
||||
@@ -34,7 +38,7 @@ export async function handlePayment(
|
||||
price: number;
|
||||
currency: string;
|
||||
},
|
||||
stripeCredential: { key: JsonValue },
|
||||
stripeCredential: { key: Prisma.JsonValue },
|
||||
booking: {
|
||||
user: { email: string | null; name: string | null; timeZone: string } | null;
|
||||
id: number;
|
||||
@@ -60,7 +64,11 @@ export async function handlePayment(
|
||||
data: {
|
||||
type: PaymentType.STRIPE,
|
||||
uid: uuidv4(),
|
||||
bookingId: booking.id,
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
amount: selectedEventType.price,
|
||||
fee: paymentFee,
|
||||
currency: selectedEventType.currency,
|
||||
@@ -69,20 +77,21 @@ export async function handlePayment(
|
||||
data: Object.assign({}, paymentIntent, {
|
||||
stripe_publishable_key,
|
||||
stripeAccount: stripe_user_id,
|
||||
}) as PaymentData as unknown as JsonValue,
|
||||
}) /* We should treat this */ as PaymentData /* but Prisma doesn't know how to handle it, so it we treat it */ as unknown /* and then */ as Prisma.InputJsonValue,
|
||||
externalId: paymentIntent.id,
|
||||
},
|
||||
});
|
||||
|
||||
const mail = new EventPaymentMail(
|
||||
createPaymentLink({
|
||||
paymentUid: payment.uid,
|
||||
name: booking.user?.name,
|
||||
date: booking.startTime.toISOString(),
|
||||
}),
|
||||
evt
|
||||
);
|
||||
await mail.sendEmail();
|
||||
await sendAwaitingPaymentEmail({
|
||||
...evt,
|
||||
paymentInfo: {
|
||||
link: createPaymentLink({
|
||||
paymentUid: payment.uid,
|
||||
name: booking.user?.name,
|
||||
date: booking.startTime.toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return payment;
|
||||
}
|
||||
@@ -97,7 +106,7 @@ export async function refund(
|
||||
success: boolean;
|
||||
refunded: boolean;
|
||||
externalId: string;
|
||||
data: JsonValue;
|
||||
data: Prisma.JsonValue;
|
||||
type: PaymentType;
|
||||
}[];
|
||||
},
|
||||
@@ -107,7 +116,7 @@ export async function refund(
|
||||
const payment = booking.payment.find((e) => e.success && !e.refunded);
|
||||
if (!payment) return;
|
||||
|
||||
if (payment.type != PaymentType.STRIPE) {
|
||||
if (payment.type !== PaymentType.STRIPE) {
|
||||
await handleRefundError({
|
||||
event: calEvent,
|
||||
reason: "cannot refund non Stripe payment",
|
||||
@@ -153,11 +162,51 @@ export async function refund(
|
||||
|
||||
async function handleRefundError(opts: { event: CalendarEvent; reason: string; paymentId: string }) {
|
||||
console.error(`refund failed: ${opts.reason} for booking '${opts.event.uid}'`);
|
||||
try {
|
||||
await new EventOrganizerRefundFailedMail(opts.event, opts.reason, opts.paymentId).sendEmail();
|
||||
} catch (e) {
|
||||
console.error("Error while sending refund error email", e);
|
||||
await sendOrganizerPaymentRefundFailedEmail({
|
||||
...opts.event,
|
||||
paymentInfo: { reason: opts.reason, id: opts.paymentId },
|
||||
});
|
||||
}
|
||||
|
||||
const userType = Prisma.validator<Prisma.UserArgs>()({
|
||||
select: {
|
||||
email: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
type UserType = Prisma.UserGetPayload<typeof userType>;
|
||||
export async function getStripeCustomerId(user: UserType): Promise<string | null> {
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (user?.metadata && typeof user.metadata === "object" && "stripeCustomerId" in user.metadata) {
|
||||
customerId = (user?.metadata as Prisma.JsonObject).stripeCustomerId as string;
|
||||
} else {
|
||||
/* We fallback to finding the customer by email (which is not optimal) */
|
||||
const customersReponse = await stripe.customers.list({
|
||||
email: user.email,
|
||||
limit: 1,
|
||||
});
|
||||
if (customersReponse.data[0]?.id) {
|
||||
customerId = customersReponse.data[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
return customerId;
|
||||
}
|
||||
|
||||
export async function deleteStripeCustomer(user: UserType): Promise<string | null> {
|
||||
const customerId = await getStripeCustomerId(user);
|
||||
|
||||
if (!customerId) {
|
||||
console.warn("No stripe customer found for user:" + user.email);
|
||||
return null;
|
||||
}
|
||||
|
||||
//delete stripe customer
|
||||
const deletedCustomer = await stripe.customers.del(customerId);
|
||||
|
||||
return deletedCustomer.id;
|
||||
}
|
||||
|
||||
export default stripe;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import { BASE_URL } from "@lib/config/constants";
|
||||
import prisma from "@lib/prisma";
|
||||
|
||||
const client_id = process.env.STRIPE_CLIENT_ID;
|
||||
@@ -27,7 +28,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
});
|
||||
|
||||
const redirect_uri = encodeURI(process.env.BASE_URL + "/api/integrations/stripepayment/callback");
|
||||
const redirect_uri = encodeURI(BASE_URL + "/api/integrations/stripepayment/callback");
|
||||
const stripeConnectParams = {
|
||||
client_id,
|
||||
scope: "read_write",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import stripe from "@ee/lib/stripe/server";
|
||||
import stripe, { getStripeCustomerId } from "@ee/lib/stripe/server";
|
||||
|
||||
import { getSession } from "@lib/auth";
|
||||
import prisma from "@lib/prisma";
|
||||
@@ -23,6 +23,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,26 +32,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
message: "User email not found",
|
||||
});
|
||||
|
||||
/**
|
||||
* TODO: We need to find a better way to get our users customer id from Stripe,
|
||||
* since the email is not an unique field in Stripe and we don't save them
|
||||
* in our DB as of now.
|
||||
**/
|
||||
const customersReponse = await stripe.customers.list({
|
||||
email: user?.email || "",
|
||||
limit: 1,
|
||||
});
|
||||
const customerId = await getStripeCustomerId(user);
|
||||
|
||||
const [customer] = customersReponse.data;
|
||||
|
||||
if (!customer?.id)
|
||||
if (!customerId)
|
||||
return res.status(404).json({
|
||||
message: "Stripe customer id not found",
|
||||
});
|
||||
|
||||
const return_url = `${process.env.BASE_URL}/settings/billing`;
|
||||
const stripeSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customer.id,
|
||||
customer: customerId,
|
||||
return_url,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import Stripe from "stripe";
|
||||
|
||||
import stripe from "@ee/lib/stripe/server";
|
||||
|
||||
import { CalendarEvent } from "@lib/calendarClient";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
import { IS_PRODUCTION } from "@lib/config/constants";
|
||||
import { HttpError as HttpCode } from "@lib/core/http/error";
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
import EventManager from "@lib/events/EventManager";
|
||||
import { CalendarEvent } from "@lib/integrations/calendar/interfaces/Calendar";
|
||||
import prisma from "@lib/prisma";
|
||||
import { Ensure } from "@lib/types/utils";
|
||||
|
||||
@@ -30,6 +31,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
booking: {
|
||||
update: {
|
||||
paid: true,
|
||||
confirmed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -56,6 +58,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
email: true,
|
||||
name: true,
|
||||
locale: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -90,12 +93,12 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
if (booking.location) evt.location = booking.location;
|
||||
|
||||
if (booking.confirmed) {
|
||||
const eventManager = new EventManager(user.credentials);
|
||||
const eventManager = new EventManager(user);
|
||||
const scheduleResult = await eventManager.create(evt);
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: payment.bookingId,
|
||||
id: booking.id,
|
||||
},
|
||||
data: {
|
||||
references: {
|
||||
@@ -104,49 +107,34 @@ async function handlePaymentSuccess(event: Stripe.Event) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new HttpCode({
|
||||
statusCode: 200,
|
||||
message: `Booking with id '${booking.id}' was paid and confirmed.`,
|
||||
});
|
||||
}
|
||||
|
||||
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
|
||||
|
||||
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
|
||||
"payment_intent.succeeded": handlePaymentSuccess,
|
||||
"customer.subscription.deleted": async (event) => {
|
||||
const object = event.data.object as Stripe.Subscription;
|
||||
|
||||
const customerId = typeof object.customer === "string" ? object.customer : object.customer.id;
|
||||
|
||||
const customer = (await stripe.customers.retrieve(customerId)) as Stripe.Customer;
|
||||
if (typeof customer.email !== "string") {
|
||||
throw new Error(`Couldn't find customer email for ${customerId}`);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
email: customer.email,
|
||||
},
|
||||
data: {
|
||||
plan: "FREE",
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
if (req.method !== "POST") {
|
||||
throw new HttpError({ statusCode: 405, message: "Method Not Allowed" });
|
||||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
const sig = req.headers["stripe-signature"];
|
||||
if (!sig) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing stripe-signature" });
|
||||
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
|
||||
}
|
||||
|
||||
if (!process.env.STRIPE_WEBHOOK_SECRET) {
|
||||
throw new HttpError({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
|
||||
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET" });
|
||||
}
|
||||
const requestBuffer = await buffer(req);
|
||||
const payload = requestBuffer.toString();
|
||||
// console.log("payload", payload);
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
|
||||
@@ -154,14 +142,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
if (handler) {
|
||||
await handler(event);
|
||||
} else {
|
||||
console.warn(`Unhandled Stripe Webhook event type ${event.type}`);
|
||||
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
|
||||
throw new HttpCode({
|
||||
statusCode: 202,
|
||||
message: `Unhandled Stripe Webhook event type ${event.type}`,
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
console.error(`Webhook Error: ${err.message}`);
|
||||
res.status(err.statusCode ?? 500).send({
|
||||
message: err.message,
|
||||
stack: process.env.NODE_ENV === "production" ? undefined : err.stack,
|
||||
stack: IS_PRODUCTION ? undefined : err.stack,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
66
ee/pages/settings/teams/[id]/availability.tsx
Normal file
66
ee/pages/settings/teams/[id]/availability.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import TeamAvailabilityScreen from "@ee/components/team/availability/TeamAvailabilityScreen";
|
||||
|
||||
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
|
||||
import { trpc } from "@lib/trpc";
|
||||
|
||||
import Loader from "@components/Loader";
|
||||
import Shell, { useMeQuery } from "@components/Shell";
|
||||
import { Alert } from "@components/ui/Alert";
|
||||
import Avatar from "@components/ui/Avatar";
|
||||
|
||||
export function TeamAvailabilityPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const me = useMeQuery();
|
||||
const isFreeUser = me.data?.plan === "FREE";
|
||||
|
||||
const { data: team, isLoading } = trpc.useQuery(["viewer.teams.get", { teamId: Number(router.query.id) }], {
|
||||
refetchOnWindowFocus: false,
|
||||
onError: (e) => {
|
||||
setErrorMessage(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
// prevent unnecessary re-renders due to shell queries
|
||||
const TeamAvailability = useMemo(() => {
|
||||
return <TeamAvailabilityScreen team={team} />;
|
||||
}, [team]);
|
||||
|
||||
return (
|
||||
<Shell
|
||||
backPath={!errorMessage ? `/settings/teams/${team?.id}` : undefined}
|
||||
heading={!isFreeUser && team?.name}
|
||||
flexChildrenContainer
|
||||
subtitle={team && !isFreeUser && "Your team's availability at a glance"}
|
||||
HeadingLeftIcon={
|
||||
team &&
|
||||
!isFreeUser && (
|
||||
<Avatar
|
||||
size={12}
|
||||
imageSrc={getPlaceholderAvatar(team?.logo, team?.name as string)}
|
||||
alt="Team Logo"
|
||||
className="mt-1"
|
||||
/>
|
||||
)
|
||||
}>
|
||||
{!!errorMessage && <Alert className="-mt-24 border" severity="error" title={errorMessage} />}
|
||||
{isLoading && <Loader />}
|
||||
{isFreeUser ? (
|
||||
<Alert
|
||||
className="-mt-24 border"
|
||||
severity="warning"
|
||||
title="This is a pro feature. Upgrade to pro to see your team's availability."
|
||||
/>
|
||||
) : (
|
||||
TeamAvailability
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamAvailabilityPage;
|
||||
@@ -14,6 +14,7 @@ const config: Config.InitialOptions = {
|
||||
"^@components(.*)$": "<rootDir>/components$1",
|
||||
"^@lib(.*)$": "<rootDir>/lib$1",
|
||||
"^@server(.*)$": "<rootDir>/server$1",
|
||||
"^@ee(.*)$": "<rootDir>/ee$1",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
const opts = {
|
||||
// launch headless on CI, in browser locally
|
||||
headless: !!process.env.CI || !!process.env.PLAYWRIGHT_HEADLESS,
|
||||
collectCoverage: false, // not possible in Next.js 12
|
||||
executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE_PATH,
|
||||
};
|
||||
|
||||
console.log("⚙️ Playwright options:", JSON.stringify(opts, null, 4));
|
||||
|
||||
module.exports = {
|
||||
verbose: true,
|
||||
preset: "jest-playwright-preset",
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
testMatch: ["<rootDir>/playwright/**/*(*.)@(spec|test).[jt]s?(x)"],
|
||||
testEnvironmentOptions: {
|
||||
"jest-playwright": {
|
||||
browsers: ["chromium" /*, 'firefox', 'webkit'*/],
|
||||
exitOnPageError: false,
|
||||
launchType: "LAUNCH",
|
||||
launchOptions: {
|
||||
headless: opts.headless,
|
||||
executablePath: opts.executablePath,
|
||||
},
|
||||
contextOptions: {
|
||||
recordVideo: {
|
||||
dir: "playwright/videos",
|
||||
},
|
||||
},
|
||||
collectCoverage: opts.collectCoverage,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,151 +1,114 @@
|
||||
import { Person } from "ics";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import { getIntegrationName } from "@lib/integrations";
|
||||
|
||||
import { CalendarEvent } from "./calendarClient";
|
||||
import { stripHtml } from "./emails/helpers";
|
||||
import { BASE_URL } from "./config/constants";
|
||||
import { CalendarEvent } from "./integrations/calendar/interfaces/Calendar";
|
||||
|
||||
const translator = short();
|
||||
|
||||
export default class CalEventParser {
|
||||
protected calEvent: CalendarEvent;
|
||||
// The odd indentation in this file is necessary because otherwise the leading tabs will be applied into the event description.
|
||||
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
this.calEvent = calEvent;
|
||||
}
|
||||
export const getWhat = (calEvent: CalendarEvent) => {
|
||||
return `
|
||||
${calEvent.language("what")}:
|
||||
${calEvent.type}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a link to reschedule the given booking.
|
||||
*/
|
||||
public getRescheduleLink(): string {
|
||||
return process.env.BASE_URL + "/reschedule/" + this.getUid();
|
||||
}
|
||||
export const getWhen = (calEvent: CalendarEvent) => {
|
||||
return `
|
||||
${calEvent.language("invitee_timezone")}:
|
||||
${calEvent.attendees[0].timeZone}
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a link to cancel the given booking.
|
||||
*/
|
||||
public getCancelLink(): string {
|
||||
return process.env.BASE_URL + "/cancel/" + this.getUid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a unique identifier for the given calendar event.
|
||||
*/
|
||||
public getUid(): string {
|
||||
return this.calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(this.calEvent), uuidv5.URL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a footer section with links to change the event (as HTML).
|
||||
*/
|
||||
public getChangeEventFooterHtml(): string {
|
||||
return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
|
||||
"need_to_make_a_change"
|
||||
)} <a href="${this.getCancelLink()}" style="color: #161e2e;">${this.calEvent.language(
|
||||
"cancel"
|
||||
)}</a> ${this.calEvent
|
||||
.language("or")
|
||||
.toLowerCase()} <a href="${this.getRescheduleLink()}" style="color: #161e2e;">${this.calEvent.language(
|
||||
"reschedule"
|
||||
)}</a></p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a footer section with links to change the event (as plain text).
|
||||
*/
|
||||
public getChangeEventFooter(): string {
|
||||
return stripHtml(this.getChangeEventFooterHtml());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an extended description with all important information (as HTML).
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
public getRichDescriptionHtml(): string {
|
||||
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
|
||||
return (
|
||||
`
|
||||
<strong>${this.calEvent.language("event_type")}:</strong><br />${this.calEvent.type}<br />
|
||||
<strong>${this.calEvent.language("invitee_email")}:</strong><br /><a href="mailto:${
|
||||
this.calEvent.attendees[0].email
|
||||
}">${this.calEvent.attendees[0].email}</a><br />
|
||||
` +
|
||||
(this.getLocation()
|
||||
? `<strong>${this.calEvent.language("location")}:</strong><br />${this.getLocation()}<br />
|
||||
`
|
||||
: "") +
|
||||
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />${
|
||||
this.calEvent.attendees[0].timeZone
|
||||
}<br />
|
||||
<strong>${this.calEvent.language("additional_notes")}:</strong><br />${this.getDescriptionText()}<br />` +
|
||||
this.getChangeEventFooterHtml()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally returns the event's location. When VideoCallData is set,
|
||||
* it returns the meeting url. Otherwise, the regular location is returned.
|
||||
* For Daily video calls returns the direct link
|
||||
* @protected
|
||||
*/
|
||||
protected getLocation(): string | null | undefined {
|
||||
const isDaily = this.calEvent.location === "integrations:daily";
|
||||
if (this.calEvent.videoCallData) {
|
||||
return this.calEvent.videoCallData.url;
|
||||
}
|
||||
if (isDaily) {
|
||||
return process.env.BASE_URL + "/call/" + this.getUid();
|
||||
}
|
||||
return this.calEvent.location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the event's description text. If VideoCallData is set, it prepends
|
||||
* some video call information before the text as well.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getDescriptionText(): string | null | undefined {
|
||||
if (this.calEvent.videoCallData) {
|
||||
export const getWho = (calEvent: CalendarEvent) => {
|
||||
const attendees = calEvent.attendees
|
||||
.map((attendee) => {
|
||||
return `
|
||||
${this.calEvent.language("integration_meeting_id", {
|
||||
integrationName: getIntegrationName(this.calEvent.videoCallData.type),
|
||||
meetingId: this.calEvent.videoCallData.id,
|
||||
})}
|
||||
${this.calEvent.language("password")}: ${this.calEvent.videoCallData.password}
|
||||
${this.calEvent.description}`;
|
||||
}
|
||||
return this.calEvent.description;
|
||||
${attendee?.name || calEvent.language("guest")}
|
||||
${attendee.email}
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const organizer = `
|
||||
${calEvent.organizer.name} - ${calEvent.language("organizer")}
|
||||
${calEvent.organizer.email}
|
||||
`;
|
||||
|
||||
return `
|
||||
${calEvent.language("who")}:
|
||||
${organizer + attendees}
|
||||
`;
|
||||
};
|
||||
|
||||
export const getAdditionalNotes = (calEvent: CalendarEvent) => {
|
||||
return `
|
||||
${calEvent.language("additional_notes")}:
|
||||
${calEvent.description}
|
||||
`;
|
||||
};
|
||||
|
||||
export const getLocation = (calEvent: CalendarEvent) => {
|
||||
let providerName = calEvent.location ? getIntegrationName(calEvent.location) : "";
|
||||
|
||||
if (calEvent.location && calEvent.location.includes("integrations:")) {
|
||||
const location = calEvent.location.split(":")[1];
|
||||
providerName = location[0].toUpperCase() + location.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an extended description with all important information (as plain text).
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
public getRichDescription(): string {
|
||||
return stripHtml(this.getRichDescriptionHtml());
|
||||
if (calEvent.videoCallData) {
|
||||
return calEvent.videoCallData.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a calendar event with rich description.
|
||||
*/
|
||||
public asRichEvent(): CalendarEvent {
|
||||
const eventCopy: CalendarEvent = { ...this.calEvent };
|
||||
eventCopy.description = this.getRichDescriptionHtml();
|
||||
eventCopy.location = this.getLocation();
|
||||
return eventCopy;
|
||||
if (calEvent.additionInformation?.hangoutLink) {
|
||||
return calEvent.additionInformation.hangoutLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a calendar event with rich description as plain text.
|
||||
*/
|
||||
public asRichEventPlain(): CalendarEvent {
|
||||
const eventCopy: CalendarEvent = { ...this.calEvent };
|
||||
eventCopy.description = this.getRichDescription();
|
||||
eventCopy.location = this.getLocation();
|
||||
return eventCopy;
|
||||
return providerName || calEvent.location || "";
|
||||
};
|
||||
|
||||
export const getManageLink = (calEvent: CalendarEvent) => {
|
||||
return `
|
||||
${calEvent.language("need_to_reschedule_or_cancel")}
|
||||
${getCancelLink(calEvent)}
|
||||
`;
|
||||
};
|
||||
|
||||
export const getUid = (calEvent: CalendarEvent): string => {
|
||||
return calEvent.uid ?? translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
|
||||
};
|
||||
|
||||
export const getCancelLink = (calEvent: CalendarEvent): string => {
|
||||
return BASE_URL + "/cancel/" + getUid(calEvent);
|
||||
};
|
||||
|
||||
export const getRichDescription = (calEvent: CalendarEvent, attendee?: Person) => {
|
||||
// Only the original attendee can make changes to the event
|
||||
// Guests cannot
|
||||
|
||||
if (attendee && attendee === calEvent.attendees[0]) {
|
||||
return `
|
||||
${getWhat(calEvent)}
|
||||
${getWhen(calEvent)}
|
||||
${getWho(calEvent)}
|
||||
${calEvent.language("where")}:
|
||||
${getLocation(calEvent)}
|
||||
${getAdditionalNotes(calEvent)}
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
${getWhat(calEvent)}
|
||||
${getWhen(calEvent)}
|
||||
${getWho(calEvent)}
|
||||
${calEvent.language("where")}:
|
||||
${getLocation(calEvent)}
|
||||
${getAdditionalNotes(calEvent)}
|
||||
${getManageLink(calEvent)}
|
||||
`.trim();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IdProvider } from "@radix-ui/react-id";
|
||||
import { Provider } from "next-auth/client";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
@@ -44,9 +44,9 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
<TelemetryProvider value={createTelemetryClient()}>
|
||||
<IdProvider>
|
||||
<DynamicIntercomProvider>
|
||||
<Provider session={session || undefined}>
|
||||
<SessionProvider session={session || undefined}>
|
||||
<CustomI18nextProvider {...props}>{props.children}</CustomI18nextProvider>
|
||||
</Provider>
|
||||
</SessionProvider>
|
||||
</DynamicIntercomProvider>
|
||||
</IdProvider>
|
||||
</TelemetryProvider>
|
||||
|
||||
23
lib/auth.ts
23
lib/auth.ts
@@ -1,6 +1,7 @@
|
||||
import { IdentityProvider } from "@prisma/client";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { DefaultSession } from "next-auth";
|
||||
import { getSession as getSessionInner, GetSessionOptions } from "next-auth/client";
|
||||
import { Session } from "next-auth";
|
||||
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
@@ -12,16 +13,7 @@ export async function verifyPassword(password: string, hashedPassword: string) {
|
||||
return isValid;
|
||||
}
|
||||
|
||||
type DefaultSessionUser = NonNullable<DefaultSession["user"]>;
|
||||
type CalendsoSessionUser = DefaultSessionUser & {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
export interface Session extends DefaultSession {
|
||||
user?: CalendsoSessionUser;
|
||||
}
|
||||
|
||||
export async function getSession(options: GetSessionOptions): Promise<Session | null> {
|
||||
export async function getSession(options: GetSessionParams): Promise<Session | null> {
|
||||
const session = await getSessionInner(options);
|
||||
|
||||
// that these are equal are ensured in `[...nextauth]`'s callback
|
||||
@@ -39,4 +31,11 @@ export enum ErrorCode {
|
||||
IncorrectTwoFactorCode = "incorrect-two-factor-code",
|
||||
InternalServerError = "internal-server-error",
|
||||
NewPasswordMatchesOld = "new-password-matches-old",
|
||||
ThirdPartyIdentityProviderEnabled = "third-party-identity-provider-enabled",
|
||||
}
|
||||
|
||||
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
|
||||
[IdentityProvider.CAL]: "Cal",
|
||||
[IdentityProvider.GOOGLE]: "Google",
|
||||
[IdentityProvider.SAML]: "SAML",
|
||||
};
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { Availability } from "@prisma/client";
|
||||
import dayjs, { ConfigType } from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { Schedule, TimeRange } from "./types/schedule";
|
||||
import { Schedule, TimeRange, WorkingHours } from "./types/schedule";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(customParseFormat);
|
||||
// sets the desired time in current date, needs to be current date for proper DST translation
|
||||
export const defaultDayRange: TimeRange = {
|
||||
start: new Date(new Date().setHours(9, 0, 0, 0)),
|
||||
end: new Date(new Date().setHours(17, 0, 0, 0)),
|
||||
start: new Date(new Date().setUTCHours(9, 0, 0, 0)),
|
||||
end: new Date(new Date().setUTCHours(17, 0, 0, 0)),
|
||||
};
|
||||
|
||||
export const DEFAULT_SCHEDULE: Schedule = [
|
||||
@@ -45,3 +52,75 @@ export function getAvailabilityFromSchedule(schedule: Schedule): Availability[]
|
||||
return availability;
|
||||
}, [] as Availability[]);
|
||||
}
|
||||
|
||||
export const MINUTES_IN_DAY = 60 * 24;
|
||||
export const MINUTES_DAY_END = MINUTES_IN_DAY - 1;
|
||||
export const MINUTES_DAY_START = 0;
|
||||
|
||||
/**
|
||||
* Allows "casting" availability (days, startTime, endTime) given in UTC to a timeZone or utcOffset
|
||||
*/
|
||||
export function getWorkingHours(
|
||||
relativeTimeUnit: {
|
||||
timeZone?: string;
|
||||
utcOffset?: number;
|
||||
},
|
||||
availability: { days: number[]; startTime: ConfigType; endTime: ConfigType }[]
|
||||
) {
|
||||
// clearly bail when availability is not set, set everything available.
|
||||
if (!availability.length) {
|
||||
return [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
// shorthand for: dayjs().startOf("day").tz(timeZone).diff(dayjs.utc().startOf("day"), "minutes")
|
||||
startTime: MINUTES_DAY_START,
|
||||
endTime: MINUTES_DAY_END,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const utcOffset = relativeTimeUnit.utcOffset ?? dayjs().tz(relativeTimeUnit.timeZone).utcOffset();
|
||||
|
||||
const workingHours = availability.reduce((workingHours: WorkingHours[], schedule) => {
|
||||
// Get times localised to the given utcOffset/timeZone
|
||||
const startTime =
|
||||
dayjs.utc(schedule.startTime).get("hour") * 60 +
|
||||
dayjs.utc(schedule.startTime).get("minute") -
|
||||
utcOffset;
|
||||
const endTime =
|
||||
dayjs.utc(schedule.endTime).get("hour") * 60 + dayjs.utc(schedule.endTime).get("minute") - utcOffset;
|
||||
|
||||
// add to working hours, keeping startTime and endTimes between bounds (0-1439)
|
||||
const sameDayStartTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, startTime));
|
||||
const sameDayEndTime = Math.max(MINUTES_DAY_START, Math.min(MINUTES_DAY_END, endTime));
|
||||
if (sameDayStartTime !== sameDayEndTime) {
|
||||
workingHours.push({
|
||||
days: schedule.days,
|
||||
startTime: sameDayStartTime,
|
||||
endTime: sameDayEndTime,
|
||||
});
|
||||
}
|
||||
// check for overflow to the previous day
|
||||
if (startTime < MINUTES_DAY_START || endTime < MINUTES_DAY_START) {
|
||||
workingHours.push({
|
||||
days: schedule.days.map((day) => day - 1),
|
||||
startTime: startTime + MINUTES_IN_DAY,
|
||||
endTime: Math.min(endTime + MINUTES_IN_DAY, MINUTES_DAY_END),
|
||||
});
|
||||
}
|
||||
// else, check for overflow in the next day
|
||||
else if (startTime > MINUTES_DAY_END || endTime > MINUTES_DAY_END) {
|
||||
workingHours.push({
|
||||
days: schedule.days.map((day) => day + 1),
|
||||
startTime: Math.max(startTime - MINUTES_IN_DAY, MINUTES_DAY_START),
|
||||
endTime: endTime - MINUTES_IN_DAY,
|
||||
});
|
||||
}
|
||||
|
||||
return workingHours;
|
||||
}, []);
|
||||
|
||||
workingHours.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
return workingHours;
|
||||
}
|
||||
|
||||
@@ -1,748 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||
import { Credential, Prisma, SelectedCalendar } from "@prisma/client";
|
||||
import { GetTokenResponse } from "google-auth-library/build/src/auth/oauth2client";
|
||||
import { Auth, calendar_v3, google } from "googleapis";
|
||||
import { TFunction } from "next-i18next";
|
||||
|
||||
import { Event, EventResult } from "@lib/events/EventManager";
|
||||
import logger from "@lib/logger";
|
||||
import { VideoCallData } from "@lib/videoClient";
|
||||
|
||||
import CalEventParser from "./CalEventParser";
|
||||
import EventOrganizerMail from "./emails/EventOrganizerMail";
|
||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||
import { AppleCalendar } from "./integrations/Apple/AppleCalendarAdapter";
|
||||
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
|
||||
import prisma from "./prisma";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||
|
||||
const googleAuth = (credential: Credential) => {
|
||||
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS!).web;
|
||||
const myGoogleAuth = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||
const googleCredentials = credential.key as Auth.Credentials;
|
||||
myGoogleAuth.setCredentials(googleCredentials);
|
||||
|
||||
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
|
||||
const isExpired = () => myGoogleAuth.isTokenExpiring();
|
||||
|
||||
const refreshAccessToken = () =>
|
||||
myGoogleAuth
|
||||
// FIXME - type errors IDK Why this is a protected method ¯\_(ツ)_/¯
|
||||
.refreshToken(googleCredentials.refresh_token)
|
||||
.then((res: GetTokenResponse) => {
|
||||
const token = res.res?.data;
|
||||
googleCredentials.access_token = token.access_token;
|
||||
googleCredentials.expiry_date = token.expiry_date;
|
||||
return prisma.credential
|
||||
.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: googleCredentials as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
myGoogleAuth.setCredentials(googleCredentials);
|
||||
return myGoogleAuth;
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error refreshing google token", err);
|
||||
return myGoogleAuth;
|
||||
});
|
||||
|
||||
return {
|
||||
getToken: () => (!isExpired() ? Promise.resolve(myGoogleAuth) : refreshAccessToken()),
|
||||
};
|
||||
};
|
||||
|
||||
function handleErrorsJson(response: Response) {
|
||||
if (!response.ok) {
|
||||
response.json().then((e) => console.error("O365 Error", e));
|
||||
throw Error(response.statusText);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function handleErrorsRaw(response: Response) {
|
||||
if (!response.ok) {
|
||||
response.text().then((e) => console.error("O365 Error", e));
|
||||
throw Error(response.statusText);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
type O365AuthCredentials = {
|
||||
expiry_date: number;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
|
||||
const o365Auth = (credential: Credential) => {
|
||||
const isExpired = (expiryDate: number) => expiryDate < Math.round(+new Date() / 1000);
|
||||
const o365AuthCredentials = credential.key as O365AuthCredentials;
|
||||
|
||||
const refreshAccessToken = (refreshToken: string) => {
|
||||
return fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
// FIXME types - IDK how to type this TBH
|
||||
body: new URLSearchParams({
|
||||
scope: "User.Read Calendars.Read Calendars.ReadWrite",
|
||||
client_id: process.env.MS_GRAPH_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
client_secret: process.env.MS_GRAPH_CLIENT_SECRET,
|
||||
}),
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody) => {
|
||||
o365AuthCredentials.access_token = responseBody.access_token;
|
||||
o365AuthCredentials.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||
return prisma.credential
|
||||
.update({
|
||||
where: {
|
||||
id: credential.id,
|
||||
},
|
||||
data: {
|
||||
key: o365AuthCredentials,
|
||||
},
|
||||
})
|
||||
.then(() => o365AuthCredentials.access_token);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
getToken: () =>
|
||||
!isExpired(o365AuthCredentials.expiry_date)
|
||||
? Promise.resolve(o365AuthCredentials.access_token)
|
||||
: refreshAccessToken(o365AuthCredentials.refresh_token),
|
||||
};
|
||||
};
|
||||
|
||||
export type Person = { name: string; email: string; timeZone: string };
|
||||
|
||||
export interface EntryPoint {
|
||||
entryPointType?: string;
|
||||
uri?: string;
|
||||
label?: string;
|
||||
pin?: string;
|
||||
accessCode?: string;
|
||||
meetingCode?: string;
|
||||
passcode?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface AdditionInformation {
|
||||
conferenceData?: ConferenceData;
|
||||
entryPoints?: EntryPoint[];
|
||||
hangoutLink?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
type: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
description?: string | null;
|
||||
team?: {
|
||||
name: string;
|
||||
members: string[];
|
||||
};
|
||||
location?: string | null;
|
||||
organizer: Person;
|
||||
attendees: Person[];
|
||||
conferenceData?: ConferenceData;
|
||||
language: TFunction;
|
||||
additionInformation?: AdditionInformation;
|
||||
/** If this property exist it we can assume it's a reschedule/update */
|
||||
uid?: string | null;
|
||||
videoCallData?: VideoCallData;
|
||||
}
|
||||
|
||||
export interface ConferenceData {
|
||||
createRequest: calendar_v3.Schema$CreateConferenceRequest;
|
||||
}
|
||||
export interface IntegrationCalendar extends Partial<SelectedCalendar> {
|
||||
primary?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
type BufferedBusyTime = { start: string; end: string };
|
||||
export interface CalendarApiAdapter {
|
||||
createEvent(event: CalendarEvent): Promise<Event>;
|
||||
|
||||
updateEvent(uid: string, event: CalendarEvent): Promise<any>;
|
||||
|
||||
deleteEvent(uid: string): Promise<unknown>;
|
||||
|
||||
getAvailability(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<BufferedBusyTime[]>;
|
||||
|
||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||
}
|
||||
|
||||
const MicrosoftOffice365Calendar = (credential: Credential): CalendarApiAdapter => {
|
||||
const auth = o365Auth(credential);
|
||||
|
||||
const translateEvent = (event: CalendarEvent) => {
|
||||
return {
|
||||
subject: event.title,
|
||||
body: {
|
||||
contentType: "HTML",
|
||||
content: event.description,
|
||||
},
|
||||
start: {
|
||||
dateTime: event.startTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
end: {
|
||||
dateTime: event.endTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
attendees: event.attendees.map((attendee) => ({
|
||||
emailAddress: {
|
||||
address: attendee.email,
|
||||
name: attendee.name,
|
||||
},
|
||||
type: "required",
|
||||
})),
|
||||
location: event.location ? { displayName: event.location } : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const integrationType = "office365_calendar";
|
||||
|
||||
function listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
return auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendars", {
|
||||
method: "get",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody: { value: OfficeCalendar[] }) => {
|
||||
return responseBody.value.map((cal) => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.id ?? "No Id",
|
||||
integration: integrationType,
|
||||
name: cal.name ?? "No calendar name",
|
||||
primary: cal.isDefaultCalendar ?? false,
|
||||
};
|
||||
return calendar;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo, selectedCalendars) => {
|
||||
const filter = `?startdatetime=${encodeURIComponent(dateFrom)}&enddatetime=${encodeURIComponent(
|
||||
dateTo
|
||||
)}`;
|
||||
return auth
|
||||
.getToken()
|
||||
.then((accessToken) => {
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === integrationType)
|
||||
.map((e) => e.externalId)
|
||||
.filter(Boolean);
|
||||
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
|
||||
// Only calendars of other integrations selected
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return (
|
||||
selectedCalendarIds.length === 0
|
||||
? listCalendars().then((cals) => cals.map((e) => e.externalId).filter(Boolean) || [])
|
||||
: Promise.resolve(selectedCalendarIds)
|
||||
).then((ids) => {
|
||||
const requests = ids.map((calendarId, id) => ({
|
||||
id,
|
||||
method: "GET",
|
||||
headers: {
|
||||
Prefer: 'outlook.timezone="Etc/GMT"',
|
||||
},
|
||||
url: `/me/calendars/${calendarId}/calendarView${filter}`,
|
||||
}));
|
||||
|
||||
type BatchResponse = {
|
||||
responses: SubResponse[];
|
||||
};
|
||||
type SubResponse = {
|
||||
body: { value: { start: { dateTime: string }; end: { dateTime: string } }[] };
|
||||
};
|
||||
|
||||
return fetch("https://graph.microsoft.com/v1.0/$batch", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ requests }),
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody: BatchResponse) =>
|
||||
responseBody.responses.reduce(
|
||||
(acc: BufferedBusyTime[], subResponse) =>
|
||||
acc.concat(
|
||||
subResponse.body.value.map((evt) => {
|
||||
return {
|
||||
start: evt.start.dateTime + "Z",
|
||||
end: evt.end.dateTime + "Z",
|
||||
};
|
||||
})
|
||||
),
|
||||
[]
|
||||
)
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
return Promise.reject([]);
|
||||
});
|
||||
},
|
||||
createEvent: (event: CalendarEvent) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
})
|
||||
.then(handleErrorsJson)
|
||||
.then((responseBody) => ({
|
||||
...responseBody,
|
||||
disableConfirmationEmail: true,
|
||||
}))
|
||||
),
|
||||
deleteEvent: (uid: string) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
},
|
||||
}).then(handleErrorsRaw)
|
||||
),
|
||||
updateEvent: (uid: string, event: CalendarEvent) =>
|
||||
auth.getToken().then((accessToken) =>
|
||||
fetch("https://graph.microsoft.com/v1.0/me/calendar/events/" + uid, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: "Bearer " + accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(translateEvent(event)),
|
||||
}).then(handleErrorsRaw)
|
||||
),
|
||||
listCalendars,
|
||||
};
|
||||
};
|
||||
|
||||
const GoogleCalendar = (credential: Credential): CalendarApiAdapter => {
|
||||
const auth = googleAuth(credential);
|
||||
const integrationType = "google_calendar";
|
||||
|
||||
return {
|
||||
getAvailability: (dateFrom, dateTo, selectedCalendars) =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === integrationType)
|
||||
.map((e) => e.externalId);
|
||||
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
|
||||
// Only calendars of other integrations selected
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
(selectedCalendarIds.length === 0
|
||||
? calendar.calendarList
|
||||
.list()
|
||||
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
|
||||
: Promise.resolve(selectedCalendarIds)
|
||||
)
|
||||
.then((calsIds) => {
|
||||
calendar.freebusy.query(
|
||||
{
|
||||
requestBody: {
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: calsIds.map((id) => ({ id: id })),
|
||||
},
|
||||
},
|
||||
(err, apires) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
// @ts-ignore FIXME
|
||||
resolve(Object.values(apires.data.calendars).flatMap((item) => item["busy"]));
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
),
|
||||
createEvent: (event: CalendarEvent) =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const payload: calendar_v3.Schema$Event = {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
start: {
|
||||
dateTime: event.startTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
end: {
|
||||
dateTime: event.endTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
attendees: event.attendees,
|
||||
reminders: {
|
||||
useDefault: false,
|
||||
overrides: [{ method: "email", minutes: 10 }],
|
||||
},
|
||||
};
|
||||
|
||||
if (event.location) {
|
||||
payload["location"] = event.location;
|
||||
}
|
||||
|
||||
if (event.conferenceData && event.location === "integrations:google:meet") {
|
||||
payload["conferenceData"] = event.conferenceData;
|
||||
}
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.events.insert(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
calendarId: "primary",
|
||||
requestBody: payload,
|
||||
conferenceDataVersion: 1,
|
||||
},
|
||||
function (err, event) {
|
||||
if (err || !event?.data) {
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
return reject(err);
|
||||
}
|
||||
// @ts-ignore FIXME
|
||||
return resolve(event.data);
|
||||
}
|
||||
);
|
||||
})
|
||||
),
|
||||
updateEvent: (uid: string, event: CalendarEvent) =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const payload: calendar_v3.Schema$Event = {
|
||||
summary: event.title,
|
||||
description: event.description,
|
||||
start: {
|
||||
dateTime: event.startTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
end: {
|
||||
dateTime: event.endTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
attendees: event.attendees,
|
||||
reminders: {
|
||||
useDefault: false,
|
||||
overrides: [{ method: "email", minutes: 10 }],
|
||||
},
|
||||
};
|
||||
|
||||
if (event.location) {
|
||||
payload["location"] = event.location;
|
||||
}
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.events.update(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
calendarId: "primary",
|
||||
eventId: uid,
|
||||
sendNotifications: true,
|
||||
sendUpdates: "all",
|
||||
requestBody: payload,
|
||||
},
|
||||
function (err, event) {
|
||||
if (err) {
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(event?.data);
|
||||
}
|
||||
);
|
||||
})
|
||||
),
|
||||
deleteEvent: (uid: string) =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.events.delete(
|
||||
{
|
||||
auth: myGoogleAuth,
|
||||
calendarId: "primary",
|
||||
eventId: uid,
|
||||
sendNotifications: true,
|
||||
sendUpdates: "all",
|
||||
},
|
||||
function (err, event) {
|
||||
if (err) {
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(event?.data);
|
||||
}
|
||||
);
|
||||
})
|
||||
),
|
||||
listCalendars: () =>
|
||||
new Promise((resolve, reject) =>
|
||||
auth.getToken().then((myGoogleAuth) => {
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
auth: myGoogleAuth,
|
||||
});
|
||||
calendar.calendarList
|
||||
.list()
|
||||
.then((cals) => {
|
||||
resolve(
|
||||
cals.data.items?.map((cal) => {
|
||||
const calendar: IntegrationCalendar = {
|
||||
externalId: cal.id ?? "No id",
|
||||
integration: integrationType,
|
||||
name: cal.summary ?? "No name",
|
||||
primary: cal.primary ?? false,
|
||||
};
|
||||
return calendar;
|
||||
}) || []
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("There was an error contacting google calendar service: ", err);
|
||||
reject(err);
|
||||
});
|
||||
})
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
function getCalendarAdapterOrNull(credential: Credential): CalendarApiAdapter | null {
|
||||
switch (credential.type) {
|
||||
case "google_calendar":
|
||||
return GoogleCalendar(credential);
|
||||
case "office365_calendar":
|
||||
return MicrosoftOffice365Calendar(credential);
|
||||
case "caldav_calendar":
|
||||
// FIXME types wrong & type casting should not be needed
|
||||
return new CalDavCalendar(credential) as never as CalendarApiAdapter;
|
||||
case "apple_calendar":
|
||||
// FIXME types wrong & type casting should not be needed
|
||||
return new AppleCalendar(credential) as never as CalendarApiAdapter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
const calendars = (withCredentials: Credential[]): CalendarApiAdapter[] =>
|
||||
withCredentials
|
||||
.map((cred) => {
|
||||
switch (cred.type) {
|
||||
case "google_calendar":
|
||||
return GoogleCalendar(cred);
|
||||
case "office365_calendar":
|
||||
return MicrosoftOffice365Calendar(cred);
|
||||
case "caldav_calendar":
|
||||
return new CalDavCalendar(cred);
|
||||
case "apple_calendar":
|
||||
return new AppleCalendar(cred);
|
||||
default:
|
||||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
})
|
||||
.flatMap((item) => (item ? [item as CalendarApiAdapter] : []));
|
||||
|
||||
const getBusyCalendarTimes = (
|
||||
withCredentials: Credential[],
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: SelectedCalendar[]
|
||||
) =>
|
||||
Promise.all(
|
||||
calendars(withCredentials).map((c) => c.getAvailability(dateFrom, dateTo, selectedCalendars))
|
||||
).then((results) => {
|
||||
return results.reduce((acc, availability) => acc.concat(availability), []);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param withCredentials
|
||||
* @deprecated
|
||||
*/
|
||||
const listCalendars = (withCredentials: Credential[]) =>
|
||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
|
||||
);
|
||||
|
||||
const createEvent = async (
|
||||
credential: Credential,
|
||||
calEvent: CalendarEvent,
|
||||
noMail: boolean | null = false
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
const uid: string = parser.getUid();
|
||||
/*
|
||||
* Matching the credential type is a workaround because the office calendar simply strips away newlines (\n and \r).
|
||||
* We need HTML there. Google Calendar understands newlines and Apple Calendar cannot show HTML, so no HTML should
|
||||
* be used for Google and Apple Calendar.
|
||||
*/
|
||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||
|
||||
let success = true;
|
||||
|
||||
const creationResult = credential
|
||||
? await calendars([credential])[0]
|
||||
.createEvent(richEvent)
|
||||
.catch((e) => {
|
||||
log.error("createEvent failed", e, calEvent);
|
||||
success = false;
|
||||
return undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!creationResult) {
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
}
|
||||
|
||||
const metadata: AdditionInformation = {};
|
||||
if (creationResult) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
metadata.hangoutLink = creationResult.hangoutLink;
|
||||
metadata.conferenceData = creationResult.conferenceData;
|
||||
metadata.entryPoints = creationResult.entryPoints;
|
||||
}
|
||||
|
||||
calEvent.additionInformation = metadata;
|
||||
|
||||
if (!noMail) {
|
||||
const organizerMail = new EventOrganizerMail(calEvent);
|
||||
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid,
|
||||
createdEvent: creationResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const updateEvent = async (
|
||||
credential: Credential,
|
||||
calEvent: CalendarEvent,
|
||||
noMail: boolean | null = false,
|
||||
bookingRefUid: string | null
|
||||
): Promise<EventResult> => {
|
||||
const parser: CalEventParser = new CalEventParser(calEvent);
|
||||
const uid = parser.getUid();
|
||||
const richEvent: CalendarEvent = parser.asRichEventPlain();
|
||||
|
||||
let success = true;
|
||||
|
||||
const updatedResult =
|
||||
credential && bookingRefUid
|
||||
? await calendars([credential])[0]
|
||||
.updateEvent(bookingRefUid, richEvent)
|
||||
.catch((e) => {
|
||||
log.error("updateEvent failed", e, calEvent);
|
||||
success = false;
|
||||
return undefined;
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!updatedResult) {
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
}
|
||||
|
||||
if (!noMail) {
|
||||
const organizerMail = new EventOrganizerRescheduledMail(calEvent);
|
||||
try {
|
||||
await organizerMail.sendEmail();
|
||||
} catch (e) {
|
||||
console.error("organizerMail.sendEmail failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: credential.type,
|
||||
success,
|
||||
uid,
|
||||
updatedEvent: updatedResult,
|
||||
originalEvent: calEvent,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteEvent = (credential: Credential, uid: string): Promise<unknown> => {
|
||||
if (credential) {
|
||||
return calendars([credential])[0].deleteEvent(uid);
|
||||
}
|
||||
|
||||
return Promise.resolve({});
|
||||
};
|
||||
|
||||
export {
|
||||
getBusyCalendarTimes,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
listCalendars,
|
||||
getCalendarAdapterOrNull,
|
||||
};
|
||||
4
lib/config/constants.ts
Normal file
4
lib/config/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_URL}`;
|
||||
export const WEBSITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://cal.com";
|
||||
export const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||
export const TRIAL_LIMIT_DAYS = 14;
|
||||
@@ -45,7 +45,7 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
|
||||
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
|
||||
if (resizeRatio <= 0.75) {
|
||||
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
|
||||
return getCroppedImg(canvas.toDataURL("image/jpeg"), {
|
||||
return getCroppedImg(canvas.toDataURL("image/png"), {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
x: 0,
|
||||
@@ -53,5 +53,5 @@ export async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/jpeg");
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import EventMail from "./EventMail";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventAttendeeMail extends EventMail {
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getHtmlRepresentation(): string {
|
||||
return (
|
||||
`
|
||||
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
|
||||
<div
|
||||
style="
|
||||
margin: 0 auto;
|
||||
max-width: 450px;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 2rem 2rem 2rem 2rem;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="height: 60px; width: 60px; color: #31c48d"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language(
|
||||
"your_meeting_has_been_booked"
|
||||
)}</h1>
|
||||
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
|
||||
<hr />
|
||||
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
||||
<colgroup>
|
||||
<col span="1" style="width: 40%;">
|
||||
<col span="1" style="width: 60%;">
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("what")}</td>
|
||||
<td>${this.calEvent.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("when")}</td>
|
||||
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
|
||||
this.calEvent.attendees[0].timeZone
|
||||
})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("who")}</td>
|
||||
<td>
|
||||
${this.calEvent.team?.name || this.calEvent.organizer.name}<br />
|
||||
<small>
|
||||
${this.calEvent.organizer.email && !this.calEvent.team ? this.calEvent.organizer.email : ""}
|
||||
${this.calEvent.team ? this.calEvent.team.members.join(", ") : ""}
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("where")}</td>
|
||||
<td>${this.getLocation()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("notes")}</td>
|
||||
<td>${this.calEvent.description}</td>
|
||||
</tr>
|
||||
</table>
|
||||
` +
|
||||
this.getAdditionalBody() +
|
||||
"<br />" +
|
||||
`
|
||||
<hr />
|
||||
` +
|
||||
this.getAdditionalFooter() +
|
||||
`
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
|
||||
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
|
||||
</body>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the video call information to the mail body.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getLocation(): string {
|
||||
if (this.calEvent.additionInformation?.hangoutLink) {
|
||||
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
|
||||
}
|
||||
|
||||
if (
|
||||
this.calEvent.additionInformation?.entryPoints &&
|
||||
this.calEvent.additionInformation?.entryPoints.length > 0
|
||||
) {
|
||||
const locations = this.calEvent.additionInformation?.entryPoints
|
||||
.map((entryPoint) => {
|
||||
return `
|
||||
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
|
||||
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||
`;
|
||||
})
|
||||
.join("<br />");
|
||||
|
||||
return `${locations}`;
|
||||
}
|
||||
|
||||
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
|
||||
}
|
||||
|
||||
protected getAdditionalBody(): string {
|
||||
return ``;
|
||||
}
|
||||
|
||||
protected getAdditionalFooter(): string {
|
||||
return this.parser.getChangeEventFooterHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
subject: this.calEvent.language("confirmed_event_type_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
name: this.calEvent.team?.name || this.calEvent.organizer.name,
|
||||
date: this.getInviteeStart().format("LT dddd, LL"),
|
||||
}),
|
||||
html: this.getHtmlRepresentation(),
|
||||
text: this.getPlainTextRepresentation(),
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the inviteeStart value used at multiple points.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
protected getInviteeStart(): Dayjs {
|
||||
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import EventAttendeeMail from "./EventAttendeeMail";
|
||||
|
||||
export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getHtmlRepresentation(): string {
|
||||
return (
|
||||
`
|
||||
<div>
|
||||
${this.calEvent.language("hi_user_name", { userName: this.calEvent.attendees[0].name })},<br />
|
||||
<br />
|
||||
${this.calEvent.language("event_type_has_been_rescheduled_on_time_date", {
|
||||
eventType: this.calEvent.type,
|
||||
name: this.calEvent.team?.name || this.calEvent.organizer.name,
|
||||
time: this.getInviteeStart().format("h:mma"),
|
||||
timeZone: this.calEvent.attendees[0].timeZone,
|
||||
date:
|
||||
`${this.calEvent.language(this.getInviteeStart().format("dddd, ").toLowerCase())}` +
|
||||
`${this.calEvent.language(this.getInviteeStart().format("LL").toLowerCase())}`,
|
||||
})}<br />
|
||||
` +
|
||||
this.getAdditionalFooter() +
|
||||
`
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
subject: this.calEvent.language("rescheduled_event_type_with_organizer", {
|
||||
eventType: this.calEvent.type,
|
||||
organizerName: this.calEvent.organizer.name,
|
||||
date: this.getInviteeStart().format("dddd, LL"),
|
||||
}),
|
||||
html: this.getHtmlRepresentation(),
|
||||
text: this.getPlainTextRepresentation(),
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { getErrorFromUnknown } from "@lib/errors";
|
||||
|
||||
import CalEventParser from "../CalEventParser";
|
||||
import { CalendarEvent } from "../calendarClient";
|
||||
import { serverConfig } from "../serverConfig";
|
||||
import { stripHtml } from "./helpers";
|
||||
|
||||
export default abstract class EventMail {
|
||||
calEvent: CalendarEvent;
|
||||
parser: CalEventParser;
|
||||
|
||||
/**
|
||||
* An EventMail always consists of a CalendarEvent
|
||||
* that stores the data of the event (like date, title, uid etc).
|
||||
*
|
||||
* @param calEvent
|
||||
*/
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.parser = new CalEventParser(calEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected abstract getHtmlRepresentation(): string;
|
||||
|
||||
/**
|
||||
* Returns the email text in a plain text representation
|
||||
* by stripping off the HTML tags.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getPlainTextRepresentation(): string {
|
||||
return stripHtml(this.getHtmlRepresentation());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
* @protected
|
||||
*/
|
||||
protected abstract getNodeMailerPayload(): Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Sends the email to the event attendant and returns a Promise.
|
||||
*/
|
||||
public sendEmail() {
|
||||
new Promise((resolve, reject) =>
|
||||
nodemailer
|
||||
.createTransport(this.getMailerOptions().transport)
|
||||
.sendMail(this.getNodeMailerPayload(), (_err, info) => {
|
||||
if (_err) {
|
||||
const err = getErrorFromUnknown(_err);
|
||||
this.printNodeMailerError(err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
})
|
||||
).catch((e) => console.error("sendEmail", e));
|
||||
return new Promise((resolve) => resolve("send mail async"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers the required provider information from the config.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getMailerOptions() {
|
||||
return {
|
||||
transport: serverConfig.transport,
|
||||
from: serverConfig.from,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Can be used to include additional HTML or plain text
|
||||
* content into the mail body. Leave it to an empty
|
||||
* string if not desired.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getAdditionalBody(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
protected abstract getLocation(): string;
|
||||
|
||||
/**
|
||||
* Prints out the desired information when an error
|
||||
* occured while sending the mail.
|
||||
* @param error
|
||||
* @protected
|
||||
*/
|
||||
protected abstract printNodeMailerError(error: Error): void;
|
||||
|
||||
/**
|
||||
* Returns a link to reschedule the given booking.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getRescheduleLink(): string {
|
||||
return this.parser.getRescheduleLink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a link to cancel the given booking.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getCancelLink(): string {
|
||||
return this.parser.getCancelLink();
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createEvent } from "ics";
|
||||
|
||||
import { Person } from "@lib/calendarClient";
|
||||
|
||||
import EventMail from "./EventMail";
|
||||
import { stripHtml } from "./helpers";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(toArray);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventOrganizerMail extends EventMail {
|
||||
/**
|
||||
* Returns the instance's event as an iCal event in string representation.
|
||||
* @protected
|
||||
*/
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
const icsEvent = createEvent({
|
||||
start: dayjs(this.calEvent.startTime)
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v)),
|
||||
startInputType: "utc",
|
||||
productId: "calendso/ics",
|
||||
title: this.calEvent.language("organizer_ics_event_title", {
|
||||
eventType: this.calEvent.type,
|
||||
attendeeName: this.calEvent.attendees[0].name,
|
||||
}),
|
||||
description:
|
||||
this.calEvent.description +
|
||||
stripHtml(this.getAdditionalBody()) +
|
||||
stripHtml(this.getAdditionalFooter()),
|
||||
duration: { minutes: dayjs(this.calEvent.endTime).diff(dayjs(this.calEvent.startTime), "minute") },
|
||||
organizer: { name: this.calEvent.organizer.name, email: this.calEvent.organizer.email },
|
||||
attendees: this.calEvent.attendees.map((attendee: Person) => ({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
})),
|
||||
status: "CONFIRMED",
|
||||
});
|
||||
if (icsEvent.error) {
|
||||
throw icsEvent.error;
|
||||
}
|
||||
return icsEvent.value;
|
||||
}
|
||||
|
||||
protected getBodyHeader(): string {
|
||||
return this.calEvent.language("new_event_scheduled");
|
||||
}
|
||||
|
||||
protected getAdditionalFooter(): string {
|
||||
return `<p style="color: #4b5563; margin-top: 20px;">${this.calEvent.language(
|
||||
"need_to_make_a_change"
|
||||
)} <a href=${process.env.BASE_URL + "/bookings"} style="color: #161e2e;">${this.calEvent.language(
|
||||
"manage_my_bookings"
|
||||
)}</a></p>`;
|
||||
}
|
||||
|
||||
protected getImage(): string {
|
||||
return `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="height: 60px; width: 60px; color: #31c48d"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getHtmlRepresentation(): string {
|
||||
return (
|
||||
`
|
||||
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
|
||||
<div
|
||||
style="
|
||||
margin: 0 auto;
|
||||
max-width: 450px;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 2rem 2rem 2rem 2rem;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
"
|
||||
>
|
||||
${this.getImage()}
|
||||
<h1 style="font-weight: 500; color: #161e2e;">${this.getBodyHeader()}</h1>
|
||||
<hr />
|
||||
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
||||
<colgroup>
|
||||
<col span="1" style="width: 40%;">
|
||||
<col span="1" style="width: 60%;">
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("what")}</td>
|
||||
<td>${this.calEvent.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("when")}</td>
|
||||
<td>${this.getOrganizerStart().format("dddd, LL")}<br>${this.getOrganizerStart().format("h:mma")} (${
|
||||
this.calEvent.organizer.timeZone
|
||||
})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("who")}</td>
|
||||
<td>${this.calEvent.attendees[0].name}<br /><small><a href="mailto:${
|
||||
this.calEvent.attendees[0].email
|
||||
}">${this.calEvent.attendees[0].email}</a></small></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("where")}</td>
|
||||
<td>${this.getLocation()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("notes")}</td>
|
||||
<td>${this.calEvent.description}</td>
|
||||
</tr>
|
||||
</table>
|
||||
` +
|
||||
this.getAdditionalBody() +
|
||||
"<br />" +
|
||||
`
|
||||
<hr />
|
||||
` +
|
||||
this.getAdditionalFooter() +
|
||||
`
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
|
||||
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
|
||||
</body>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the video call information to the mail body.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getLocation(): string {
|
||||
if (this.calEvent.additionInformation?.hangoutLink) {
|
||||
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
|
||||
}
|
||||
|
||||
if (
|
||||
this.calEvent.additionInformation?.entryPoints &&
|
||||
this.calEvent.additionInformation?.entryPoints.length > 0
|
||||
) {
|
||||
const locations = this.calEvent.additionInformation?.entryPoints
|
||||
.map((entryPoint) => {
|
||||
return `
|
||||
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
|
||||
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||
`;
|
||||
})
|
||||
.join("<br />");
|
||||
|
||||
return `${locations}`;
|
||||
}
|
||||
|
||||
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
|
||||
}
|
||||
|
||||
protected getAdditionalBody(): string {
|
||||
return ``;
|
||||
}
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
const toAddresses = [this.calEvent.organizer.email];
|
||||
if (this.calEvent.team) {
|
||||
this.calEvent.team.members.forEach((member) => {
|
||||
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
|
||||
if (memberAttendee) {
|
||||
toAddresses.push(memberAttendee.email);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
},
|
||||
from: `Cal.com <${this.getMailerOptions().from}>`,
|
||||
to: toAddresses.join(","),
|
||||
subject: this.getSubject(),
|
||||
html: this.getHtmlRepresentation(),
|
||||
text: this.getPlainTextRepresentation(),
|
||||
};
|
||||
}
|
||||
|
||||
protected getSubject(): string {
|
||||
return this.calEvent.language("new_event_subject", {
|
||||
attendeeName: this.calEvent.attendees[0].name,
|
||||
date: this.getOrganizerStart().format("LT dddd, LL"),
|
||||
eventType: this.calEvent.type,
|
||||
});
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the organizerStart value used at multiple points.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
protected getOrganizerStart(): Dayjs {
|
||||
return dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { CalendarEvent } from "@lib/calendarClient";
|
||||
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(toArray);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventOrganizerRefundFailedMail extends EventOrganizerMail {
|
||||
reason: string;
|
||||
paymentId: string;
|
||||
|
||||
constructor(calEvent: CalendarEvent, reason: string, paymentId: string) {
|
||||
super(calEvent);
|
||||
this.reason = reason;
|
||||
this.paymentId = paymentId;
|
||||
}
|
||||
|
||||
protected getBodyHeader(): string {
|
||||
return this.calEvent.language("a_refund_failed");
|
||||
}
|
||||
|
||||
protected getBodyText(): string {
|
||||
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||
return `${this.calEvent.language("refund_failed", {
|
||||
eventType: this.calEvent.type,
|
||||
userName: this.calEvent.attendees[0].name,
|
||||
date: organizerStart.format("LT dddd, LL"),
|
||||
})} ${this.calEvent.language("check_with_provider_and_user", {
|
||||
userName: this.calEvent.attendees[0].name,
|
||||
})}<br>${this.calEvent.language("error_message", { errorMessage: this.reason })}<br>PaymentId: '${
|
||||
this.paymentId
|
||||
}'`;
|
||||
}
|
||||
|
||||
protected getAdditionalBody(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
protected getImage(): string {
|
||||
return `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="height: 60px; width: 60px; color: #9b0125"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
protected getSubject(): string {
|
||||
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||
return this.calEvent.language("refund_failed_subject", {
|
||||
userName: this.calEvent.attendees[0].name,
|
||||
date: organizerStart.format("LT dddd, LL"),
|
||||
eventType: this.calEvent.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(toArray);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventOrganizerRequestMail extends EventOrganizerMail {
|
||||
protected getBodyHeader(): string {
|
||||
return this.calEvent.language("event_awaiting_approval");
|
||||
}
|
||||
|
||||
protected getBodyText(): string {
|
||||
return this.calEvent.language("check_bookings_page_to_confirm_or_reject");
|
||||
}
|
||||
|
||||
protected getAdditionalBody(): string {
|
||||
return `<a href="${process.env.BASE_URL}/bookings">${this.calEvent.language(
|
||||
"confirm_or_reject_booking"
|
||||
)}</a>`;
|
||||
}
|
||||
|
||||
protected getImage(): string {
|
||||
return `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="height: 60px; width: 60px; color: #01579b"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
protected getSubject(): string {
|
||||
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||
return this.calEvent.language("new_event_request", {
|
||||
attendeeName: this.calEvent.attendees[0].name,
|
||||
date: organizerStart.format("LT dddd, LL"),
|
||||
eventType: this.calEvent.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import toArray from "dayjs/plugin/toArray";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(toArray);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventOrganizerRequestReminderMail extends EventOrganizerRequestMail {
|
||||
protected getBodyHeader(): string {
|
||||
return this.calEvent.language("still_waiting_for_approval");
|
||||
}
|
||||
|
||||
protected getSubject(): string {
|
||||
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||
return this.calEvent.language("event_is_still_waiting", {
|
||||
attendeeName: this.calEvent.attendees[0].name,
|
||||
date: organizerStart.format("LT dddd, LL"),
|
||||
eventType: this.calEvent.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
|
||||
import EventOrganizerMail from "./EventOrganizerMail";
|
||||
|
||||
export default class EventOrganizerRescheduledMail extends EventOrganizerMail {
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getHtmlRepresentation(): string {
|
||||
return (
|
||||
`
|
||||
<div>
|
||||
${this.calEvent.language("hi_user_name", { userName: this.calEvent.organizer.name })},<br />
|
||||
<br />
|
||||
${this.calEvent.language("event_has_been_rescheduled")}<br />
|
||||
<br />
|
||||
<strong>${this.calEvent.language("event_type")}:</strong><br />
|
||||
${this.calEvent.type}<br />
|
||||
<br />
|
||||
<strong>${this.calEvent.language("invitee_email")}:</strong><br />
|
||||
<a href="mailto:${this.calEvent.attendees[0].email}">${this.calEvent.attendees[0].email}</a><br />
|
||||
<br />` +
|
||||
this.getAdditionalBody() +
|
||||
(this.calEvent.location
|
||||
? `
|
||||
<strong>${this.calEvent.language("location")}:</strong><br />
|
||||
${this.calEvent.location}<br />
|
||||
<br />
|
||||
`
|
||||
: "") +
|
||||
`<strong>${this.calEvent.language("invitee_timezone")}:</strong><br />
|
||||
${this.calEvent.attendees[0].timeZone}<br />
|
||||
<br />
|
||||
<strong>${this.calEvent.language("additional_notes")}:</strong><br />
|
||||
${this.calEvent.description}
|
||||
` +
|
||||
this.getAdditionalFooter() +
|
||||
`
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
const organizerStart: Dayjs = dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||
|
||||
return {
|
||||
icalEvent: {
|
||||
filename: "event.ics",
|
||||
content: this.getiCalEventAsString(),
|
||||
},
|
||||
from: `Cal.com <${this.getMailerOptions().from}>`,
|
||||
to: this.calEvent.organizer.email,
|
||||
subject: this.calEvent.language("rescheduled_event_type_with_attendee", {
|
||||
attendeeName: this.calEvent.attendees[0].name,
|
||||
date: organizerStart.format("LT dddd, LL"),
|
||||
eventType: this.calEvent.type,
|
||||
}),
|
||||
html: this.getHtmlRepresentation(),
|
||||
text: this.getPlainTextRepresentation(),
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import { CalendarEvent } from "@lib/calendarClient";
|
||||
|
||||
import EventMail from "./EventMail";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventPaymentMail extends EventMail {
|
||||
paymentLink: string;
|
||||
|
||||
constructor(paymentLink: string, calEvent: CalendarEvent) {
|
||||
super(calEvent);
|
||||
this.paymentLink = paymentLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getHtmlRepresentation(): string {
|
||||
return (
|
||||
`
|
||||
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
|
||||
<div
|
||||
style="
|
||||
margin: 0 auto;
|
||||
max-width: 450px;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 2rem 2rem 2rem 2rem;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="height: 60px; width: 60px; color: #31c48d"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_awaiting_payment")}</h1>
|
||||
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language(
|
||||
"emailed_you_and_any_other_attendees"
|
||||
)}</p>
|
||||
<hr />
|
||||
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
||||
<colgroup>
|
||||
<col span="1" style="width: 40%;">
|
||||
<col span="1" style="width: 60%;">
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("what")}</td>
|
||||
<td>${this.calEvent.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("when")}</td>
|
||||
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
|
||||
this.calEvent.attendees[0].timeZone
|
||||
})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("who")}</td>
|
||||
<td>${this.calEvent.organizer.name}<br /><small>${this.calEvent.organizer.email}</small></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("where")}</td>
|
||||
<td>${this.getLocation()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>${this.calEvent.language("notes")}Notes</td>
|
||||
<td>${this.calEvent.description}</td>
|
||||
</tr>
|
||||
</table>
|
||||
` +
|
||||
this.getAdditionalBody() +
|
||||
"<br />" +
|
||||
`
|
||||
<hr />
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
|
||||
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
|
||||
</body>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the video call information to the mail body.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getLocation(): string {
|
||||
if (this.calEvent.additionInformation?.hangoutLink) {
|
||||
return `<a href="${this.calEvent.additionInformation?.hangoutLink}">${this.calEvent.additionInformation?.hangoutLink}</a><br />`;
|
||||
}
|
||||
|
||||
if (
|
||||
this.calEvent.additionInformation?.entryPoints &&
|
||||
this.calEvent.additionInformation?.entryPoints.length > 0
|
||||
) {
|
||||
const locations = this.calEvent.additionInformation?.entryPoints
|
||||
.map((entryPoint) => {
|
||||
return `
|
||||
${this.calEvent.language("join_by_entrypoint", { entryPoint: entryPoint.entryPointType })}: <br />
|
||||
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||
`;
|
||||
})
|
||||
.join("<br />");
|
||||
|
||||
return `${locations}`;
|
||||
}
|
||||
|
||||
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
|
||||
}
|
||||
|
||||
protected getAdditionalBody(): string {
|
||||
return `<a href="${this.paymentLink}">${this.calEvent.language("pay_now")}</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
subject: this.calEvent.language("awaiting_payment", {
|
||||
eventType: this.calEvent.type,
|
||||
organizerName: this.calEvent.organizer.name,
|
||||
date: this.getInviteeStart().format("dddd, LL"),
|
||||
}),
|
||||
html: this.getHtmlRepresentation(),
|
||||
text: this.getPlainTextRepresentation(),
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the inviteeStart value used at multiple points.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
protected getInviteeStart(): Dayjs {
|
||||
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import EventMail from "./EventMail";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export default class EventRejectionMail extends EventMail {
|
||||
/**
|
||||
* Returns the email text as HTML representation.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getHtmlRepresentation(): string {
|
||||
return (
|
||||
`
|
||||
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
|
||||
<div
|
||||
style="
|
||||
margin: 0 auto;
|
||||
max-width: 450px;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 2rem 2rem 2rem 2rem;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style="height: 60px; width: 60px; color: #ba2525"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 style="font-weight: 500; color: #161e2e;">${this.calEvent.language("meeting_request_rejected")}</h1>
|
||||
<p style="color: #4b5563; margin-bottom: 30px;">${this.calEvent.language("emailed_you_and_attendees")}</p>
|
||||
<hr />
|
||||
` +
|
||||
`
|
||||
</div>
|
||||
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
|
||||
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
|
||||
</body>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload object for the nodemailer.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||
return {
|
||||
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||
replyTo: this.calEvent.organizer.email,
|
||||
subject: this.calEvent.language("rejected_event_type_with_organizer", {
|
||||
eventType: this.calEvent.type,
|
||||
organizer: this.calEvent.organizer.name,
|
||||
date: this.getInviteeStart().format("dddd, LL"),
|
||||
}),
|
||||
html: this.getHtmlRepresentation(),
|
||||
text: this.getPlainTextRepresentation(),
|
||||
};
|
||||
}
|
||||
|
||||
protected printNodeMailerError(error: Error): void {
|
||||
console.error("SEND_BOOKING_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the inviteeStart value used at multiple points.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getInviteeStart(): Dayjs {
|
||||
return dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the video call information to the mail body.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getLocation(): string {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import EventAttendeeMail from "./EventAttendeeMail";
|
||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||
|
||||
export default class VideoEventAttendeeMail extends EventAttendeeMail {
|
||||
/**
|
||||
* Adds the video call information to the mail body.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getAdditionalBody(): string {
|
||||
if (!this.calEvent.videoCallData) {
|
||||
return "";
|
||||
}
|
||||
const meetingPassword = this.calEvent.videoCallData.password;
|
||||
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
|
||||
|
||||
if (meetingId && meetingPassword) {
|
||||
return `
|
||||
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||
this.calEvent.videoCallData
|
||||
)}<br />
|
||||
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
|
||||
this.calEvent.videoCallData
|
||||
)}<br />
|
||||
<strong>${this.calEvent.language("meeting_password")}:</strong> ${
|
||||
this.calEvent.videoCallData.password
|
||||
}<br />
|
||||
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
|
||||
this.calEvent.videoCallData.url
|
||||
}">${this.calEvent.videoCallData.url}</a><br />
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||
this.calEvent.videoCallData
|
||||
)}<br />
|
||||
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${
|
||||
this.calEvent.videoCallData.url
|
||||
}">${this.calEvent.videoCallData.url}</a><br />
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import EventOrganizerMail from "./EventOrganizerMail";
|
||||
import { getFormattedMeetingId, getIntegrationName } from "./helpers";
|
||||
|
||||
export default class VideoEventOrganizerMail extends EventOrganizerMail {
|
||||
/**
|
||||
* Adds the video call information to the mail body
|
||||
* and calendar event description.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected getAdditionalBody(): string {
|
||||
if (!this.calEvent.videoCallData) {
|
||||
return "";
|
||||
}
|
||||
const meetingPassword = this.calEvent.videoCallData.password;
|
||||
const meetingId = getFormattedMeetingId(this.calEvent.videoCallData);
|
||||
// This odd indentation is necessary because otherwise the leading tabs will be applied into the event description.
|
||||
if (meetingPassword && meetingId) {
|
||||
return `
|
||||
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||
this.calEvent.videoCallData
|
||||
)}<br />
|
||||
<strong>${this.calEvent.language("meeting_id")}:</strong> ${getFormattedMeetingId(
|
||||
this.calEvent.videoCallData
|
||||
)}<br />
|
||||
<strong>${this.calEvent.language("meeting_password")}:</strong> ${this.calEvent.videoCallData.password}<br />
|
||||
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
|
||||
this.calEvent.videoCallData.url
|
||||
}</a><br />
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<strong>${this.calEvent.language("video_call_provider")}:</strong> ${getIntegrationName(
|
||||
this.calEvent.videoCallData
|
||||
)}<br />
|
||||
<strong>${this.calEvent.language("meeting_url")}:</strong> <a href="${this.calEvent.videoCallData.url}">${
|
||||
this.calEvent.videoCallData.url
|
||||
}</a><br />
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import Handlebars from "handlebars";
|
||||
import { TFunction } from "next-i18next";
|
||||
|
||||
export type VarType = {
|
||||
language: TFunction;
|
||||
user: {
|
||||
name: string | null;
|
||||
};
|
||||
link: string;
|
||||
};
|
||||
|
||||
export type MessageTemplateTypes = {
|
||||
messageTemplate: string;
|
||||
subjectTemplate: string;
|
||||
vars: VarType;
|
||||
};
|
||||
|
||||
export type BuildTemplateResult = {
|
||||
subject: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const buildMessageTemplate = ({
|
||||
messageTemplate,
|
||||
subjectTemplate,
|
||||
vars,
|
||||
}: MessageTemplateTypes): BuildTemplateResult => {
|
||||
const buildMessage = Handlebars.compile(messageTemplate);
|
||||
const message = buildMessage(vars);
|
||||
|
||||
const buildSubject = Handlebars.compile(subjectTemplate);
|
||||
const subject = buildSubject(vars);
|
||||
|
||||
return {
|
||||
subject,
|
||||
message,
|
||||
};
|
||||
};
|
||||
|
||||
export default buildMessageTemplate;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user