Merge branch 'main' into feature/scheduling

pull/293/head
Alex van Andel 2021-06-21 20:37:19 +00:00
commit b2812deeff
49 changed files with 2836 additions and 4630 deletions

View File

@ -9,6 +9,10 @@ NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r
MS_GRAPH_CLIENT_ID= MS_GRAPH_CLIENT_ID=
MS_GRAPH_CLIENT_SECRET= MS_GRAPH_CLIENT_SECRET=
# Used for the Zoom integration
ZOOM_CLIENT_ID=
ZOOM_CLIENT_SECRET=
# E-mail settings # E-mail settings
# Calendso uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to # Calendso uses nodemailer (@see https://nodemailer.com/about/) to provide email sending. As such we are trying to

32
.eslintrc.json Normal file
View File

@ -0,0 +1,32 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"modules": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"plugins": ["@typescript-eslint", "prettier", "react", "react-hooks"],
"rules": {
"prettier/prettier": ["error"],
"@typescript-eslint/no-unused-vars": "error"
},
"env": {
"browser": true,
"node": true,
"es6": true
},
"settings": {
"react": {
"version": "detect"
}
}
}

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
.next
public
**/**/node_modules
**/**/.next
**/**/public

10
.prettierrc.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
bracketSpacing: true,
jsxBracketSameLine: true,
singleQuote: false,
jsxSingleQuote: false,
trailingComma: "es5",
semi: true,
printWidth: 110,
arrowParens: "always",
};

View File

@ -196,6 +196,30 @@ Contributions are what make the open source community such an amazing place to b
5. Use **Application (client) ID** as the **MS_GRAPH_CLIENT_ID** attribute value in .env 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** attriubte
## Obtaining Zoom Client ID and Secret
1. Open [Zoom Marketplace](https://marketplace.zoom.us/) and sign in with your Zoom account.
2. On the upper right, click "Develop" => "Build App".
3. On "OAuth", select "Create".
4. Name your App.
5. Choose "Account-level app" as the app type.
6. De-select the option to publish the app on the Zoom App Marketplace.
7. Click "Create".
8. Now copy the Client ID and Client Secret to your .env file into the `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` fields.
4. Set the Redirect URL for OAuth `<CALENDSO URL>/api/integrations/zoomvideo/callback` replacing CALENDSO URL with the URI at which your application runs.
5. Also add the redirect URL given above as a whitelist URL and enable "Subdomain check". Make sure, it says "saved" below the form.
7. You don't need to provide basic information about your app. Instead click at "Scopes" and then at "+ Add Scopes". Search for and check the following scopes:
1. account:master
2. account:read:admin
3. account:write:admin
4. meeting:master
5. meeting:read:admin
6. meeting:write:admin
7. user:master
8. user:read:admin
9. user:write:admin
8. Click "Done".
9. You're good to go. Now you can easily add your Zoom integration in the Calendso settings.
<!-- LICENSE --> <!-- LICENSE -->
## License ## License

View File

@ -1,6 +1,6 @@
import { useRouter } from 'next/router' import {useRouter} from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import React, { Children } from 'react' import React, {Children} from 'react'
const ActiveLink = ({ children, activeClassName, ...props }) => { const ActiveLink = ({ children, activeClassName, ...props }) => {
const { asPath } = useRouter() const { asPath } = useRouter()

View File

@ -16,7 +16,7 @@ export default function Avatar({ user, className = '', fallback }: {
return ( return (
<img <img
onError={() => setGravatarAvailable(false)} onError={() => setGravatarAvailable(false)}
src={`https://www.gravatar.com/avatar/${md5(user.email)}?d=404&s=160`} src={`https://www.gravatar.com/avatar/${md5(user.email)}?s=160&d=identicon&r=PG`}
alt="Avatar" alt="Avatar"
className={className} className={className}
/> />

View File

@ -1,5 +1,5 @@
import ActiveLink from '../components/ActiveLink'; import ActiveLink from '../components/ActiveLink';
import { UserCircleIcon, KeyIcon, CodeIcon, UserGroupIcon, CreditCardIcon } from '@heroicons/react/outline'; import {CodeIcon, CreditCardIcon, KeyIcon, UserCircleIcon, UserGroupIcon} from '@heroicons/react/outline';
export default function SettingsShell(props) { export default function SettingsShell(props) {
return ( return (

View File

@ -1,69 +1,77 @@
import EventOrganizerMail from "./emails/EventOrganizerMail";
import EventAttendeeMail from "./emails/EventAttendeeMail";
import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid';
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
const translator = short();
const {google} = require('googleapis'); const {google} = require('googleapis');
import createNewEventEmail from "./emails/new-event";
const googleAuth = () => { const googleAuth = () => {
const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web; const {client_secret, client_id, redirect_uris} = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]); return new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
}; };
function handleErrorsJson(response) { function handleErrorsJson(response) {
if (!response.ok) { if (!response.ok) {
response.json().then(console.log); response.json().then(console.log);
throw Error(response.statusText); throw Error(response.statusText);
} }
return response.json(); return response.json();
} }
function handleErrorsRaw(response) { function handleErrorsRaw(response) {
if (!response.ok) { if (!response.ok) {
response.text().then(console.log); response.text().then(console.log);
throw Error(response.statusText); throw Error(response.statusText);
} }
return response.text(); return response.text();
} }
const o365Auth = (credential) => { const o365Auth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +(new Date()); const isExpired = (expiryDate) => expiryDate < +(new Date());
const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { const refreshAccessToken = (refreshToken) => fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'}, headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({ body: new URLSearchParams({
'scope': 'User.Read Calendars.Read Calendars.ReadWrite', 'scope': 'User.Read Calendars.Read Calendars.ReadWrite',
'client_id': process.env.MS_GRAPH_CLIENT_ID, 'client_id': process.env.MS_GRAPH_CLIENT_ID,
'refresh_token': refreshToken, 'refresh_token': refreshToken,
'grant_type': 'refresh_token', 'grant_type': 'refresh_token',
'client_secret': process.env.MS_GRAPH_CLIENT_SECRET, 'client_secret': process.env.MS_GRAPH_CLIENT_SECRET,
}) })
})
.then(handleErrorsJson)
.then((responseBody) => {
credential.key.access_token = responseBody.access_token;
credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
}) })
.then(handleErrorsJson)
.then((responseBody) => {
credential.key.access_token = responseBody.access_token;
credential.key.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
})
return { return {
getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token) getToken: () => !isExpired(credential.key.expiry_date) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
}; };
}; };
interface Person { interface Person {
name?: string, name?: string,
email: string, email: string,
timeZone: string timeZone: string
} }
interface CalendarEvent { interface CalendarEvent {
type: string; type: string;
title: string; title: string;
startTime: string; startTime: string;
endTime: string; endTime: string;
description?: string; description?: string;
location?: string; location?: string;
organizer: Person; organizer: Person;
attendees: Person[]; attendees: Person[];
}; };
interface IntegrationCalendar { interface IntegrationCalendar {
@ -74,11 +82,11 @@ interface IntegrationCalendar {
} }
interface CalendarApiAdapter { interface CalendarApiAdapter {
createEvent(event: CalendarEvent): Promise<any>; createEvent(event: CalendarEvent): Promise<any>;
updateEvent(uid: String, event: CalendarEvent); updateEvent(uid: String, event: CalendarEvent);
deleteEvent(uid: String); deleteEvent(uid: String);
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>; getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<any>;
@ -87,39 +95,39 @@ interface CalendarApiAdapter {
const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => { const MicrosoftOffice365Calendar = (credential): CalendarApiAdapter => {
const auth = o365Auth(credential); const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => { const translateEvent = (event: CalendarEvent) => {
let optional = {}; let optional = {};
if (event.location) { if (event.location) {
optional.location = {displayName: event.location}; optional.location = {displayName: event.location};
} }
return { return {
subject: event.title, subject: event.title,
body: { body: {
contentType: 'HTML', contentType: 'HTML',
content: event.description, content: event.description,
}, },
start: { start: {
dateTime: event.startTime, dateTime: event.startTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
end: { end: {
dateTime: event.endTime, dateTime: event.endTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
attendees: event.attendees.map(attendee => ({ attendees: event.attendees.map(attendee => ({
emailAddress: { emailAddress: {
address: attendee.email, address: attendee.email,
name: attendee.name name: attendee.name
}, },
type: "required" type: "required"
})), })),
...optional ...optional
} }
}; };
const integrationType = "office365_calendar"; const integrationType = "office365_calendar";
@ -243,69 +251,69 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
reject(err); reject(err);
}); });
}), }),
createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => { createEvent: (event: CalendarEvent) => new Promise((resolve, reject) => {
const payload = { const payload = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: { start: {
dateTime: event.startTime, dateTime: event.startTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
end: { end: {
dateTime: event.endTime, dateTime: event.endTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
attendees: event.attendees, attendees: event.attendees,
reminders: { reminders: {
useDefault: false, useDefault: false,
overrides: [ overrides: [
{'method': 'email', 'minutes': 60} {'method': 'email', 'minutes': 60}
], ],
}, },
}; };
if (event.location) { if (event.location) {
payload['location'] = event.location; payload['location'] = event.location;
} }
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.events.insert({ calendar.events.insert({
auth: myGoogleAuth, auth: myGoogleAuth,
calendarId: 'primary', calendarId: 'primary',
resource: payload, resource: payload,
}, function (err, event) { }, function (err, event) {
if (err) { if (err) {
console.log('There was an error contacting the Calendar service: ' + err); console.log('There was an error contacting the Calendar service: ' + err);
return reject(err); return reject(err);
} }
return resolve(event.data); return resolve(event.data);
}); });
}), }),
updateEvent: (uid: String, event: CalendarEvent) => new Promise((resolve, reject) => { updateEvent: (uid: String, event: CalendarEvent) => new Promise((resolve, reject) => {
const payload = { const payload = {
summary: event.title, summary: event.title,
description: event.description, description: event.description,
start: { start: {
dateTime: event.startTime, dateTime: event.startTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
end: { end: {
dateTime: event.endTime, dateTime: event.endTime,
timeZone: event.organizer.timeZone, timeZone: event.organizer.timeZone,
}, },
attendees: event.attendees, attendees: event.attendees,
reminders: { reminders: {
useDefault: false, useDefault: false,
overrides: [ overrides: [
{'method': 'email', 'minutes': 60} {'method': 'email', 'minutes': 60}
], ],
}, },
}; };
if (event.location) { if (event.location) {
payload['location'] = event.location; payload['location'] = event.location;
} }
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth}); const calendar = google.calendar({version: 'v3', auth: myGoogleAuth});
calendar.events.update({ calendar.events.update({
@ -360,17 +368,17 @@ const GoogleCalendar = (credential): CalendarApiAdapter => {
// factory // factory
const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map((cred) => { const calendars = (withCredentials): CalendarApiAdapter[] => withCredentials.map((cred) => {
switch (cred.type) { switch (cred.type) {
case 'google_calendar': case 'google_calendar':
return GoogleCalendar(cred); return GoogleCalendar(cred);
case 'office365_calendar': case 'office365_calendar':
return MicrosoftOffice365Calendar(cred); return MicrosoftOffice365Calendar(cred);
default: default:
return; // unknown credential, could be legacy? In any case, ignore return; // unknown credential, could be legacy? In any case, ignore
} }
}).filter(Boolean); }).filter(Boolean);
const getBusyTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all( const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalendars) => Promise.all(
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars)) calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo, selectedCalendars))
).then( ).then(
(results) => { (results) => {
@ -384,33 +392,50 @@ const listCalendars = (withCredentials) => Promise.all(
(results) => results.reduce((acc, calendars) => acc.concat(calendars), []) (results) => results.reduce((acc, calendars) => acc.concat(calendars), [])
); );
const createEvent = (credential, calEvent: CalendarEvent): Promise<any> => { const createEvent = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
createNewEventEmail( const creationResult = credential ? await calendars([credential])[0].createEvent(calEvent) : null;
calEvent,
);
if (credential) { const organizerMail = new EventOrganizerMail(calEvent, uid);
return calendars([credential])[0].createEvent(calEvent); const attendeeMail = new EventAttendeeMail(calEvent, uid);
} await organizerMail.sendEmail();
return Promise.resolve({}); if (!creationResult || !creationResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid,
createdEvent: creationResult
};
}; };
const updateEvent = (credential, uid: String, calEvent: CalendarEvent): Promise<any> => { const updateEvent = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
if (credential) { const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
return calendars([credential])[0].updateEvent(uid, calEvent);
}
return Promise.resolve({}); const updateResult = credential ? await calendars([credential])[0].updateEvent(uidToUpdate, calEvent) : null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
await organizerMail.sendEmail();
if (!updateResult || !updateResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid: newUid,
updatedEvent: updateResult
};
}; };
const deleteEvent = (credential, uid: String): Promise<any> => { const deleteEvent = (credential, uid: String): Promise<any> => {
if (credential) { if (credential) {
return calendars([credential])[0].deleteEvent(uid); return calendars([credential])[0].deleteEvent(uid);
} }
return Promise.resolve({}); return Promise.resolve({});
}; };
export {getBusyTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar}; export {getBusyCalendarTimes, createEvent, updateEvent, deleteEvent, CalendarEvent, listCalendars, IntegrationCalendar};

View File

@ -0,0 +1,55 @@
import dayjs, {Dayjs} from "dayjs";
import EventMail from "./EventMail";
export default class EventAttendeeMail extends EventMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return `
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} at ${this.getInviteeStart().format('h:mma')}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')} is scheduled.<br />
<br />` + this.getAdditionalBody() + (
this.calEvent.location ? `<strong>Location:</strong> ${this.calEvent.location}<br /><br />` : ''
) +
`<strong>Additional notes:</strong><br />
${this.calEvent.description}<br />
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
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: `Confirmed: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): 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>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
}
}

View File

@ -0,0 +1,40 @@
import EventAttendeeMail from "./EventAttendeeMail";
export default class EventAttendeeRescheduledMail extends EventAttendeeMail {
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return `
<div>
Hi ${this.calEvent.attendees[0].name},<br />
<br />
Your ${this.calEvent.type} with ${this.calEvent.organizer.name} has been rescheduled to ${this.getInviteeStart().format('h:mma')}
(${this.calEvent.attendees[0].timeZone}) on ${this.getInviteeStart().format('dddd, LL')}.<br />
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
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: `Rescheduled: ${this.calEvent.type} with ${this.calEvent.organizer.name} on ${this.getInviteeStart().format('dddd, LL')}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): void {
console.error("SEND_RESCHEDULE_CONFIRMATION_ERROR", this.calEvent.attendees[0].email, error);
}
}

135
lib/emails/EventMail.ts Normal file
View File

@ -0,0 +1,135 @@
import {CalendarEvent} from "../calendarClient";
import {serverConfig} from "../serverConfig";
import nodemailer from 'nodemailer';
export default abstract class EventMail {
calEvent: CalendarEvent;
uid: string;
/**
* An EventMail always consists of a CalendarEvent
* that stores the very basic data of the event (like date, title etc).
* It also needs the UID of the stored booking in our database.
*
* @param calEvent
* @param uid
*/
constructor(calEvent: CalendarEvent, uid: string) {
this.calEvent = calEvent;
this.uid = uid;
}
/**
* 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 this.stripHtml(this.getHtmlRepresentation());
}
/**
* Strips off all HTML tags and leaves plain text.
*
* @param html
* @protected
*/
protected stripHtml(html: string): string {
return html
.replace('<br />', "\n")
.replace(/<[^>]+>/g, '');
}
/**
* Returns the payload object for the nodemailer.
* @protected
*/
protected abstract getNodeMailerPayload(): Object;
/**
* Sends the email to the event attendant and returns a Promise.
*/
public sendEmail(): Promise<any> {
return new Promise((resolve, reject) => nodemailer.createTransport(this.getMailerOptions().transport).sendMail(
this.getNodeMailerPayload(),
(error, info) => {
if (error) {
this.printNodeMailerError(error);
reject(new Error(error));
} else {
resolve(info);
}
}));
}
/**
* Gathers the required provider information from the config.
*
* @protected
*/
protected getMailerOptions(): any {
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 "";
}
/**
* Prints out the desired information when an error
* occured while sending the mail.
* @param error
* @protected
*/
protected abstract printNodeMailerError(error: string): void;
/**
* Returns a link to reschedule the given booking.
*
* @protected
*/
protected getRescheduleLink(): string {
return process.env.BASE_URL + '/reschedule/' + this.uid;
}
/**
* Returns a link to cancel the given booking.
*
* @protected
*/
protected getCancelLink(): string {
return process.env.BASE_URL + '/cancel/' + this.uid;
}
/**
* Defines a footer that will be appended to the email.
* @protected
*/
protected getAdditionalFooter(): string {
return `
<br/>
Need to change this event?<br />
Cancel: <a href="${this.getCancelLink()}">${this.getCancelLink()}</a><br />
Reschedule: <a href="${this.getRescheduleLink()}">${this.getRescheduleLink()}</a>
`;
}
}

View File

@ -0,0 +1,87 @@
import {createEvent} from "ics";
import dayjs, {Dayjs} from "dayjs";
import EventMail from "./EventMail";
export default class EventOrganizerMail extends EventMail {
/**
* Returns the instance's event as an iCal event in string representation.
* @protected
*/
protected getiCalEventAsString(): string {
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.type} with ${this.calEvent.attendees[0].name}`,
description: this.calEvent.description + this.stripHtml(this.getAdditionalBody()) + this.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: any) => ({ name: attendee.name, email: attendee.email }) ),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
/**
* Returns the email text as HTML representation.
*
* @protected
*/
protected getHtmlRepresentation(): string {
return `
<div>
Hi ${this.calEvent.organizer.name},<br />
<br />
A new event has been scheduled.<br />
<br />
<strong>Event Type:</strong><br />
${this.calEvent.type}<br />
<br />
<strong>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>Location:</strong><br />
${this.calEvent.location}<br />
<br />
` : ''
) +
`<strong>Invitee Time Zone:</strong><br />
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${this.calEvent.description}
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: 'event.ics',
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: `New event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): void {
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
}

View File

@ -0,0 +1,64 @@
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>
Hi ${this.calEvent.organizer.name},<br />
<br />
Your event has been rescheduled.<br />
<br />
<strong>Event Type:</strong><br />
${this.calEvent.type}<br />
<br />
<strong>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>Location:</strong><br />
${this.calEvent.location}<br />
<br />
` : ''
) +
`<strong>Invitee Time Zone:</strong><br />
${this.calEvent.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${this.calEvent.description}
` + this.getAdditionalFooter() + `
</div>
`;
}
/**
* Returns the payload object for the nodemailer.
*
* @protected
*/
protected getNodeMailerPayload(): Object {
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
return {
icalEvent: {
filename: 'event.ics',
content: this.getiCalEventAsString(),
},
from: `Calendso <${this.getMailerOptions().from}>`,
to: this.calEvent.organizer.email,
subject: `Rescheduled event: ${this.calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${this.calEvent.type}`,
html: this.getHtmlRepresentation(),
text: this.getPlainTextRepresentation(),
};
}
protected printNodeMailerError(error: string): void {
console.error("SEND_RESCHEDULE_EVENT_NOTIFICATION_ERROR", this.calEvent.organizer.email, error);
}
}

View File

@ -0,0 +1,27 @@
import {CalendarEvent} from "../calendarClient";
import EventAttendeeMail from "./EventAttendeeMail";
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
import {VideoCallData} from "../videoClient";
export default class VideoEventAttendeeMail extends EventAttendeeMail {
videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
super(calEvent, uid);
this.videoCallData = videoCallData;
}
/**
* Adds the video call information to the mail body.
*
* @protected
*/
protected getAdditionalBody(): string {
return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
`;
}
}

View File

@ -0,0 +1,28 @@
import {CalendarEvent} from "../calendarClient";
import EventOrganizerMail from "./EventOrganizerMail";
import {VideoCallData} from "../videoClient";
import {getFormattedMeetingId, getIntegrationName} from "./helpers";
export default class VideoEventOrganizerMail extends EventOrganizerMail {
videoCallData: VideoCallData;
constructor(calEvent: CalendarEvent, uid: string, videoCallData: VideoCallData) {
super(calEvent, uid);
this.videoCallData = videoCallData;
}
/**
* Adds the video call information to the mail body
* and calendar event description.
*
* @protected
*/
protected getAdditionalBody(): string {
return `
<strong>Video call provider:</strong> ${getIntegrationName(this.videoCallData)}<br />
<strong>Meeting ID:</strong> ${getFormattedMeetingId(this.videoCallData)}<br />
<strong>Meeting Password:</strong> ${this.videoCallData.password}<br />
<strong>Meeting URL:</strong> <a href="${this.videoCallData.url}">${this.videoCallData.url}</a><br />
`;
}
}

View File

@ -1,70 +0,0 @@
import nodemailer from 'nodemailer';
import {serverConfig} from "../serverConfig";
import {CalendarEvent} from "../calendarClient";
import dayjs, {Dayjs} from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(localizedFormat);
dayjs.extend(utc);
dayjs.extend(timezone);
export default function createConfirmBookedEmail(calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, options: any = {}) {
return sendEmail(calEvent, cancelLink, rescheduleLink, {
provider: {
transport: serverConfig.transport,
from: serverConfig.from,
},
...options
});
}
const sendEmail = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string, {
provider,
}) => new Promise( (resolve, reject) => {
const { from, transport } = provider;
const inviteeStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
nodemailer.createTransport(transport).sendMail(
{
to: `${calEvent.attendees[0].name} <${calEvent.attendees[0].email}>`,
from: `${calEvent.organizer.name} <${from}>`,
replyTo: calEvent.organizer.email,
subject: `Confirmed: ${calEvent.type} with ${calEvent.organizer.name} on ${inviteeStart.format('dddd, LL')}`,
html: html(calEvent, cancelLink, rescheduleLink),
text: text(calEvent, cancelLink, rescheduleLink),
},
(error, info) => {
if (error) {
console.error("SEND_BOOKING_CONFIRMATION_ERROR", calEvent.attendees[0].email, error);
return reject(new Error(error));
}
return resolve();
}
)
});
const html = (calEvent: CalendarEvent, cancelLink: string, rescheduleLink: string) => {
const inviteeStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.attendees[0].timeZone);
return `
<div>
Hi ${calEvent.attendees[0].name},<br />
<br />
Your ${calEvent.type} with ${calEvent.organizer.name} at ${inviteeStart.format('h:mma')}
(${calEvent.attendees[0].timeZone}) on ${inviteeStart.format('dddd, LL')} is scheduled.<br />
<br />` + (
calEvent.location ? `<strong>Location:</strong> ${calEvent.location}<br /><br />` : ''
) +
`Additional notes:<br />
${calEvent.description}<br />
<br />
Need to change this event?<br />
Cancel: <a href="${cancelLink}">${cancelLink}</a><br />
Reschedule: <a href="${rescheduleLink}">${rescheduleLink}</a>
</div>
`;
};
const text = (evt: CalendarEvent, cancelLink: string, rescheduleLink: string) => html(evt, cancelLink, rescheduleLink).replace('<br />', "\n").replace(/<[^>]+>/g, '');

20
lib/emails/helpers.ts Normal file
View File

@ -0,0 +1,20 @@
import {VideoCallData} from "../videoClient";
export function getIntegrationName(videoCallData: VideoCallData): string {
//TODO: When there are more complex integration type strings, we should consider using an extra field in the DB for that.
const nameProto = videoCallData.type.split("_")[0];
return nameProto.charAt(0).toUpperCase() + nameProto.slice(1);
}
export function getFormattedMeetingId(videoCallData: VideoCallData): string {
switch(videoCallData.type) {
case 'zoom_video':
const strId = videoCallData.id.toString();
const part1 = strId.slice(0, 3);
const part2 = strId.slice(3, 7);
const part3 = strId.slice(7, 11);
return part1 + " " + part2 + " " + part3;
default:
return videoCallData.id.toString();
}
}

View File

@ -1,99 +0,0 @@
import nodemailer from 'nodemailer';
import dayjs, { Dayjs } from "dayjs";
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import toArray from 'dayjs/plugin/toArray';
import { createEvent } from 'ics';
import { CalendarEvent } from '../calendarClient';
import { serverConfig } from '../serverConfig';
dayjs.extend(localizedFormat);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(toArray);
export default function createNewEventEmail(calEvent: CalendarEvent, options: any = {}) {
return sendEmail(calEvent, {
provider: {
transport: serverConfig.transport,
from: serverConfig.from,
},
...options
});
}
const icalEventAsString = (calEvent: CalendarEvent): string => {
const icsEvent = createEvent({
start: dayjs(calEvent.startTime).utc().toArray().slice(0, 6).map((v, i) => i === 1 ? v + 1 : v),
startInputType: 'utc',
productId: 'calendso/ics',
title: `${calEvent.type} with ${calEvent.attendees[0].name}`,
description: calEvent.description,
duration: { minutes: dayjs(calEvent.endTime).diff(dayjs(calEvent.startTime), 'minute') },
organizer: { name: calEvent.organizer.name, email: calEvent.organizer.email },
attendees: calEvent.attendees.map( (attendee: any) => ({ name: attendee.name, email: attendee.email }) ),
status: "CONFIRMED",
});
if (icsEvent.error) {
throw icsEvent.error;
}
return icsEvent.value;
}
const sendEmail = (calEvent: CalendarEvent, {
provider,
}) => new Promise( (resolve, reject) => {
const { transport, from } = provider;
const organizerStart: Dayjs = <Dayjs>dayjs(calEvent.startTime).tz(calEvent.organizer.timeZone);
nodemailer.createTransport(transport).sendMail(
{
icalEvent: {
filename: 'event.ics',
content: icalEventAsString(calEvent),
},
from: `Calendso <${from}>`,
to: calEvent.organizer.email,
subject: `New event: ${calEvent.attendees[0].name} - ${organizerStart.format('LT dddd, LL')} - ${calEvent.type}`,
html: html(calEvent),
text: text(calEvent),
},
(error) => {
if (error) {
console.error("SEND_NEW_EVENT_NOTIFICATION_ERROR", calEvent.organizer.email, error);
return reject(new Error(error));
}
return resolve();
});
});
const html = (evt: CalendarEvent) => `
<div>
Hi ${evt.organizer.name},<br />
<br />
A new event has been scheduled.<br />
<br />
<strong>Event Type:</strong><br />
${evt.type}<br />
<br />
<strong>Invitee Email:</strong><br />
<a href="mailto:${evt.attendees[0].email}">${evt.attendees[0].email}</a><br />
<br />` +
(
evt.location ? `
<strong>Location:</strong><br />
${evt.location}<br />
<br />
` : ''
) +
`<strong>Invitee Time Zone:</strong><br />
${evt.attendees[0].timeZone}<br />
<br />
<strong>Additional notes:</strong><br />
${evt.description}
</div>
`;
// just strip all HTML and convert <br /> to \n
const text = (evt: CalendarEvent) => html(evt).replace('<br />', "\n").replace(/<[^>]+>/g, '');

13
lib/eventTypeInput.ts Normal file
View File

@ -0,0 +1,13 @@
export enum EventTypeCustomInputType {
Text = 'text',
TextLong = 'textLong',
Number = 'number',
Bool = 'bool',
}
export interface EventTypeCustomInput {
id?: number;
type: EventTypeCustomInputType;
label: string;
required: boolean;
}

View File

@ -4,6 +4,8 @@ export function getIntegrationName(name: String) {
return "Google Calendar"; return "Google Calendar";
case "office365_calendar": case "office365_calendar":
return "Office 365 Calendar"; return "Office 365 Calendar";
case "zoom_video":
return "Zoom";
default: default:
return "Unknown"; return "Unknown";
} }

239
lib/videoClient.ts Normal file
View File

@ -0,0 +1,239 @@
import prisma from "./prisma";
import {CalendarEvent} from "./calendarClient";
import VideoEventOrganizerMail from "./emails/VideoEventOrganizerMail";
import VideoEventAttendeeMail from "./emails/VideoEventAttendeeMail";
import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid';
import EventAttendeeRescheduledMail from "./emails/EventAttendeeRescheduledMail";
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
const translator = short();
export interface VideoCallData {
type: string;
id: string;
password: string;
url: string;
}
function handleErrorsJson(response) {
if (!response.ok) {
response.json().then(console.log);
throw Error(response.statusText);
}
return response.json();
}
function handleErrorsRaw(response) {
if (!response.ok) {
response.text().then(console.log);
throw Error(response.statusText);
}
return response.text();
}
const zoomAuth = (credential) => {
const isExpired = (expiryDate) => expiryDate < +(new Date());
const authHeader = 'Basic ' + Buffer.from(process.env.ZOOM_CLIENT_ID + ':' + process.env.ZOOM_CLIENT_SECRET).toString('base64');
const refreshAccessToken = (refreshToken) => fetch('https://zoom.us/oauth/token', {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'refresh_token': refreshToken,
'grant_type': 'refresh_token',
})
})
.then(handleErrorsJson)
.then(async (responseBody) => {
// Store new tokens in database.
await prisma.credential.update({
where: {
id: credential.id
},
data: {
key: responseBody
}
});
credential.key.access_token = responseBody.access_token;
credential.key.expires_in = Math.round((+(new Date()) / 1000) + responseBody.expires_in);
return credential.key.access_token;
})
return {
getToken: () => !isExpired(credential.key.expires_in) ? Promise.resolve(credential.key.access_token) : refreshAccessToken(credential.key.refresh_token)
};
};
interface VideoApiAdapter {
createMeeting(event: CalendarEvent): Promise<any>;
updateMeeting(uid: String, event: CalendarEvent);
deleteMeeting(uid: String);
getAvailability(dateFrom, dateTo): Promise<any>;
}
const ZoomVideo = (credential): VideoApiAdapter => {
const auth = zoomAuth(credential);
const translateEvent = (event: CalendarEvent) => {
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
return {
topic: event.title,
type: 2, // Means that this is a scheduled meeting
start_time: event.startTime,
duration: ((new Date(event.endTime)).getTime() - (new Date(event.startTime)).getTime()) / 60000,
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
timezone: event.attendees[0].timeZone,
//password: "string", TODO: Should we use a password? Maybe generate a random one?
agenda: event.description,
settings: {
host_video: true,
participant_video: true,
cn_meeting: false, // TODO: true if host meeting in China
in_meeting: false, // TODO: true if host meeting in India
join_before_host: true,
mute_upon_entry: false,
watermark: false,
use_pmi: false,
approval_type: 2,
audio: "both",
auto_recording: "none",
enforce_login: false,
registrants_email_notification: true
}
};
};
return {
getAvailability: (dateFrom, dateTo) => {
return auth.getToken().then(
// TODO Possibly implement pagination for cases when there are more than 300 meetings already scheduled.
(accessToken) => fetch('https://api.zoom.us/v2/users/me/meetings?type=scheduled&page_size=300', {
method: 'get',
headers: {
'Authorization': 'Bearer ' + accessToken
}
})
.then(handleErrorsJson)
.then(responseBody => {
return responseBody.meetings.map((meeting) => ({
start: meeting.start_time,
end: (new Date((new Date(meeting.start_time)).getTime() + meeting.duration * 60000)).toISOString()
}))
})
).catch((err) => {
console.log(err);
});
},
createMeeting: (event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/users/me/meetings', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(translateEvent(event))
}).then(handleErrorsJson)),
deleteMeeting: (uid: String) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + accessToken
}
}).then(handleErrorsRaw)),
updateMeeting: (uid: String, event: CalendarEvent) => auth.getToken().then(accessToken => fetch('https://api.zoom.us/v2/meetings/' + uid, {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify(translateEvent(event))
}).then(handleErrorsRaw)),
}
};
// factory
const videoIntegrations = (withCredentials): VideoApiAdapter[] => withCredentials.map((cred) => {
switch (cred.type) {
case 'zoom_video':
return ZoomVideo(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
}).filter(Boolean);
const getBusyVideoTimes = (withCredentials, dateFrom, dateTo) => Promise.all(
videoIntegrations(withCredentials).map(c => c.getAvailability(dateFrom, dateTo))
).then(
(results) => results.reduce((acc, availability) => acc.concat(availability), [])
);
const createMeeting = async (credential, calEvent: CalendarEvent): Promise<any> => {
const uid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) {
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
}
const creationResult = await videoIntegrations([credential])[0].createMeeting(calEvent);
const videoCallData: VideoCallData = {
type: credential.type,
id: creationResult.id,
password: creationResult.password,
url: creationResult.join_url,
};
const organizerMail = new VideoEventOrganizerMail(calEvent, uid, videoCallData);
const attendeeMail = new VideoEventAttendeeMail(calEvent, uid, videoCallData);
await organizerMail.sendEmail();
if (!creationResult || !creationResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid,
createdEvent: creationResult
};
};
const updateMeeting = async (credential, uidToUpdate: String, calEvent: CalendarEvent): Promise<any> => {
const newUid: string = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
if (!credential) {
throw new Error("Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set.");
}
const updateResult = credential ? await videoIntegrations([credential])[0].updateMeeting(uidToUpdate, calEvent) : null;
const organizerMail = new EventOrganizerRescheduledMail(calEvent, newUid);
const attendeeMail = new EventAttendeeRescheduledMail(calEvent, newUid);
await organizerMail.sendEmail();
if (!updateResult || !updateResult.disableConfirmationEmail) {
await attendeeMail.sendEmail();
}
return {
uid: newUid,
updatedEvent: updateResult
};
};
const deleteMeeting = (credential, uid: String): Promise<any> => {
if (credential) {
return videoIntegrations([credential])[0].deleteMeeting(uid);
}
return Promise.resolve({});
};
export {getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting};

3966
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,10 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"postinstall": "prisma generate" "postinstall": "prisma generate",
"pre-commit": "lint-staged",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
"prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^1.0.0", "@headlessui/react": "^1.0.0",
@ -34,10 +37,26 @@
"devDependencies": { "devDependencies": {
"@types/node": "^14.14.33", "@types/node": "^14.14.33",
"@types/react": "^17.0.3", "@types/react": "^17.0.3",
"@typescript-eslint/eslint-plugin": "^4.27.0",
"@typescript-eslint/parser": "^4.27.0",
"autoprefixer": "^10.2.5", "autoprefixer": "^10.2.5",
"eslint": "^7.29.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"husky": "^6.0.0",
"lint-staged": "^11.0.0",
"postcss": "^8.2.8", "postcss": "^8.2.8",
"prettier": "^2.3.1",
"prisma": "^2.23.0", "prisma": "^2.23.0",
"tailwindcss": "^2.0.3", "tailwindcss": "^2.2.2",
"typescript": "^4.2.3" "typescript": "^4.2.3"
},
"lint-staged": {
"./{*,{pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [
"prettier --write",
"eslint"
]
} }
} }

View File

@ -13,6 +13,7 @@ import PhoneInput from 'react-phone-number-input';
import {LocationType} from '../../lib/location'; import {LocationType} from '../../lib/location';
import Avatar from '../../components/Avatar'; import Avatar from '../../components/Avatar';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import {EventTypeCustomInputType} from "../../lib/eventTypeInput";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -49,12 +50,31 @@ export default function Book(props) {
const bookingHandler = event => { const bookingHandler = event => {
event.preventDefault(); event.preventDefault();
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.value ? "Yes" : "No")
} else {
return input.label + "\n" + data.value
}
}
}).join("\n\n")
}
if (!!notes && !!event.target.notes.value) {
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
} else {
notes += event.target.notes.value;
}
let payload = { let payload = {
start: dayjs(date).format(), start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, 'minute').format(), end: dayjs(date).add(props.eventType.length, 'minute').format(),
name: event.target.name.value, name: event.target.name.value,
email: event.target.email.value, email: event.target.email.value,
notes: event.target.notes.value, notes: notes,
timeZone: preferredTimeZone, timeZone: preferredTimeZone,
eventTypeId: props.eventType.id, eventTypeId: props.eventType.id,
rescheduleUid: rescheduleUid rescheduleUid: rescheduleUid
@ -76,7 +96,7 @@ export default function Book(props) {
} }
); );
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=1&name=${payload.name}`; let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
if (payload['location']) { if (payload['location']) {
successUrl += "&location=" + encodeURIComponent(payload['location']); successUrl += "&location=" + encodeURIComponent(payload['location']);
} }
@ -143,9 +163,38 @@ export default function Book(props) {
<PhoneInput name="phone" placeholder="Enter phone number" id="phone" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" onChange={() => {}} /> <PhoneInput name="phone" placeholder="Enter phone number" id="phone" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" onChange={() => {}} />
</div> </div>
</div>)} </div>)}
{props.eventType.customInputs && props.eventType.customInputs.sort((a,b) => a.id - b.id).map(input => (
<div className="mb-4">
{input.type !== EventTypeCustomInputType.Bool &&
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700 mb-1">{input.label}</label>}
{input.type === EventTypeCustomInputType.TextLong &&
<textarea name={"custom_" + input.id} id={"custom_" + input.id}
required={input.required}
rows={3}
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder=""/>}
{input.type === EventTypeCustomInputType.Text &&
<input type="text" name={"custom_" + input.id} id={"custom_" + input.id}
required={input.required}
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder=""/>}
{input.type === EventTypeCustomInputType.Number &&
<input type="number" name={"custom_" + input.id} id={"custom_" + input.id}
required={input.required}
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
placeholder=""/>}
{input.type === EventTypeCustomInputType.Bool &&
<div className="flex items-center h-5">
<input type="checkbox" name={"custom_" + input.id} id={"custom_" + input.id}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
placeholder=""/>
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700">{input.label}</label>
</div>}
</div>
))}
<div className="mb-4"> <div className="mb-4">
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label> <label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label>
<textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Please share anything that will help prepare for our meeting." defaultValue={props.booking ? props.booking.description : ''}></textarea> <textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Please share anything that will help prepare for our meeting." defaultValue={props.booking ? props.booking.description : ''}/>
</div> </div>
<div className="flex items-start"> <div className="flex items-start">
<Button type="submit" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button> <Button type="submit" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button>
@ -188,6 +237,7 @@ export async function getServerSideProps(context) {
description: true, description: true,
length: true, length: true,
locations: true, locations: true,
customInputs: true,
} }
}); });

View File

@ -1,6 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type {NextApiRequest, NextApiResponse} from 'next';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
import { getBusyTimes } from '../../../lib/calendarClient'; import {getBusyCalendarTimes} from '../../../lib/calendarClient';
import {getBusyVideoTimes} from '../../../lib/videoClient';
import dayjs from "dayjs"; import dayjs from "dayjs";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -23,12 +24,26 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
})); }));
let availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars); const hasCalendarIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith('_calendar')).length > 0;
const hasVideoIntegrations = currentUser.credentials.filter((cred) => cred.type.endsWith('_video')).length > 0;
availability = availability.map(a => ({ const calendarAvailability = await getBusyCalendarTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo, selectedCalendars);
const videoAvailability = await getBusyVideoTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo);
let commonAvailability = [];
if(hasCalendarIntegrations && hasVideoIntegrations) {
commonAvailability = calendarAvailability.filter(availability => videoAvailability.includes(availability));
} else if(hasVideoIntegrations) {
commonAvailability = videoAvailability;
} else if(hasCalendarIntegrations) {
commonAvailability = calendarAvailability;
}
commonAvailability = commonAvailability.map(a => ({
start: dayjs(a.start).subtract(currentUser.bufferTime, 'minute').toString(), start: dayjs(a.start).subtract(currentUser.bufferTime, 'minute').toString(),
end: dayjs(a.end).add(currentUser.bufferTime, 'minute').toString() end: dayjs(a.end).add(currentUser.bufferTime, 'minute').toString()
})); }));
res.status(200).json(availability); res.status(200).json(commonAvailability);
} }

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type {NextApiRequest, NextApiResponse} from 'next';
import { getSession } from 'next-auth/client'; import {getSession} from 'next-auth/client';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
import {IntegrationCalendar, listCalendars} from "../../../lib/calendarClient"; import {IntegrationCalendar, listCalendars} from "../../../lib/calendarClient";

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type {NextApiRequest, NextApiResponse} from 'next';
import { getSession } from 'next-auth/client'; import {getSession} from 'next-auth/client';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@ -1,5 +1,5 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type {NextApiRequest, NextApiResponse} from 'next';
import { getSession } from 'next-auth/client'; import {getSession} from 'next-auth/client';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -18,7 +18,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
length: parseInt(req.body.length), length: parseInt(req.body.length),
hidden: req.body.hidden, hidden: req.body.hidden,
locations: req.body.locations, locations: req.body.locations,
eventName: req.body.eventName eventName: req.body.eventName,
customInputs: !req.body.customInputs
? undefined
: {
createMany: {
data: req.body.customInputs.filter(input => !input.id).map(input => ({
type: input.type,
label: input.label,
required: input.required
}))
},
update: req.body.customInputs.filter(input => !!input.id).map(input => ({
data: {
type: input.type,
label: input.label,
required: input.required
},
where: {
id: input.id
}
}))
},
}; };
if (req.method == "POST") { if (req.method == "POST") {

View File

@ -1,10 +1,11 @@
import type {NextApiRequest, NextApiResponse} from 'next'; import type {NextApiRequest, NextApiResponse} from 'next';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient'; import {CalendarEvent, createEvent, updateEvent} from '../../../lib/calendarClient';
import createConfirmBookedEmail from "../../../lib/emails/confirm-booked";
import async from 'async'; import async from 'async';
import {v5 as uuidv5} from 'uuid'; import {v5 as uuidv5} from 'uuid';
import short from 'short-uuid'; import short from 'short-uuid';
import {createMeeting, updateMeeting} from "../../../lib/videoClient";
import EventAttendeeMail from "../../../lib/emails/EventAttendeeMail";
import {getEventName} from "../../../lib/event"; import {getEventName} from "../../../lib/event";
const translator = short(); const translator = short();
@ -25,6 +26,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
}); });
// Split credentials up into calendar credentials and video credentials
const calendarCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_calendar'));
const videoCredentials = currentUser.credentials.filter(cred => cred.type.endsWith('_video'));
const rescheduleUid = req.body.rescheduleUid; const rescheduleUid = req.body.rescheduleUid;
const selectedEventType = await prisma.eventType.findFirst({ const selectedEventType = await prisma.eventType.findFirst({
@ -51,19 +56,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
] ]
}; };
const hashUID: string = translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
const cancelLink: string = process.env.BASE_URL + '/cancel/' + hashUID;
const rescheduleLink:string = process.env.BASE_URL + '/reschedule/' + hashUID;
const appendLinksToEvents = (event: CalendarEvent) => {
const eventCopy = {...event};
eventCopy.description += "\n\n"
+ "Need to change this event?\n"
+ "Cancel: " + cancelLink + "\n"
+ "Reschedule:" + rescheduleLink;
return eventCopy;
}
const eventType = await prisma.eventType.findFirst({ const eventType = await prisma.eventType.findFirst({
where: { where: {
userId: currentUser.id, userId: currentUser.id,
@ -74,8 +66,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
}); });
let results = undefined; let results = [];
let referencesToCreate = undefined; let referencesToCreate = [];
if (rescheduleUid) { if (rescheduleUid) {
// Reschedule event // Reschedule event
@ -96,10 +88,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
// Use all integrations // Use all integrations
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid; const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
return await updateEvent(credential, bookingRefUid, appendLinksToEvents(evt)) const response = await updateEvent(credential, bookingRefUid, evt);
});
return {
type: credential.type,
response
};
}));
results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
const bookingRefUid = booking.references.filter((ref) => ref.type === credential.type)[0].uid;
const response = await updateMeeting(credential, bookingRefUid, evt);
return {
type: credential.type,
response
};
}));
// Clone elements // Clone elements
referencesToCreate = [...booking.references]; referencesToCreate = [...booking.references];
@ -128,22 +134,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
]); ]);
} else { } else {
// Schedule event // Schedule event
results = await async.mapLimit(currentUser.credentials, 5, async (credential) => { results = results.concat(await async.mapLimit(calendarCredentials, 5, async (credential) => {
const response = await createEvent(credential, appendLinksToEvents(evt)); const response = await createEvent(credential, evt);
return { return {
type: credential.type, type: credential.type,
response response
}; };
}); }));
results = results.concat(await async.mapLimit(videoCredentials, 5, async (credential) => {
const response = await createMeeting(credential, evt);
return {
type: credential.type,
response
};
}));
referencesToCreate = results.map((result => { referencesToCreate = results.map((result => {
return { return {
type: result.type, type: result.type,
uid: result.response.id uid: result.response.createdEvent.id.toString()
}; };
})); }));
} }
// TODO Should just be set to the true case as soon as we have a "bare email" integration class.
// UID generation should happen in the integration itself, not here.
const hashUID = results.length > 0 ? results[0].response.uid : translator.fromUUID(uuidv5(JSON.stringify(evt), uuidv5.URL));
if(results.length === 0) {
// Legacy as well, as soon as we have a separate email integration class. Just used
// to send an email even if there is no integration at all.
const mail = new EventAttendeeMail(evt, hashUID);
await mail.sendEmail();
}
await prisma.booking.create({ await prisma.booking.create({
data: { data: {
uid: hashUID, uid: hashUID,
@ -164,12 +188,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
}); });
// If one of the integrations allows email confirmations or no integrations are added, send it.
if (currentUser.credentials.length === 0 || !results.every((result) => result.disableConfirmationEmail)) {
await createConfirmBookedEmail(
evt, cancelLink, rescheduleLink
);
}
res.status(200).json(results); res.status(200).json(results);
} }

View File

@ -1,6 +1,7 @@
import prisma from '../../lib/prisma'; import prisma from '../../lib/prisma';
import {deleteEvent} from "../../lib/calendarClient"; import {deleteEvent} from "../../lib/calendarClient";
import async from 'async'; import async from 'async';
import {deleteMeeting} from "../../lib/videoClient";
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method == "POST") { if (req.method == "POST") {
@ -29,7 +30,11 @@ export default async function handler(req, res) {
const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => { const apiDeletes = async.mapLimit(bookingToDelete.user.credentials, 5, async (credential) => {
const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid; const bookingRefUid = bookingToDelete.references.filter((ref) => ref.type === credential.type)[0].uid;
return await deleteEvent(credential, bookingRefUid); if(credential.type.endsWith("_calendar")) {
return await deleteEvent(credential, bookingRefUid);
} else if(credential.type.endsWith("_video")) {
return await deleteMeeting(credential, bookingRefUid);
}
}); });
const attendeeDeletes = prisma.attendee.deleteMany({ const attendeeDeletes = prisma.attendee.deleteMany({
where: { where: {

View File

@ -0,0 +1,29 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {getSession} from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const client_id = process.env.ZOOM_CLIENT_ID;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
// Check that user is authenticated
const session = await getSession({req: req});
if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; }
// Get user
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true
}
});
const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoomvideo/callback');
const authUrl = 'https://zoom.us/oauth/authorize?response_type=code&client_id=' + client_id + '&redirect_uri=' + redirectUri;
res.status(200).json({url: authUrl});
}
}

View File

@ -0,0 +1,39 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {getSession} from "next-auth/client";
import prisma from "../../../../lib/prisma";
const client_id = process.env.ZOOM_CLIENT_ID;
const client_secret = process.env.ZOOM_CLIENT_SECRET;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
// Check that user is authenticated
const session = await getSession({req: req});
if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; }
const redirectUri = encodeURI(process.env.BASE_URL + '/api/integrations/zoomvideo/callback');
const authHeader = 'Basic ' + Buffer.from(client_id + ':' + client_secret).toString('base64');
return new Promise( async (resolve, reject) => {
const result = await fetch('https://zoom.us/oauth/token?grant_type=authorization_code&code=' + code + '&redirect_uri=' + redirectUri, {
method: 'POST',
headers: {
Authorization: authHeader
}
})
.then(res => res.json());
await prisma.credential.create({
data: {
type: 'zoom_video',
key: result,
userId: session.user.id
}
});
res.redirect('/integrations');
resolve();
});
}

View File

@ -1,10 +1,10 @@
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import {useRouter} from 'next/router';
import { useRef, useState } from 'react'; import {useRef, useState} from 'react';
import Select, { OptionBase } from 'react-select'; import Select, {OptionBase} from 'react-select';
import prisma from '../../../lib/prisma'; import prisma from '../../../lib/prisma';
import { LocationType } from '../../../lib/location'; import {LocationType} from '../../../lib/location';
import Shell from '../../../components/Shell'; import Shell from '../../../components/Shell';
import { useSession, getSession } from 'next-auth/client'; import { useSession, getSession } from 'next-auth/client';
import {Scheduler} from "../../../components/ui/Scheduler"; import {Scheduler} from "../../../components/ui/Scheduler";
@ -15,6 +15,8 @@ import {
XIcon, XIcon,
PhoneIcon, PhoneIcon,
} from '@heroicons/react/outline'; } from '@heroicons/react/outline';
import {EventTypeCustomInput, EventTypeCustomInputType} from "../../../lib/eventTypeInput";
import {PlusIcon} from "@heroicons/react/solid";
import dayjs, {Dayjs} from "dayjs"; import dayjs, {Dayjs} from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
@ -25,11 +27,21 @@ dayjs.extend(timezone);
export default function EventType(props) { export default function EventType(props) {
const router = useRouter(); const router = useRouter();
const inputOptions: OptionBase[] = [
{ value: EventTypeCustomInputType.Text, label: 'Text' },
{ value: EventTypeCustomInputType.TextLong, label: 'Multiline Text' },
{ value: EventTypeCustomInputType.Number, label: 'Number', },
{ value: EventTypeCustomInputType.Bool, label: 'Checkbox', },
]
const [ session, loading ] = useSession(); const [ session, loading ] = useSession();
const [ showLocationModal, setShowLocationModal ] = useState(false); const [ showLocationModal, setShowLocationModal ] = useState(false);
const [ showAddCustomModal, setShowAddCustomModal ] = useState(false);
const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined); const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined);
const [ selectedInputOption, setSelectedInputOption ] = useState<OptionBase>(inputOptions[0]);
const [ locations, setLocations ] = useState(props.eventType.locations || []); const [ locations, setLocations ] = useState(props.eventType.locations || []);
const [ schedule, setSchedule ] = useState(undefined); const [ schedule, setSchedule ] = useState(undefined);
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []);
const titleRef = useRef<HTMLInputElement>(); const titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>(); const slugRef = useRef<HTMLInputElement>();
@ -55,7 +67,7 @@ export default function EventType(props) {
const response = await fetch('/api/availability/eventtype', { const response = await fetch('/api/availability/eventtype', {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, eventName: enteredEventName }), body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, eventName: enteredEventName, customInputs }),
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
@ -119,6 +131,11 @@ export default function EventType(props) {
setShowLocationModal(false); setShowLocationModal(false);
}; };
const closeAddCustomModal = () => {
setSelectedInputOption(inputOptions[0]);
setShowAddCustomModal(false);
};
const LocationOptions = () => { const LocationOptions = () => {
if (!selectedLocation) { if (!selectedLocation) {
return null; return null;
@ -169,6 +186,21 @@ export default function EventType(props) {
setLocations(locations.filter( (location) => location.type !== selectedLocation.type )); setLocations(locations.filter( (location) => location.type !== selectedLocation.type ));
}; };
const updateCustom = (e) => {
e.preventDefault();
const customInput: EventTypeCustomInput = {
label: e.target.label.value,
required: e.target.required.checked,
type: e.target.type.value
};
setCustomInputs(customInputs.concat(customInput));
console.log(customInput)
setShowAddCustomModal(false);
};
return ( return (
<div> <div>
<Head> <Head>
@ -274,6 +306,44 @@ export default function EventType(props) {
<input ref={eventNameRef} type="text" name="title" id="title" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Meeting with {USER}" defaultValue={props.eventType.eventName} /> <input ref={eventNameRef} type="text" name="title" id="title" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Meeting with {USER}" defaultValue={props.eventType.eventName} />
</div> </div>
</div> </div>
<div className="mb-4">
<label htmlFor="additionalFields" className="block text-sm font-medium text-gray-700">Additional Inputs</label>
<ul className="w-96 mt-1">
{customInputs.map( (customInput) => (
<li key={customInput.type} className="bg-blue-50 mb-2 p-2 border">
<div className="flex justify-between">
<div>
<div>
<span className="ml-2 text-sm">Label: {customInput.label}</span>
</div>
<div>
<span className="ml-2 text-sm">Type: {customInput.type}</span>
</div>
<div>
<span
className="ml-2 text-sm">{customInput.required ? "Required" : "Optional"}</span>
</div>
</div>
<div className="flex">
<button type="button" onClick={() => {
}} className="mr-2 text-sm text-blue-600">Edit
</button>
<button onClick={() => {
}}>
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 "/>
</button>
</div>
</div>
</li>
))}
<li>
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowAddCustomModal(true)}>
<PlusCircleIcon className="h-6 w-6" />
<span className="ml-1">Add another input</span>
</button>
</li>
</ul>
</div>
<div className="my-8"> <div className="my-8">
<div className="relative flex items-start"> <div className="relative flex items-start">
<div className="flex items-center h-5"> <div className="flex items-center h-5">
@ -366,6 +436,66 @@ export default function EventType(props) {
</div> </div>
</div> </div>
} }
{showAddCustomModal &&
<div className="fixed z-10 inset-0 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="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"/>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left 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">
<PlusIcon className="h-6 w-6 text-blue-600" />
</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">Add new custom input field</h3>
<div>
<p className="text-sm text-gray-400">
This input will be shown when booking this event
</p>
</div>
</div>
</div>
<form onSubmit={updateCustom}>
<div className="mb-2">
<label htmlFor="type" className="block text-sm font-medium text-gray-700">Input type</label>
<Select
name="type"
defaultValue={selectedInputOption}
options={inputOptions}
isSearchable="false"
required
className="mb-2 flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300 mt-1"
onChange={setSelectedInputOption}
/>
</div>
<div className="mb-2">
<label htmlFor="label" className="block text-sm font-medium text-gray-700">Label</label>
<div className="mt-1">
<input type="text" name="label" id="label" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" />
</div>
</div>
<div className="flex items-center h-5">
<input id="required" name="required" type="checkbox" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" defaultChecked={true}/>
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
Is required
</label>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Save
</button>
<button onClick={closeAddCustomModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
</Shell> </Shell>
</div> </div>
); );
@ -404,9 +534,16 @@ export async function getServerSideProps(context) {
locations: true, locations: true,
eventName: true, eventName: true,
availability: true, availability: true,
customInputs: true
} }
}); });
if (!eventType) {
return {
notFound: true,
}
}
const getAvailability = (providesAvailability) => ( const getAvailability = (providesAvailability) => (
providesAvailability.availability && providesAvailability.availability.length providesAvailability.availability && providesAvailability.availability.length
) ? providesAvailability.availability : null; ) ? providesAvailability.availability : null;

View File

@ -3,11 +3,10 @@ import Link from 'next/link';
import prisma from '../../lib/prisma'; import prisma from '../../lib/prisma';
import Modal from '../../components/Modal'; import Modal from '../../components/Modal';
import Shell from '../../components/Shell'; import Shell from '../../components/Shell';
import { useRouter } from 'next/router'; import {useRouter} from 'next/router';
import { useRef } from 'react'; import {useRef, useState} from 'react';
import { useState } from 'react'; import {getSession, useSession} from 'next-auth/client';
import { useSession, getSession } from 'next-auth/client'; import {ClockIcon, PlusIcon} from '@heroicons/react/outline';
import { PlusIcon, ClockIcon } from '@heroicons/react/outline';
export default function Availability(props) { export default function Availability(props) {
const [ session, loading ] = useSession(); const [ session, loading ] = useSession();

View File

@ -2,8 +2,8 @@ import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import prisma from '../lib/prisma'; import prisma from '../lib/prisma';
import Shell from '../components/Shell'; import Shell from '../components/Shell';
import { signIn, useSession, getSession } from 'next-auth/client'; import {getSession, useSession} from 'next-auth/client';
import { ClockIcon, CheckIcon, InformationCircleIcon } from '@heroicons/react/outline'; import {CheckIcon, ClockIcon, InformationCircleIcon} from '@heroicons/react/outline';
import DonateBanner from '../components/DonateBanner'; import DonateBanner from '../components/DonateBanner';
function classNames(...classes) { function classNames(...classes) {
@ -206,10 +206,13 @@ export default function Home(props) {
<li className="pb-4 flex"> <li className="pb-4 flex">
{integration.type == 'google_calendar' && <img className="h-10 w-10 mr-2" src="integrations/google-calendar.png" alt="Google Calendar" />} {integration.type == 'google_calendar' && <img className="h-10 w-10 mr-2" src="integrations/google-calendar.png" alt="Google Calendar" />}
{integration.type == 'office365_calendar' && <img className="h-10 w-10 mr-2" src="integrations/office-365.png" alt="Office 365 / Outlook.com Calendar" />} {integration.type == 'office365_calendar' && <img className="h-10 w-10 mr-2" src="integrations/office-365.png" alt="Office 365 / Outlook.com Calendar" />}
{integration.type == 'zoom_video' && <img className="h-10 w-10 mr-2" src="integrations/zoom.png" alt="Zoom" />}
<div className="ml-3"> <div className="ml-3">
{integration.type == 'office365_calendar' && <p className="text-sm font-medium text-gray-900">Office 365 / Outlook.com Calendar</p>} {integration.type == 'office365_calendar' && <p className="text-sm font-medium text-gray-900">Office 365 / Outlook.com Calendar</p>}
{integration.type == 'google_calendar' && <p className="text-sm font-medium text-gray-900">Google Calendar</p>} {integration.type == 'google_calendar' && <p className="text-sm font-medium text-gray-900">Google Calendar</p>}
<p className="text-sm text-gray-500">Calendar Integration</p> {integration.type == 'zoom_video' && <p className="text-sm font-medium text-gray-900">Zoom</p>}
{integration.type.endsWith('_calendar') && <p className="text-sm text-gray-500">Calendar Integration</p>}
{integration.type.endsWith('_video') && <p className="text-sm text-gray-500">Video Conferencing</p>}
</div> </div>
</li> </li>
)} )}

View File

@ -6,7 +6,7 @@ import {useEffect, useState} from 'react';
import {getSession, useSession} from 'next-auth/client'; import {getSession, useSession} from 'next-auth/client';
import {CalendarIcon, CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid'; import {CalendarIcon, CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon} from '@heroicons/react/solid';
import {InformationCircleIcon} from '@heroicons/react/outline'; import {InformationCircleIcon} from '@heroicons/react/outline';
import { Switch } from '@headlessui/react' import {Switch} from '@headlessui/react'
export default function Home({ integrations }) { export default function Home({ integrations }) {
const [session, loading] = useSession(); const [session, loading] = useSession();
@ -107,6 +107,7 @@ export default function Home({ integrations }) {
<p className="text-sm font-medium text-blue-600 truncate">{ig.title}</p> <p className="text-sm font-medium text-blue-600 truncate">{ig.title}</p>
<p className="flex items-center text-sm text-gray-500"> <p className="flex items-center text-sm text-gray-500">
{ig.type.endsWith('_calendar') && <span className="truncate">Calendar Integration</span>} {ig.type.endsWith('_calendar') && <span className="truncate">Calendar Integration</span>}
{ig.type.endsWith('_video') && <span className="truncate">Video Conferencing</span>}
</p> </p>
</div> </div>
<div className="hidden md:block"> <div className="hidden md:block">
@ -363,14 +364,21 @@ export async function getServerSideProps(context) {
type: "google_calendar", type: "google_calendar",
title: "Google Calendar", title: "Google Calendar",
imageSrc: "integrations/google-calendar.png", imageSrc: "integrations/google-calendar.png",
description: "For personal and business accounts", description: "For personal and business calendars",
}, { }, {
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar", type: "office365_calendar",
credential: credentials.find( (integration) => integration.type === "office365_calendar" ) || null, credential: credentials.find( (integration) => integration.type === "office365_calendar" ) || null,
title: "Office 365 / Outlook.com Calendar", title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/office-365.png", imageSrc: "integrations/office-365.png",
description: "For personal and business accounts", description: "For personal and business calendars",
}, {
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
type: "zoom_video",
credential: credentials.find( (integration) => integration.type === "zoom_video" ) || null,
title: "Zoom",
imageSrc: "integrations/zoom.png",
description: "Video Conferencing",
} ]; } ];
return { return {

View File

@ -2,14 +2,14 @@ import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
import prisma from '../lib/prisma'; import prisma from '../lib/prisma';
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import { useRouter } from 'next/router'; import {useRouter} from 'next/router';
import { CheckIcon } from '@heroicons/react/outline'; import {CheckIcon} from '@heroicons/react/outline';
import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid'; import {CalendarIcon, ClockIcon, LocationMarkerIcon} from '@heroicons/react/solid';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import toArray from 'dayjs/plugin/toArray'; import toArray from 'dayjs/plugin/toArray';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import { createEvent } from 'ics'; import {createEvent} from 'ics';
import {getEventName} from "../lib/event"; import {getEventName} from "../lib/event";
dayjs.extend(utc); dayjs.extend(utc);

View File

@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "EventTypeCustomInput" (
"id" SERIAL NOT NULL,
"eventTypeId" INTEGER NOT NULL,
"label" TEXT NOT NULL,
"type" TEXT NOT NULL,
"required" BOOLEAN NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "EventTypeCustomInput" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -23,6 +23,7 @@ model EventType {
bookings Booking[] bookings Booking[]
availability Schedule[] availability Schedule[]
eventName String? eventName String?
customInputs EventTypeCustomInput[]
} }
model Credential { model Credential {
@ -148,3 +149,13 @@ model SelectedCalendar {
externalId String externalId String
@@id([userId,integration,externalId]) @@id([userId,integration,externalId])
} }
model EventTypeCustomInput {
id Int @id @default(autoincrement())
eventTypeId Int
eventType EventType @relation(fields: [eventTypeId], references: [id])
label String
type String
required Boolean
}

View File

@ -1,16 +0,0 @@
nav#nav--settings > a {
@apply border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 border-l-4 px-3 py-2 flex items-center text-sm font-medium;
}
nav#nav--settings > a svg {
@apply text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6;
}
nav#nav--settings > a.active {
@apply bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700;
}
nav#nav--settings > a.active svg {
@apply text-blue-500;
}

View File

@ -1,76 +0,0 @@
@layer components {
/* Primary buttons */
.btn-xs.btn-primary {
@apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-sm.btn-primary {
@apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn.btn-primary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-lg.btn-primary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-xl.btn-primary {
@apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-wide.btn-primary {
@apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
/* Secondary buttons */
.btn-xs.btn-secondary {
@apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-sm.btn-secondary {
@apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn.btn-secondary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-lg.btn-secondary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-xl.btn-secondary {
@apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-wide.btn-secondary {
@apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
/* White buttons */
.btn-xs.btn-white {
@apply inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-sm.btn-white {
@apply inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn.btn-white {
@apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-lg.btn-white {
@apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-xl.btn-white {
@apply inline-flex items-center px-6 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-wide.btn-white {
@apply w-full text-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
}

View File

@ -1,14 +0,0 @@
.loader {
margin: 80px auto;
border: 8px solid #f3f3f3; /* Light grey */
border-top: 8px solid #039be5; /* Blue */
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@ -1,8 +0,0 @@
table tbody tr:nth-child(odd) {
@apply bg-gray-50;
}
.highlight-odd > *:nth-child(odd) {
@apply bg-gray-50;
}

View File

@ -2,10 +2,121 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@import './components/buttons.css';
@import './components/spinner.css'; /* note(PeerRich): TODO move @layer components into proper React Components: <Button color="primary" size="xs" /> */
@import './components/activelink.css'; @layer components {
@import './components/table.css'; /* Primary buttons */
.btn-xs.btn-primary {
@apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-sm.btn-primary {
@apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn.btn-primary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-lg.btn-primary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-xl.btn-primary {
@apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-wide.btn-primary {
@apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
/* Secondary buttons */
.btn-xs.btn-secondary {
@apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-sm.btn-secondary {
@apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn.btn-secondary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-lg.btn-secondary {
@apply inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-xl.btn-secondary {
@apply inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-wide.btn-secondary {
@apply w-full text-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
/* White buttons */
.btn-xs.btn-white {
@apply inline-flex items-center px-2.5 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-sm.btn-white {
@apply inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn.btn-white {
@apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-lg.btn-white {
@apply inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-xl.btn-white {
@apply inline-flex items-center px-6 py-3 border border-gray-300 shadow-sm text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-wide.btn-white {
@apply w-full text-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
}
.loader {
margin: 80px auto;
border: 8px solid #f3f3f3; /* Light grey */
border-top: 8px solid #039be5; /* Blue */
border-radius: 50%;
width: 60px;
height: 60px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
nav#nav--settings > a {
@apply border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 border-l-4 px-3 py-2 flex items-center text-sm font-medium;
}
nav#nav--settings > a svg {
@apply text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6;
}
nav#nav--settings > a.active {
@apply bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700;
}
nav#nav--settings > a.active svg {
@apply text-blue-500;
}
table tbody tr:nth-child(odd) {
@apply bg-gray-50;
}
body { body {
background-color: #f3f4f6; background-color: #f3f4f6;

View File

@ -1,41 +1,40 @@
module.exports = { module.exports = {
purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], mode: "jit",
purge: ["./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
darkMode: false, // or 'media' or 'class' darkMode: false, // or 'media' or 'class'
theme: { theme: {
extend: { extend: {
colors: { colors: {
gray: { gray: {
100: '#EBF1F5', 100: "#EBF1F5",
200: '#D9E3EA', 200: "#D9E3EA",
300: '#C5D2DC', 300: "#C5D2DC",
400: '#9BA9B4', 400: "#9BA9B4",
500: '#707D86', 500: "#707D86",
600: '#55595F', 600: "#55595F",
700: '#33363A', 700: "#33363A",
800: '#25282C', 800: "#25282C",
900: '#151719', 900: "#151719",
}, },
blue: { blue: {
100: '#b3e5fc', 100: "#b3e5fc",
200: '#81d4fa', 200: "#81d4fa",
300: '#4fc3f7', 300: "#4fc3f7",
400: '#29b6f6', 400: "#29b6f6",
500: '#03a9f4', 500: "#03a9f4",
600: '#039be5', 600: "#039be5",
700: '#0288d1', 700: "#0288d1",
800: '#0277bd', 800: "#0277bd",
900: '#01579b', 900: "#01579b",
}, },
}, },
maxHeight: { maxHeight: {
97: '25rem', 97: "25rem",
}, },
}, },
}, },
variants: { variants: {
extend: {}, extend: {},
}, },
plugins: [ plugins: [require("@tailwindcss/forms")],
require('@tailwindcss/forms'), };
],
}

1395
yarn.lock

File diff suppressed because it is too large Load Diff