add calendso example (#104)

This commit is contained in:
Faraz Patankar
2021-05-22 00:00:59 +04:00
committed by GitHub
parent 80a3ebd582
commit f59186a4dd
83 changed files with 13873 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
DATABASE_URL='postgresql://<user>:<pass>@<db-host>:<db-port>/<db-name>'
GOOGLE_API_CREDENTIALS='secret'
NEXTAUTH_URL='http://localhost:3000'
# Remove this var if you don't want Calendso to collect anonymous usage
NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r
# Used for the Office 365 / Outlook.com Calendar integration
MS_GRAPH_CLIENT_ID=
MS_GRAPH_CLIENT_SECRET=

View File

@@ -0,0 +1,25 @@
---
name: Bug report
about: Report any issues with the platform
title: ''
labels: bug
assignees: ''
---
Found a bug? Please fill out the sections below. 👍
### Issue Summary
A summary of the issue. This needs to be a clear detailed-rich summary.
### Steps to Reproduce
1. (for example) Went to ...
2. Clicked on...
3. ...
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
### Technical details
* Browser version: You can use https://www.whatsmybrowser.org/ to find this out.
* Node.js version
* Anything else that you think could be an issue.

View File

@@ -0,0 +1,36 @@
---
name: Feature request
about: Suggest a feature or idea
title: ''
labels: enhancement
assignees: ''
---
> Please check if your Feature Request has not been already raised in the [Discussions Tab](https://github.com/calendso/calendso/discussions), as we would like to reduce duplicates. If it has been already raised, simply upvote it 🔼.
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)

View File

@@ -0,0 +1,14 @@
---
name: Questions
about: Ask a general question about the project
title: ''
labels: ''
assignees: ''
---
Please do not use GitHub for asking questions, as this unnecessarily pollutes it. Instead, if you have a general question about Calendso or about Calendso's features we encourage you to post on our Slack workspace instead: [Calendso's Slack](https://calendso.com/slack). The maintainers and other community members can provide help and answer your questions there.
If you've discovered a bug or would like to propose a change/new feature please use one of the other issue templates.
Thanks!

37
examples/calendso/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# .env file
.env
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel

19
examples/calendso/LICENSE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2021 Calendso
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,29 @@
---
title: Calendso
description: A self-hosted version of Calendso using a MongoDB database
tags:
- calendso
- postgres
- prisma
---
# Calendso example
This example deploys a self-hosted version of [Calendso](https://calendso.com/). Internally it uses a PostgreSQL database to store the data.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https%3A%2F%2Fgithub.com%2Frailwayapp%2Fexamples%2Ftree%2Fmaster%2Fexamples%2Fcalendso&plugins=postgresql&envs=GOOGLE_API_CREDENTIALS%2CNEXTAUTH_URL&NEXTAUTH_URLDefault=http%3A%2F%2Flocalhost%3A3000%2F)
## ✨ Features
- Calendso
- PostgreSQL
## 💁‍♀️ How to use
- Click the Railway button 👆
- Add the required environment variables
- Deploy
## 📝 Notes
- You need to set up Google API credentials to use this starter. We've written an entire [blog post](http://blog.railway.app/calendso) on it to help you through the process.

View File

@@ -0,0 +1,147 @@
openapi: 3.0.0
info:
title: Calendso API
description: The open source Calendly alternative.
contact:
name: Support
email: support@calendso.com
license:
name: MIT License
url: https://opensource.org/licenses/MIT
version: 0.1.0
server:
url: http://localhost:{port}
description: Local Development Server
variables:
port:
default: '3000'
tags:
- name: Authentication
description: Auth routes, powered by Next-Auth.js
externalDocs:
url: http://next-auth.js.org/
- name: Availability
description: Checking and setting user availability
- name: Booking
description: Create and manage bookings
- name: Integrations
description: Manage integrations
- name: User
description: Manage the user's profile and settings
paths:
/api/auth/signin:
get:
description: Displays the sign in page.
summary: Displays the sign in page
tags:
- Authentication
/api/auth/signin/:provider:
post:
description: Starts an OAuth signin flow for the specified provider. The POST submission requires CSRF token from /api/auth/csrf.
summary: Starts an OAuth signin flow for the specified provider
tags:
- Authentication
/api/auth/callback/:provider:
get:
description: Handles returning requests from OAuth services during sign in. For OAuth 2.0 providers that support the state option, the value of the state parameter is checked against the one that was generated when the sign in flow was started - this uses a hash of the CSRF token which MUST match for both the POST and GET calls during sign in.
summary: Handles returning requests from OAuth services
tags:
- Authentication
/api/auth/signout:
get:
description: Displays the sign out page.
summary: Displays the sign out page
tags:
- Authentication
post:
description: Handles signing out - this is a POST submission to prevent malicious links from triggering signing a user out without their consent. Handles signing out - this is a POST submission to prevent malicious links from triggering signing a user out without their consent.
summary: Handles signing out
tags:
- Authentication
/api/auth/session:
get:
description: Returns client-safe session object - or an empty object if there is no session. The contents of the session object that is returned are configurable with the session callback.
summary: Returns client-safe session object
tags:
- Authentication
/api/auth/csrf:
get:
description: Returns object containing CSRF token. In NextAuth.js, CSRF protection is present on all authentication routes. It uses the "double submit cookie method", which uses a signed HttpOnly, host-only cookie. The CSRF token returned by this endpoint must be passed as form variable named csrfToken in all POST submissions to any API endpoint.
summary: Returns object containing CSRF token
tags:
- Authentication
/api/auth/providers:
get:
description: Returns a list of configured OAuth services and details (e.g. sign in and callback URLs) for each service. It can be used to dynamically generate custom sign up pages and to check what callback URLs are configured for each OAuth provider that is configured.
summary: Returns configured OAuth services
tags:
- Authentication
/api/auth/changepw:
post:
description: Changes the password for the currently logged in account.
summary: Changes the password for the currently logged in account
tags:
- Authentication
/api/availability/:user:
get:
description: Gets the busy times for a particular user, by username.
summary: Gets the busy times for a user
tags:
- Availability
/api/availability/day:
patch:
description: Updates the start and end times for a user's availability.
summary: Updates the user's start and end times
tags:
- Availability
/api/availability/eventtype:
post:
description: Adds a new event type for the user.
summary: Adds a new event type
tags:
- Availability
patch:
description: Updates an event type for the user.
summary: Updates an event type
tags:
- Availability
delete:
description: Deletes an event type for the user.
summary: Deletes an event type
tags:
- Availability
/api/book/:user:
post:
description: Creates a booking in the user's calendar.
summary: Creates a booking for a user
tags:
- Booking
/api/integrations:
get:
description: Gets a list of the user's integrations.
summary: Gets the user's integrations
tags:
- Integrations
delete:
description: Deletes a user's integration
summary: Deletes a user's integration
tags:
- Integrations
/api/integrations/googlecalendar/add:
get:
description: Gets the OAuth URL for a Google Calendar integration.
summary: Gets the OAuth URL
tags:
- Integrations
/api/integrations/googlecalendar/callback:
post:
description: Gets and stores the OAuth token for a Google Calendar integration.
summary: Gets and stores the OAuth token
tags:
- Integrations
/api/user/profile:
patch:
description: Updates a user's profile.
summary: Updates a user's profile
tags:
- User

View File

@@ -0,0 +1,27 @@
import { useState } from "react";
import md5 from '../lib/md5';
export default function Avatar({ user, className = '', fallback }: {
user: any;
className?: string;
fallback?: JSX.Element;
}) {
const [gravatarAvailable, setGravatarAvailable] = useState(true);
if (user.avatar) {
return <img src={user.avatar} alt="Avatar" className={className} />;
}
if (gravatarAvailable) {
return (
<img
onError={() => setGravatarAvailable(false)}
src={`https://www.gravatar.com/avatar/${md5(user.email)}?d=404&s=160`}
alt="Avatar"
className={className}
/>
);
}
return fallback || null;
}

View File

@@ -0,0 +1,44 @@
import { GiftIcon } from "@heroicons/react/outline";
export default function DonateBanner() {
if (location.hostname.endsWith(".calendso.com")) {
return null;
}
return (
<>
<div className="h-12" />
<div className="fixed inset-x-0 bottom-0">
<div className="bg-blue-600">
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap">
<div className="w-0 flex-1 flex items-center">
<span className="flex p-2 rounded-lg bg-blue-600">
<GiftIcon className="h-6 w-6 text-white" aria-hidden="true" />
</span>
<p className="ml-3 font-medium text-white truncate">
<span className="md:hidden">
Support the ongoing development
</span>
<span className="hidden md:inline">
You're using the free self-hosted version. Support the
ongoing development by making a donation.
</span>
</p>
</div>
<div className="order-3 mt-2 flex-shrink-0 w-full sm:order-2 sm:mt-0 sm:w-auto">
<a
target="_blank"
href="https://calendso.com/donate"
className="flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-blue-600 bg-white hover:bg-blue-50"
>
Donate
</a>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,66 @@
import { Fragment, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { CheckIcon } from '@heroicons/react/outline'
export default function Modal(props) {
return (
<Transition.Root show={props.open} as={Fragment}>
<Dialog as="div" static className="fixed z-10 inset-0 overflow-y-auto" open={props.open} onClose={props.handleClose}>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-5">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
{props.heading}
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
{props.description}
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<button
type="button"
className="btn-wide btn-primary"
onClick={() => props.handleClose()}
>
Dismiss
</button>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
)
}

View File

@@ -0,0 +1,87 @@
import Link from 'next/link';
import { useRouter } from "next/router";
import { UserCircleIcon, KeyIcon } from '@heroicons/react/outline';
export default function SettingsShell(props) {
const router = useRouter();
return (
<div>
<main className="relative -mt-32">
<div>
<div className="bg-white rounded-lg shadow">
<div className="divide-y divide-gray-200 lg:grid lg:grid-cols-12 lg:divide-y-0 lg:divide-x">
<aside className="py-6 lg:col-span-3">
<nav className="space-y-1">
<Link href="/settings/profile">
<a className={router.pathname == "/settings/profile" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"} aria-current="page">
<UserCircleIcon className={router.pathname == "/settings/profile" ? "text-blue-500 group-hover:text-blue-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" : "text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"} />
<span className="truncate">
Profile
</span>
</a>
</Link>
{/* <Link href="/settings/account">
<a className={router.pathname == "/settings/account" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"}>
<svg className={router.pathname == "/settings/account" ? "text-blue-500 group-hover:text-blue-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" : "text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="truncate">
Account
</span>
</a>
</Link> */}
<Link href="/settings/password">
<a className={router.pathname == "/settings/password" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"}>
<KeyIcon className={router.pathname == "/settings/password" ? "text-blue-500 group-hover:text-blue-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" : "text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"} />
<span className="truncate">
Password
</span>
</a>
</Link>
{/* <Link href="/settings/notifications">
<a className={router.pathname == "/settings/notifications" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"}>
<svg className={router.pathname == "/settings/notifications" ? "text-blue-500 group-hover:text-blue-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" : "text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span className="truncate">
Notifications
</span>
</a>
</Link>
<Link href="/settings/billing">
<a className={router.pathname == "/settings/billing" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"}>
<svg className={router.pathname == "/settings/billing" ? "text-blue-500 group-hover:text-blue-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" : "text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
<span className="truncate">
Billing
</span>
</a>
</Link>
<Link href="/settings/integrations">
<a className={router.pathname == "/settings/integrations" ? "bg-blue-50 border-blue-500 text-blue-700 hover:bg-blue-50 hover:text-blue-700 group border-l-4 px-3 py-2 flex items-center text-sm font-medium" : "border-transparent text-gray-900 hover:bg-gray-50 hover:text-gray-900 group border-l-4 px-3 py-2 flex items-center text-sm font-medium"}>
<svg className={router.pathname == "/settings/integrations" ? "text-blue-500 group-hover:text-blue-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6" : "text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z" />
</svg>
<span className="truncate">
Integrations
</span>
</a>
</Link> */}
</nav>
</aside>
{props.children}
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import Link from 'next/link';
import {useContext, useEffect, useState} from "react";
import { useRouter } from "next/router";
import { signOut, useSession } from 'next-auth/client';
import { MenuIcon, XIcon } from '@heroicons/react/outline';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../lib/telemetry";
export default function Shell(props) {
const router = useRouter();
const [ session, loading ] = useSession();
const [ profileDropdownExpanded, setProfileDropdownExpanded ] = useState(false);
const [ mobileMenuExpanded, setMobileMenuExpanded ] = useState(false);
let telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu((jitsu) => {
return jitsu.track(telemetryEventTypes.pageView, collectPageParameters(router.pathname))
});
}, [telemetry])
const toggleProfileDropdown = () => {
setProfileDropdownExpanded(!profileDropdownExpanded);
}
const toggleMobileMenu = () => {
setMobileMenuExpanded(!mobileMenuExpanded);
}
const logoutHandler = () => {
signOut({ redirect: false }).then( () => router.push('/auth/logout') );
}
if ( ! loading && ! session ) {
router.replace('/auth/login');
}
return session && (
<div>
<div className="bg-gradient-to-b from-blue-600 via-blue-600 to-blue-300 pb-32">
<nav className="bg-blue-600">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="border-b border-blue-500">
<div className="flex items-center justify-between h-16 px-4 sm:px-0">
<div className="flex items-center">
<div className="flex-shrink-0">
<img className="h-6" src="/calendso-white.svg" alt="Calendso" />
</div>
<div className="hidden md:block">
<div className="ml-10 flex items-baseline space-x-4">
<Link href="/">
<a className={router.pathname == "/" ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Dashboard</a>
</Link>
{/* <Link href="/">
<a className={router.pathname.startsWith("/bookings") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Bookings</a>
</Link> */}
<Link href="/availability">
<a className={router.pathname.startsWith("/availability") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Availability</a>
</Link>
<Link href="/integrations">
<a className={router.pathname.startsWith("/integrations") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Integrations</a>
</Link>
<Link href="/settings/profile">
<a className={router.pathname.startsWith("/settings") ? "bg-blue-500 transition-colors duration-300 ease-in-out text-white px-3 py-2 rounded-md text-sm font-medium" : "text-white hover:bg-blue-500 transition-colors duration-300 ease-in-out hover:text-white px-3 py-2 rounded-md text-sm font-medium"}>Settings</a>
</Link>
</div>
</div>
</div>
<div className="hidden md:block">
<div className="ml-4 flex items-center md:ml-6">
<div className="ml-3 relative">
<div>
<button onClick={toggleProfileDropdown} type="button" className="max-w-xs bg-gray-800 rounded-full flex items-center text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" id="user-menu" aria-expanded="false" aria-haspopup="true">
<span className="sr-only">Open user menu</span>
<img className="h-8 w-8 rounded-full" src={session.user.image ? session.user.image : "https://eu.ui-avatars.com/api/?background=fff&color=039be5&name=" + encodeURIComponent(session.user.name || "")} alt="" />
</button>
</div>
{
profileDropdownExpanded && (
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-50" role="menu" aria-orientation="vertical" aria-labelledby="user-menu">
<Link href={"/" + session.user.username}><a target="_blank" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Your Public Page</a></Link>
<Link href="/settings/profile"><a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Your Profile</a></Link>
<Link href="/settings/password"><a className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Login &amp; Security</a></Link>
<button onClick={logoutHandler} className="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Sign out</button>
</div>
)
}
</div>
</div>
</div>
<div className="-mr-2 flex md:hidden">
<button onClick={toggleMobileMenu} type="button" className=" inline-flex items-center justify-center p-2 rounded-md text-white focus:outline-none" aria-controls="mobile-menu" aria-expanded="false">
<span className="sr-only">Open main menu</span>
{ !mobileMenuExpanded && <MenuIcon className="block h-6 w-6" /> }
{ mobileMenuExpanded && <XIcon className="block h-6 w-6" /> }
</button>
</div>
</div>
</div>
</div>
{ mobileMenuExpanded && <div className="border-b border-gray-700 md:hidden bg-gray-700" id="mobile-menu">
<div className="px-2 py-3 space-y-1 sm:px-3">
<Link href="/">
<a className={router.pathname == "/" ? "bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Dashboard</a>
</Link>
<Link href="/availability">
<a className={router.pathname.startsWith("/availability") ? "bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Availability</a>
</Link>
<Link href="/integrations">
<a className={router.pathname.startsWith("/integrations") ? "bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Integrations</a>
</Link>
</div>
<div className="pt-4 pb-3 border-t border-gray-800">
<div className="flex items-center px-5">
<div className="flex-shrink-0">
<img className="h-10 w-10 rounded-full" src={"https://eu.ui-avatars.com/api/?background=039be5&color=fff&name=" + encodeURIComponent(session.user.name || session.user.username)} alt="" />
</div>
<div className="ml-3">
<div className="text-base font-medium leading-none text-white">{session.user.name || session.user.username}</div>
<div className="text-sm font-medium leading-none text-gray-400">{session.user.email}</div>
</div>
</div>
<div className="mt-3 px-2 space-y-1">
<Link href="/settings/profile">
<a className="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700">Your Profile</a>
</Link>
<Link href="/settings">
<a className={router.pathname.startsWith("/settings") ? "bg-gray-900 text-white block px-3 py-2 rounded-md text-base font-medium" : "text-gray-300 hover:bg-gray-700 hover:text-white block px-3 py-2 rounded-md text-base font-medium"}>Settings</a>
</Link>
<button onClick={logoutHandler} className="block w-full text-left px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700">Sign out</button>
</div>
</div>
</div>
}
</nav>
<header className="py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-white">
{props.heading}
</h1>
</div>
</header>
</div>
<main className="-mt-32">
<div className="max-w-7xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
{props.children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { hash, compare } from 'bcryptjs';
export async function hashPassword(password) {
const hashedPassword = await hash(password, 12);
return hashedPassword;
}
export async function verifyPassword(password, hashedPassword) {
const isValid = await compare(password, hashedPassword);
return isValid;
}

View File

@@ -0,0 +1,252 @@
const { google } = require("googleapis");
const credentials = process.env.GOOGLE_API_CREDENTIALS;
const googleAuth = () => {
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]);
};
function handleErrors(response) {
if (!response.ok) {
response.json().then(console.log);
throw Error(response.statusText);
}
return response.json();
}
const o365Auth = credential => {
const isExpired = expiryDate => expiryDate < +new Date();
const refreshAccessToken = refreshToken =>
fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
scope: "User.Read Calendars.Read Calendars.ReadWrite",
client_id: process.env.MS_GRAPH_CLIENT_ID,
refresh_token: refreshToken,
grant_type: "refresh_token",
client_secret: process.env.MS_GRAPH_CLIENT_SECRET,
}),
})
.then(handleErrors)
.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 {
getToken: () =>
!isExpired(credential.key.expiry_date)
? Promise.resolve(credential.key.access_token)
: refreshAccessToken(credential.key.refresh_token),
};
};
interface CalendarEvent {
title: string;
startTime: string;
timeZone: string;
endTime: string;
description?: string;
location?: string;
organizer: { name?: string; email: string };
attendees: { name?: string; email: string }[];
}
const MicrosoftOffice365Calendar = credential => {
const auth = o365Auth(credential);
const translateEvent = (event: CalendarEvent) => {
let optional = {};
if (event.location) {
optional.location = { displayName: event.location };
}
return {
subject: event.title,
body: {
contentType: "HTML",
content: event.description,
},
start: {
dateTime: event.startTime,
timeZone: event.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.timeZone,
},
attendees: event.attendees.map(attendee => ({
emailAddress: {
address: attendee.email,
name: attendee.name,
},
type: "required",
})),
...optional,
};
};
return {
getAvailability: (dateFrom, dateTo) => {
const payload = {
schedules: [credential.key.email],
startTime: {
dateTime: dateFrom,
timeZone: "UTC",
},
endTime: {
dateTime: dateTo,
timeZone: "UTC",
},
availabilityViewInterval: 60,
};
return auth
.getToken()
.then(accessToken =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/getSchedule", {
method: "post",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
.then(handleErrors)
.then(responseBody => {
return responseBody.value[0].scheduleItems.map(evt => ({
start: evt.start.dateTime + "Z",
end: evt.end.dateTime + "Z",
}));
}),
)
.catch(err => {
console.log(err);
});
},
createEvent: (event: CalendarEvent) =>
auth.getToken().then(accessToken =>
fetch("https://graph.microsoft.com/v1.0/me/calendar/events", {
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(translateEvent(event)),
}).then(handleErrors),
),
};
};
const GoogleCalendar = credential => {
const myGoogleAuth = googleAuth();
myGoogleAuth.setCredentials(credential.key);
return {
getAvailability: (dateFrom, dateTo) =>
new Promise((resolve, reject) => {
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
calendar.calendarList
.list()
.then(cals => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: cals.data.items,
},
},
(err, apires) => {
if (err) {
reject(err);
}
resolve(
Object.values(apires.data.calendars).flatMap(
item => item["busy"],
),
);
},
);
})
.catch(err => {
reject(err);
});
}),
createEvent: (event: CalendarEvent) =>
new Promise((resolve, reject) => {
const payload = {
summary: event.title,
description: event.description,
start: {
dateTime: event.startTime,
timeZone: event.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: false,
overrides: [{ method: "email", minutes: 60 }],
},
};
if (event.location) {
payload["location"] = event.location;
}
const calendar = google.calendar({ version: "v3", auth: myGoogleAuth });
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: "primary",
resource: payload,
},
function (err, event) {
if (err) {
console.log(
"There was an error contacting the Calendar service: " + err,
);
return reject(err);
}
return resolve(event.data);
},
);
}),
};
};
// factory
const calendars = (withCredentials): [] =>
withCredentials
.map(cred => {
switch (cred.type) {
case "google_calendar":
return GoogleCalendar(cred);
case "office365_calendar":
return MicrosoftOffice365Calendar(cred);
default:
return; // unknown credential, could be legacy? In any case, ignore
}
})
.filter(Boolean);
const getBusyTimes = (withCredentials, dateFrom, dateTo) =>
Promise.all(
calendars(withCredentials).map(c => c.getAvailability(dateFrom, dateTo)),
).then(results =>
results.reduce((acc, availability) => acc.concat(availability)),
);
const createEvent = (credential, evt: CalendarEvent) =>
calendars([credential])[0].createEvent(evt);
export { getBusyTimes, createEvent, CalendarEvent };

View File

@@ -0,0 +1,17 @@
export function getIntegrationName(name: String) {
switch(name) {
case "google_calendar":
return "Google Calendar";
case "office365_calendar":
return "Office 365 Calendar";
default:
return "Unknown";
}
}
export function getIntegrationType(name: String) {
if (name.endsWith('_calendar')) {
return 'Calendar';
}
return "Unknown";
}

View File

@@ -0,0 +1,6 @@
export enum LocationType {
InPerson = 'inPerson',
Phone = 'phone',
}

View File

@@ -0,0 +1,176 @@
function md5cycle(x, k) {
var a = x[0],
b = x[1],
c = x[2],
d = x[3];
a = ff(a, b, c, d, k[0], 7, -680876936);
d = ff(d, a, b, c, k[1], 12, -389564586);
c = ff(c, d, a, b, k[2], 17, 606105819);
b = ff(b, c, d, a, k[3], 22, -1044525330);
a = ff(a, b, c, d, k[4], 7, -176418897);
d = ff(d, a, b, c, k[5], 12, 1200080426);
c = ff(c, d, a, b, k[6], 17, -1473231341);
b = ff(b, c, d, a, k[7], 22, -45705983);
a = ff(a, b, c, d, k[8], 7, 1770035416);
d = ff(d, a, b, c, k[9], 12, -1958414417);
c = ff(c, d, a, b, k[10], 17, -42063);
b = ff(b, c, d, a, k[11], 22, -1990404162);
a = ff(a, b, c, d, k[12], 7, 1804603682);
d = ff(d, a, b, c, k[13], 12, -40341101);
c = ff(c, d, a, b, k[14], 17, -1502002290);
b = ff(b, c, d, a, k[15], 22, 1236535329);
a = gg(a, b, c, d, k[1], 5, -165796510);
d = gg(d, a, b, c, k[6], 9, -1069501632);
c = gg(c, d, a, b, k[11], 14, 643717713);
b = gg(b, c, d, a, k[0], 20, -373897302);
a = gg(a, b, c, d, k[5], 5, -701558691);
d = gg(d, a, b, c, k[10], 9, 38016083);
c = gg(c, d, a, b, k[15], 14, -660478335);
b = gg(b, c, d, a, k[4], 20, -405537848);
a = gg(a, b, c, d, k[9], 5, 568446438);
d = gg(d, a, b, c, k[14], 9, -1019803690);
c = gg(c, d, a, b, k[3], 14, -187363961);
b = gg(b, c, d, a, k[8], 20, 1163531501);
a = gg(a, b, c, d, k[13], 5, -1444681467);
d = gg(d, a, b, c, k[2], 9, -51403784);
c = gg(c, d, a, b, k[7], 14, 1735328473);
b = gg(b, c, d, a, k[12], 20, -1926607734);
a = hh(a, b, c, d, k[5], 4, -378558);
d = hh(d, a, b, c, k[8], 11, -2022574463);
c = hh(c, d, a, b, k[11], 16, 1839030562);
b = hh(b, c, d, a, k[14], 23, -35309556);
a = hh(a, b, c, d, k[1], 4, -1530992060);
d = hh(d, a, b, c, k[4], 11, 1272893353);
c = hh(c, d, a, b, k[7], 16, -155497632);
b = hh(b, c, d, a, k[10], 23, -1094730640);
a = hh(a, b, c, d, k[13], 4, 681279174);
d = hh(d, a, b, c, k[0], 11, -358537222);
c = hh(c, d, a, b, k[3], 16, -722521979);
b = hh(b, c, d, a, k[6], 23, 76029189);
a = hh(a, b, c, d, k[9], 4, -640364487);
d = hh(d, a, b, c, k[12], 11, -421815835);
c = hh(c, d, a, b, k[15], 16, 530742520);
b = hh(b, c, d, a, k[2], 23, -995338651);
a = ii(a, b, c, d, k[0], 6, -198630844);
d = ii(d, a, b, c, k[7], 10, 1126891415);
c = ii(c, d, a, b, k[14], 15, -1416354905);
b = ii(b, c, d, a, k[5], 21, -57434055);
a = ii(a, b, c, d, k[12], 6, 1700485571);
d = ii(d, a, b, c, k[3], 10, -1894986606);
c = ii(c, d, a, b, k[10], 15, -1051523);
b = ii(b, c, d, a, k[1], 21, -2054922799);
a = ii(a, b, c, d, k[8], 6, 1873313359);
d = ii(d, a, b, c, k[15], 10, -30611744);
c = ii(c, d, a, b, k[6], 15, -1560198380);
b = ii(b, c, d, a, k[13], 21, 1309151649);
a = ii(a, b, c, d, k[4], 6, -145523070);
d = ii(d, a, b, c, k[11], 10, -1120210379);
c = ii(c, d, a, b, k[2], 15, 718787259);
b = ii(b, c, d, a, k[9], 21, -343485551);
x[0] = add32(a, x[0]);
x[1] = add32(b, x[1]);
x[2] = add32(c, x[2]);
x[3] = add32(d, x[3]);
}
function cmn(q, a, b, x, s, t) {
a = add32(add32(a, q), add32(x, t));
return add32((a << s) | (a >>> (32 - s)), b);
}
function ff(a, b, c, d, x, s, t) {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a, b, c, d, x, s, t) {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a, b, c, d, x, s, t) {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a, b, c, d, x, s, t) {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function md51(s) {
let txt = "";
var n = s.length,
state = [1732584193, -271733879, -1732584194, 271733878],
i;
for (i = 64; i <= s.length; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)));
}
s = s.substring(i - 64);
var tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for (i = 0; i < s.length; i++)
tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3);
tail[i >> 2] |= 0x80 << (i % 4 << 3);
if (i > 55) {
md5cycle(state, tail);
for (i = 0; i < 16; i++) tail[i] = 0;
}
tail[14] = n * 8;
md5cycle(state, tail);
return state;
}
/* there needs to be support for Unicode here,
* unless we pretend that we can redefine the MD-5
* algorithm for multi-byte characters (perhaps
* by adding every four 16-bit characters and
* shortening the sum to 32 bits). Otherwise
* I suggest performing MD-5 as if every character
* was two bytes--e.g., 0040 0025 = @%--but then
* how will an ordinary MD-5 sum be matched?
* There is no way to standardize text to something
* like UTF-8 before transformation; speed cost is
* utterly prohibitive. The JavaScript standard
* itself needs to look at this: it should start
* providing access to strings as preformed UTF-8
* 8-bit unsigned value arrays.
*/
function md5blk(s) {
/* I figured global was faster. */
var md5blks = [],
i; /* Andy King said do it this way. */
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s.charCodeAt(i) +
(s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) +
(s.charCodeAt(i + 3) << 24);
}
return md5blks;
}
var hex_chr = "0123456789abcdef".split("");
function rhex(n) {
var s = "",
j = 0;
for (; j < 4; j++)
s += hex_chr[(n >> (j * 8 + 4)) & 0x0f] + hex_chr[(n >> (j * 8)) & 0x0f];
return s;
}
function hex(x) {
for (var i = 0; i < x.length; i++) x[i] = rhex(x[i]);
return x.join("");
}
function md5(s) {
return hex(md51(s));
}
function add32(a, b) {
return (a + b) & 0xffffffff;
}
export default md5;

View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
let prisma: PrismaClient;
const globalAny:any = global;
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient();
} else {
if (!globalAny.prisma) {
globalAny.prisma = new PrismaClient();
}
prisma = globalAny.prisma;
}
export default prisma;

View File

@@ -0,0 +1,94 @@
const dayjs = require("dayjs");
const isToday = require("dayjs/plugin/isToday");
const utc = require("dayjs/plugin/utc");
const timezone = require("dayjs/plugin/timezone");
dayjs.extend(isToday);
dayjs.extend(utc);
dayjs.extend(timezone);
const getMinutesFromMidnight = (date) => {
return date.hour() * 60 + date.minute();
};
const getSlots = ({
calendarTimeZone,
eventLength,
selectedTimeZone,
selectedDate,
dayStartTime,
dayEndTime
}) => {
if(!selectedDate) return []
const lowerBound = selectedDate.tz(selectedTimeZone).startOf("day");
// Simple case, same timezone
if (calendarTimeZone === selectedTimeZone) {
const slots = [];
const now = dayjs();
for (
let minutes = dayStartTime;
minutes <= dayEndTime - eventLength;
minutes += parseInt(eventLength, 10)
) {
const slot = lowerBound.add(minutes, "minutes");
if (slot > now) {
slots.push(slot);
}
}
return slots;
}
const upperBound = selectedDate.tz(selectedTimeZone).endOf("day");
// We need to start generating slots from the start of the calendarTimeZone day
const startDateTime = lowerBound
.tz(calendarTimeZone)
.startOf("day")
.add(dayStartTime, "minutes");
let phase = 0;
if (startDateTime < lowerBound) {
// Getting minutes of the first event in the day of the chooser
const diff = lowerBound.diff(startDateTime, "minutes");
// finding first event
phase = diff + eventLength - (diff % eventLength);
}
// We can stop as soon as the selectedTimeZone day ends
const endDateTime = upperBound
.tz(calendarTimeZone)
.subtract(eventLength, "minutes");
const maxMinutes = endDateTime.diff(startDateTime, "minutes");
const slots = [];
const now = dayjs();
for (
let minutes = phase;
minutes <= maxMinutes;
minutes += parseInt(eventLength, 10)
) {
const slot = startDateTime.add(minutes, "minutes");
const minutesFromMidnight = getMinutesFromMidnight(slot);
if (
minutesFromMidnight < dayStartTime ||
minutesFromMidnight > dayEndTime - eventLength ||
slot < now
) {
continue;
}
slots.push(slot.tz(selectedTimeZone));
}
return slots;
};
export default getSlots

View File

@@ -0,0 +1,97 @@
import React, {useContext} from 'react'
import {jitsuClient, JitsuClient} from "@jitsu/sdk-js";
/**
* Enumeration of all event types that are being sent
* to telemetry collection.
*/
export const telemetryEventTypes = {
pageView: 'page_view',
dateSelected: 'date_selected',
timeSelected: 'time_selected',
bookingConfirmed: 'booking_confirmed'
}
/**
* Telemetry client
*/
export type TelemetryClient = {
/**
* Use it as: withJitsu((jitsu) => {return jitsu.track()}). If telemetry is disabled, the callback will ignored
*
* ATTENTION: always return the value of jitsu.track() or id() call. Otherwise unhandled rejection can happen,
* which is handled in Next.js with a popup.
*/
withJitsu: (callback: (jitsu: JitsuClient) => void | Promise<void>) => void
}
const emptyClient: TelemetryClient = {withJitsu: () => {}};
function useTelemetry(): TelemetryClient {
return useContext(TelemetryContext);
}
function isLocalhost(host: string) {
return "localhost" === host || "127.0.0.1" === host;
}
/**
* Collects page parameters and makes sure no sensitive data made it to telemetry
* @param route current next.js route
*/
export function collectPageParameters(route?: string): any {
let host = document.location.hostname;
let maskedHost = isLocalhost(host) ? "localhost" : "masked";
//starts with ''
let docPath = route ?? "";
return {
page_url: route,
page_title: "",
source_ip: "",
url: document.location.protocol + "//" + host + (docPath ?? ""),
doc_host: maskedHost,
doc_search: "",
doc_path: docPath,
referer: "",
}
}
function createTelemetryClient(): TelemetryClient {
if (process.env.NEXT_PUBLIC_TELEMETRY_KEY) {
return {
withJitsu: (callback) => {
if (!process.env.NEXT_PUBLIC_TELEMETRY_KEY) {
//telemetry is disabled
return;
}
if (!window) {
console.warn("Jitsu has been called during SSR, this scenario isn't supported yet");
return;
} else if (!window['jitsu']) {
window['jitsu'] = jitsuClient({
log_level: 'ERROR',
tracking_host: "https://t.calendso.com",
key: process.env.NEXT_PUBLIC_TELEMETRY_KEY,
cookie_name: "__clnds",
capture_3rd_party_cookies: false,
});
}
let res = callback(window['jitsu']);
if (res && typeof res['catch'] === "function") {
res.catch(e => {
console.debug("Unable to send telemetry event", e)
});
}
}
}
} else {
return emptyClient;
}
}
const TelemetryContext = React.createContext<TelemetryClient>(emptyClient)
const TelemetryProvider = TelemetryContext.Provider
export { TelemetryContext, TelemetryProvider, createTelemetryClient, useTelemetry };

2
examples/calendso/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

View File

@@ -0,0 +1,34 @@
const withTM = require('next-transpile-modules')(['react-timezone-select']);
const validJson = (jsonString) => {
try {
const o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return o;
}
}
catch (e) {}
return false;
}
if (process.env.GOOGLE_API_CREDENTIALS && ! validJson(process.env.GOOGLE_API_CREDENTIALS)) {
console.warn('\x1b[33mwarn', '\x1b[0m', "- Disabled 'Google Calendar' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {\"web\":{\"client_id\":\"<clid>\",\"client_secret\":\"<secret>\",\"redirect_uris\":[\"<yourhost>/api/integrations/googlecalendar/callback>\"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials.");
}
module.exports = withTM({
future: {
webpack5: true,
},
typescript: {
ignoreBuildErrors: true,
},
async redirects() {
return [
{
source: '/settings',
destination: '/settings/profile',
permanent: true,
}
]
}
});

View File

@@ -0,0 +1,39 @@
{
"name": "calendso",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start --port ${PORT-3000}",
"postinstall": "prisma generate"
},
"dependencies": {
"@headlessui/react": "^1.0.0",
"@heroicons/react": "^1.0.1",
"@jitsu/sdk-js": "^2.0.1",
"@prisma/client": "2.23.0",
"@tailwindcss/forms": "^0.2.1",
"bcryptjs": "^2.4.3",
"dayjs": "^1.10.4",
"googleapis": "^67.1.1",
"ics": "^2.27.0",
"next": "^10.2.0",
"next-auth": "^3.13.2",
"next-transpile-modules": "^7.0.0",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-phone-number-input": "^3.1.21",
"react-select": "^4.3.0",
"react-timezone-select": "^1.0.2"
},
"devDependencies": {
"@types/node": "^14.14.33",
"@types/react": "^17.0.3",
"autoprefixer": "^10.2.5",
"postcss": "^8.2.8",
"prisma": "2.23.0",
"tailwindcss": "^2.0.3",
"typescript": "^4.2.3"
}
}

View File

@@ -0,0 +1,92 @@
import Head from 'next/head';
import Link from 'next/link';
import prisma from '../lib/prisma';
import Avatar from '../components/Avatar';
export default function User(props) {
const eventTypes = props.eventTypes.map(type =>
<li key={type.id}>
<Link href={'/' + props.user.username + '/' + type.slug}>
<a className="block px-6 py-4">
<div className="inline-block w-3 h-3 rounded-full mr-2" style={{backgroundColor:getRandomColorCode()}}></div>
<h2 className="inline-block font-medium">{type.title}</h2>
<p className="inline-block text-gray-400 ml-2">{type.description}</p>
</a>
</Link>
</li>
);
return (
<div>
<Head>
<title>{props.user.name || props.user.username} | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-2xl mx-auto my-24">
<div className="mb-8 text-center">
<Avatar user={props.user} className="mx-auto w-24 h-24 rounded-full mb-4" />
<h1 className="text-3xl font-semibold text-gray-800 mb-1">{props.user.name || props.user.username}</h1>
<p className="text-gray-600">{props.user.bio}</p>
</div>
<div className="bg-white shadow overflow-hidden rounded-md">
<ul className="divide-y divide-gray-200">
{eventTypes}
</ul>
{eventTypes.length == 0 &&
<div className="p-8 text-center text-gray-400">
<h2 className="font-semibold text-3xl text-gray-600">Uh oh!</h2>
<p className="max-w-md mx-auto">This user hasn't set up any event types yet.</p>
</div>
}
</div>
</main>
</div>
)
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
id: true,
username: true,
email:true,
name: true,
bio: true,
avatar: true,
eventTypes: true
}
});
if (!user) {
return {
notFound: true,
}
}
const eventTypes = await prisma.eventType.findMany({
where: {
userId: user.id,
hidden: false
}
});
return {
props: {
user,
eventTypes
},
}
}
// Auxiliary methods
export function getRandomColorCode() {
let color = '#';
for (let idx = 0; idx < 6; idx++) {
color += Math.floor(Math.random() * 10);
}
return color;
}

View File

@@ -0,0 +1,392 @@
import {useEffect, useState, useMemo} from 'react';
import Head from 'next/head';
import Link from 'next/link';
import prisma from '../../lib/prisma';
import { useRouter } from 'next/router';
import dayjs, { Dayjs } from 'dayjs';
import { Switch } from '@headlessui/react';
import TimezoneSelect from 'react-timezone-select';
import { ClockIcon, GlobeIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isBetween from 'dayjs/plugin/isBetween';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import Avatar from '../../components/Avatar';
dayjs.extend(isSameOrBefore);
dayjs.extend(isBetween);
dayjs.extend(utc);
dayjs.extend(timezone);
import getSlots from '../../lib/slots';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default function Type(props) {
// Initialise state
const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
const [loading, setLoading] = useState(false);
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [is24h, setIs24h] = useState(false);
const [busy, setBusy] = useState([]);
const telemetry = useTelemetry();
const [selectedTimeZone, setSelectedTimeZone] = useState('');
function toggleTimeOptions() {
setIsTimeOptionsOpen(!isTimeOptionsOpen);
}
useEffect(() => {
// Setting timezone only client-side
setSelectedTimeZone(dayjs.tz.guess())
}, [])
useEffect(() => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.pageView, collectPageParameters()))
})
// Get router variables
const router = useRouter();
const { user } = router.query;
// Handle month changes
const incrementMonth = () => {
setSelectedMonth(selectedMonth + 1);
}
const decrementMonth = () => {
setSelectedMonth(selectedMonth - 1);
}
// Need to define the bounds of the 24-hour window
const lowerBound = useMemo(() => {
if(!selectedDate) {
return
}
return selectedDate.startOf('day')
}, [selectedDate])
const upperBound = useMemo(() => {
if(!selectedDate) return
return selectedDate.endOf('day')
}, [selectedDate])
// Set up calendar
var daysInMonth = dayjs().month(selectedMonth).daysInMonth();
var days = [];
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
// Create placeholder elements for empty days in first week
const weekdayOfFirst = dayjs().month(selectedMonth).date(1).day();
const emptyDays = Array(weekdayOfFirst).fill(null).map((day, i) =>
<div key={`e-${i}`} className={"text-center w-10 h-10 rounded-full mx-auto"}>
{null}
</div>
);
// Combine placeholder days with actual days
const calendar = [...emptyDays, ...days.map((day) =>
<button key={day} onClick={(e) => {
telemetry.withJitsu((jitsu) => jitsu.track(telemetryEventTypes.dateSelected, collectPageParameters()))
setSelectedDate(dayjs().tz(selectedTimeZone).month(selectedMonth).date(day))
}} disabled={selectedMonth < parseInt(dayjs().format('MM')) && dayjs().month(selectedMonth).format("D") > day} className={"text-center w-10 h-10 rounded-full mx-auto " + (dayjs().isSameOrBefore(dayjs().date(day).month(selectedMonth)) ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-400 font-light') + (dayjs(selectedDate).month(selectedMonth).format("D") == day ? ' bg-blue-600 text-white-important' : '')}>
{day}
</button>
)];
// Handle date change and timezone change
useEffect(() => {
const changeDate = async () => {
if (!selectedDate) {
return
}
setLoading(true);
const res = await fetch(`/api/availability/${user}?dateFrom=${lowerBound.utc().format()}&dateTo=${upperBound.utc().format()}`);
const busyTimes = await res.json();
if (busyTimes.length > 0) setBusy(busyTimes);
setLoading(false);
}
changeDate();
}, [selectedDate, selectedTimeZone]);
const times = useMemo(() =>
getSlots({
calendarTimeZone: props.user.timeZone,
selectedTimeZone: selectedTimeZone,
eventLength: props.eventType.length,
selectedDate: selectedDate,
dayStartTime: props.user.startTime,
dayEndTime: props.user.endTime,
})
, [selectedDate, selectedTimeZone])
// Check for conflicts
for(let i = times.length - 1; i >= 0; i -= 1) {
busy.forEach(busyTime => {
let startTime = dayjs(busyTime.start);
let endTime = dayjs(busyTime.end);
// Check if start times are the same
if (dayjs(times[i]).format('HH:mm') == startTime.format('HH:mm')) {
times.splice(i, 1);
}
// Check if time is between start and end times
if (dayjs(times[i]).isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if slot end time is between start and end time
if (dayjs(times[i]).add(props.eventType.length, 'minutes').isBetween(startTime, endTime)) {
times.splice(i, 1);
}
// Check if startTime is between slot
if(startTime.isBetween(dayjs(times[i]), dayjs(times[i]).add(props.eventType.length, 'minutes'))) {
times.splice(i, 1);
}
});
}
// Display available times
const availableTimes = times.map((time) =>
<div key={dayjs(time).utc().format()}>
<Link href={`/${props.user.username}/book?date=${dayjs(time).utc().format()}&type=${props.eventType.id}`}>
<a key={dayjs(time).format("hh:mma")} className="block font-medium mb-4 text-blue-600 border border-blue-600 rounded hover:text-white hover:bg-blue-600 py-4">{dayjs(time).tz(selectedTimeZone).format(is24h ? "HH:mm" : "hh:mma")}</a>
</Link>
</div>
);
return (
<div>
<Head>
<title>
{props.eventType.title} | {props.user.name || props.user.username} |
Calendso
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main
className={
"mx-auto my-24 transition-max-width ease-in-out duration-500 " +
(selectedDate ? "max-w-6xl" : "max-w-3xl")
}
>
<div className="bg-white shadow rounded-lg">
<div className="sm:flex px-4 py-5 sm:p-4">
<div
className={
"pr-8 sm:border-r " + (selectedDate ? "sm:w-1/3" : "sm:w-1/2")
}
>
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
<h2 className="font-medium text-gray-500">{props.user.name}</h2>
<h1 className="text-3xl font-semibold text-gray-800 mb-4">
{props.eventType.title}
</h1>
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
<button
onClick={toggleTimeOptions}
className="text-gray-500 mb-1 px-2 py-1 -ml-2"
>
<GlobeIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{selectedTimeZone}
<ChevronDownIcon className="inline-block w-4 h-4 ml-1 -mt-1" />
</button>
{isTimeOptionsOpen && (
<div className="w-full rounded shadow border bg-white px-4 py-2">
<div className="flex mb-4">
<div className="w-1/2 font-medium">Time Options</div>
<div className="w-1/2">
<Switch.Group
as="div"
className="flex items-center justify-end"
>
<Switch.Label as="span" className="mr-3">
<span className="text-sm text-gray-500">am/pm</span>
</Switch.Label>
<Switch
checked={is24h}
onChange={setIs24h}
className={classNames(
is24h ? "bg-blue-600" : "bg-gray-200",
"relative inline-flex flex-shrink-0 h-5 w-8 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
)}
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className={classNames(
is24h ? "translate-x-3" : "translate-x-0",
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm text-gray-500">24h</span>
</Switch.Label>
</Switch.Group>
</div>
</div>
<TimezoneSelect
id="timeZone"
value={selectedTimeZone}
onChange={({ value }) => setSelectedTimeZone(value)}
className="mb-2 shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
)}
<p className="text-gray-600 mt-3 mb-8">
{props.eventType.description}
</p>
</div>
<div
className={
"mt-8 sm:mt-0 " +
(selectedDate
? "sm:w-1/3 border-r sm:px-4"
: "sm:w-1/2 sm:pl-4")
}
>
<div className="flex text-gray-600 font-light text-xl mb-4 ml-2">
<span className="w-1/2">
{dayjs().month(selectedMonth).format("MMMM YYYY")}
</span>
<div className="w-1/2 text-right">
<button
onClick={decrementMonth}
className={
"mr-4 " +
(selectedMonth < parseInt(dayjs().format("MM")) &&
"text-gray-400")
}
disabled={selectedMonth < parseInt(dayjs().format("MM"))}
>
<ChevronLeftIcon className="w-5 h-5" />
</button>
<button onClick={incrementMonth}>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-y-4 text-center">
<div className="uppercase text-gray-400 text-xs tracking-widest">
Sun
</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">
Mon
</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">
Tue
</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">
Wed
</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">
Thu
</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">
Fri
</div>
<div className="uppercase text-gray-400 text-xs tracking-widest">
Sat
</div>
{calendar}
</div>
</div>
{selectedDate && (
<div className="sm:pl-4 mt-8 sm:mt-0 text-center sm:w-1/3 md:max-h-97 overflow-y-auto">
<div className="text-gray-600 font-light text-xl mb-4 text-left">
<span className="w-1/2">
{dayjs(selectedDate).format("dddd DD MMMM YYYY")}
</span>
</div>
{!loading ? availableTimes : <div className="loader"></div>}
</div>
)}
</div>
</div>
{/* note(peer):
you can remove calendso branding here, but we'd also appreciate it, if you don't <3
*/}
<div className="text-xs text-right pt-1">
<Link href="https://calendso.com">
<a
style={{ color: "#104D86" }}
className="opacity-50 hover:opacity-100"
>
powered by{" "}
<img
style={{ top: -2 }}
className="w-auto inline h-3 relative"
src="/calendso-logo-word.svg"
alt="Calendso Logo"
/>
</a>
</Link>
</div>
</main>
</div>
);
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
eventTypes: true,
startTime: true,
timeZone: true,
endTime: true
}
});
if (!user) {
return {
notFound: true,
}
}
const eventType = await prisma.eventType.findFirst({
where: {
userId: user.id,
slug: {
equals: context.query.type,
},
},
select: {
id: true,
title: true,
description: true,
length: true
}
});
return {
props: {
user,
eventType
},
}
}

View File

@@ -0,0 +1,184 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid';
import prisma from '../../lib/prisma';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
import { useEffect, useState } from "react";
import dayjs from 'dayjs';
import 'react-phone-number-input/style.css';
import PhoneInput from 'react-phone-number-input';
import { LocationType } from '../../lib/location';
import Avatar from '../../components/Avatar';
export default function Book(props) {
const router = useRouter();
const { date, user } = router.query;
const locations = props.eventType.locations || [];
const [ selectedLocation, setSelectedLocation ] = useState<LocationType>(locations.length === 1 ? locations[0].type : '');
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
});
const locationInfo = (type: LocationType) => locations.find(
(location) => location.type === type
);
// TODO: Move to translations
const locationLabels = {
[LocationType.InPerson]: 'In-person meeting',
[LocationType.Phone]: 'Phone call',
};
const bookingHandler = event => {
event.preventDefault();
let payload = {
start: dayjs(date).format(),
end: dayjs(date).add(props.eventType.length, 'minute').format(),
name: event.target.name.value,
email: event.target.email.value,
notes: event.target.notes.value
};
if (selectedLocation) {
payload['location'] = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address;
}
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
const res = fetch(
'/api/book/' + user,
{
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
},
method: 'POST'
}
);
let successUrl = `/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}`;
if (payload['location']) {
successUrl += "&location=" + encodeURIComponent(payload['location']);
}
router.push(successUrl);
}
return (
<div>
<Head>
<title>Confirm your {props.eventType.title} with {props.user.name || props.user.username} | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="sm:flex px-4 py-5 sm:p-6">
<div className="sm:w-1/2 sm:border-r">
<Avatar user={props.user} className="w-16 h-16 rounded-full mb-4" />
<h2 className="font-medium text-gray-500">{props.user.name}</h2>
<h1 className="text-3xl font-semibold text-gray-800 mb-4">{props.eventType.title}</h1>
<p className="text-gray-500 mb-2">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
{selectedLocation === LocationType.InPerson && <p className="text-gray-500 mb-2">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{locationInfo(selectedLocation).address}
</p>}
<p className="text-blue-600 mb-4">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")}
</p>
<p className="text-gray-600">{props.eventType.description}</p>
</div>
<div className="sm:w-1/2 pl-8 pr-4">
<form onSubmit={bookingHandler}>
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Your name</label>
<div className="mt-1">
<input type="text" name="name" id="name" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="John Doe" />
</div>
</div>
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email address</label>
<div className="mt-1">
<input type="email" name="email" id="email" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="you@example.com" />
</div>
</div>
{locations.length > 1 && (
<div className="mb-4">
<span className="block text-sm font-medium text-gray-700">Location</span>
{locations.map( (location) => (
<label key={location.type} className="block">
<input type="radio" required onChange={(e) => setSelectedLocation(e.target.value)} className="location" name="location" value={location.type} checked={selectedLocation === location.type} />
<span className="text-sm ml-2">{locationLabels[location.type]}</span>
</label>
))}
</div>
)}
{selectedLocation === LocationType.Phone && (<div className="mb-4">
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">Phone Number</label>
<div className="mt-1">
<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 className="mb-4">
<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."></textarea>
</div>
<div>
<button type="submit" className="btn btn-primary">Confirm</button>
<Link href={"/" + props.user.username + "/" + props.eventType.slug}>
<a className="ml-2 btn btn-white">Cancel</a>
</Link>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
)
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
username: true,
name: true,
email:true,
bio: true,
avatar: true,
eventTypes: true
}
});
const eventType = await prisma.eventType.findUnique({
where: {
id: parseInt(context.query.type),
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
locations: true,
}
});
return {
props: {
user,
eventType
},
}
}

View File

@@ -0,0 +1,15 @@
import '../styles/globals.css';
import {createTelemetryClient, TelemetryProvider} from '../lib/telemetry';
import { Provider } from 'next-auth/client';
function MyApp({ Component, pageProps }) {
return (
<TelemetryProvider value={createTelemetryClient()}>
<Provider session={pageProps.session}>
<Component {...pageProps} />
</Provider>
</TelemetryProvider>
);
}
export default MyApp;

View File

@@ -0,0 +1,59 @@
import NextAuth from 'next-auth';
import Providers from 'next-auth/providers';
import prisma from '../../../lib/prisma';
import {verifyPassword} from "../../../lib/auth";
export default NextAuth({
session: {
jwt: true
},
pages: {
signIn: '/auth/login',
signOut: '/auth/logout',
error: '/auth/error', // Error code passed in query string as ?error=
},
providers: [
Providers.Credentials({
name: 'Calendso',
credentials: {
email: { label: "Email Address", type: "email", placeholder: "john.doe@example.com" },
password: { label: "Password", type: "password", placeholder: "Your super secure password" }
},
async authorize(credentials) {
const user = await prisma.user.findFirst({
where: {
email: credentials.email
}
});
if (!user) {
throw new Error('No user found');
}
const isValid = await verifyPassword(credentials.password, user.password);
if (!isValid) {
throw new Error('Incorrect password');
}
return {id: user.id, username: user.username, email: user.email, name: user.name, image: user.avatar};
}
})
],
callbacks: {
async jwt(token, user, account, profile, isNewUser) {
// Add username to the token right after signin
if (user?.username) {
token.id = user.id;
token.username = user.username;
}
return token;
},
async session(session, token) {
session.user = session.user || {}
session.user.id = token.id;
session.user.username = token.username;
return session;
},
},
});

View File

@@ -0,0 +1,46 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { hashPassword, verifyPassword } from '../../../lib/auth';
import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req});
if (!session) {
res.status(401).json({message: "Not authenticated"});
return;
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
password: true
}
});
if (!user) { res.status(404).json({message: 'User not found'}); return; }
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const currentPassword = user.password;
const passwordsMatch = await verifyPassword(oldPassword, currentPassword);
if (!passwordsMatch) { res.status(403).json({message: 'Incorrect password'}); return; }
const hashedPassword = await hashPassword(newPassword);
const updateUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
password: hashedPassword,
},
});
res.status(200).json({message: 'Password updated successfully'});
}

View File

@@ -0,0 +1,56 @@
import prisma from '../../../lib/prisma';
import { hashPassword } from "../../../lib/auth";
export default async function handler(req, res) {
if (req.method !== 'POST') {
return;
}
const data = req.body;
const { username, email, password } = data;
if (!username) {
res.status(422).json({message: 'Invalid username'});
return;
}
if (!email || !email.includes('@')) {
res.status(422).json({message: 'Invalid email'});
return;
}
if (!password || password.trim().length < 7) {
res.status(422).json({message: 'Invalid input - password should be at least 7 characters long.'});
return;
}
const existingUser = await prisma.user.findFirst({
where: {
OR: [
{
username: username
},
{
email: email
}
]
}
});
if (existingUser) {
res.status(422).json({message: 'A user exists with that username or email address'});
return;
}
const hashedPassword = await hashPassword(password);
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword
}
});
res.status(201).json({message: 'Created user'});
}

View File

@@ -0,0 +1,20 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../../lib/prisma';
import { getBusyTimes } from '../../../lib/calendarClient';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query
const currentUser = await prisma.user.findFirst({
where: {
username: user,
},
select: {
credentials: true,
timeZone: true
}
});
const availability = await getBusyTimes(currentUser.credentials, req.query.dateFrom, req.query.dateTo);
res.status(200).json(availability);
}

View File

@@ -0,0 +1,29 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req});
if (!session) {
res.status(401).json({message: "Not authenticated"});
return;
}
if (req.method == "PATCH") {
const startMins = req.body.start;
const endMins = req.body.end;
const updateDay = await prisma.user.update({
where: {
id: session.user.id,
},
data: {
startTime: startMins,
endTime: endMins
},
});
res.status(200).json({message: 'Start and end times updated successfully'});
}
}

View File

@@ -0,0 +1,53 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req});
if (!session) {
res.status(401).json({message: "Not authenticated"});
return;
}
if (req.method == "PATCH" || req.method == "POST") {
const data = {
title: req.body.title,
slug: req.body.slug,
description: req.body.description,
length: parseInt(req.body.length),
hidden: req.body.hidden,
locations: req.body.locations,
};
if (req.method == "POST") {
const createEventType = await prisma.eventType.create({
data: {
userId: session.user.id,
...data,
},
});
res.status(200).json({message: 'Event created successfully'});
}
else if (req.method == "PATCH") {
const updateEventType = await prisma.eventType.update({
where: {
id: req.body.id,
},
data,
});
res.status(200).json({message: 'Event updated successfully'});
}
}
if (req.method == "DELETE") {
const deleteEventType = await prisma.eventType.delete({
where: {
id: req.body.id,
},
});
res.status(200).json({message: 'Event deleted successfully'});
}
}

View File

@@ -0,0 +1,33 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../../lib/prisma';
import { createEvent, CalendarEvent } from '../../../lib/calendarClient';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { user } = req.query;
const currentUser = await prisma.user.findFirst({
where: {
username: user,
},
select: {
credentials: true,
timeZone: true,
}
});
const evt: CalendarEvent = {
title: 'Meeting with ' + req.body.name,
description: req.body.notes,
startTime: req.body.start,
endTime: req.body.end,
timeZone: currentUser.timeZone,
location: req.body.location,
attendees: [
{ email: req.body.email, name: req.body.name }
]
};
// TODO: for now, first integration created; primary = obvious todo; ability to change primary.
const result = await createEvent(currentUser.credentials[0], evt);
res.status(200).json(result);
}

View File

@@ -0,0 +1,39 @@
import prisma from '../../lib/prisma';
import { getSession } from 'next-auth/client';
export default async function handler(req, res) {
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; }
const credentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
},
select: {
type: true,
key: true
}
});
res.status(200).json(credentials);
}
if (req.method == "DELETE") {
const session = await getSession({req: req});
if (!session) { res.status(401).json({message: 'You must be logged in to do this'}); return; }
const id = req.body.id;
const deleteIntegration = await prisma.credential.delete({
where: {
id: id,
},
});
res.status(200).json({message: 'Integration deleted successfully'});
}
}

View File

@@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const {google} = require('googleapis');
const credentials = process.env.GOOGLE_API_CREDENTIALS;
const scopes = ['https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/calendar'];
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
}
});
// Get token from Google Calendar API
const {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: scopes,
// A refresh token is only returned the first time the user
// consents to providing access. For illustration purposes,
// setting the prompt to 'consent' will force this consent
// every time, forcing a refresh_token to be returned.
prompt: 'consent',
});
res.status(200).json({url: authUrl});
}
}

View File

@@ -0,0 +1,34 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const {google} = require('googleapis');
const credentials = process.env.GOOGLE_API_CREDENTIALS;
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 {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
// Convert to token
return new Promise( (resolve, reject) => oAuth2Client.getToken(code, async (err, token) => {
if (err) return console.error('Error retrieving access token', err);
const credential = await prisma.credential.create({
data: {
type: 'google_calendar',
key: token,
userId: session.user.id
}
});
res.redirect('/integrations');
resolve();
}));
}

View File

@@ -0,0 +1,35 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const scopes = ['User.Read', 'Calendars.Read', 'Calendars.ReadWrite'];
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 hostname = 'x-forwarded-host' in req.headers ? 'https://' + req.headers['x-forwarded-host'] : 'host' in req.headers ? (req.secure ? 'https://' : 'http://') + req.headers['host'] : '';
if ( ! hostname || ! req.headers.referer.startsWith(hostname)) {
throw new Error('Unable to determine external url, check server settings');
}
function generateAuthUrl() {
return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=code&scope=' + scopes.join(' ') + '&client_id=' + process.env.MS_GRAPH_CLIENT_ID + '&redirect_uri=' + hostname + '/api/integrations/office365calendar/callback';
}
res.status(200).json({url: generateAuthUrl() });
}
}

View File

@@ -0,0 +1,44 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../../lib/prisma';
const scopes = ['offline_access', 'Calendars.Read', 'Calendars.ReadWrite'];
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 toUrlEncoded = payload => Object.keys(payload).map( (key) => key + '=' + encodeURIComponent(payload[ key ]) ).join('&');
const hostname = 'x-forwarded-host' in req.headers ? 'https://' + req.headers['x-forwarded-host'] : 'host' in req.headers ? (req.secure ? 'https://' : 'http://') + req.headers['host'] : '';
const body = toUrlEncoded({ client_id: process.env.MS_GRAPH_CLIENT_ID, grant_type: 'authorization_code', code, scope: scopes.join(' '), redirect_uri: hostname + '/api/integrations/office365calendar/callback', client_secret: process.env.MS_GRAPH_CLIENT_SECRET });
const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { method: 'POST', headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
}, body });
const responseBody = await response.json();
if (!response.ok) {
return res.redirect('/integrations?error=' + JSON.stringify(responseBody));
}
const whoami = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { 'Authorization': 'Bearer ' + responseBody.access_token } });
const graphUser = await whoami.json();
responseBody.email = graphUser.mail;
responseBody.expiry_date = Math.round((+(new Date()) / 1000) + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in;
const credential = await prisma.credential.create({
data: {
type: 'office365_calendar',
key: responseBody,
userId: session.user.id
}
});
return res.redirect('/integrations');
}

View File

@@ -0,0 +1,46 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSession } from 'next-auth/client';
import prisma from '../../../lib/prisma';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req});
if (!session) {
res.status(401).json({message: "Not authenticated"});
return;
}
// Get user
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
password: true
}
});
if (!user) { res.status(404).json({message: 'User not found'}); return; }
const username = req.body.username;
const name = req.body.name;
const description = req.body.description;
const avatar = req.body.avatar;
const timeZone = req.body.timeZone;
const updateUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
username,
name,
avatar,
bio: description,
timeZone: timeZone,
},
});
res.status(200).json({message: 'Profile updated successfully'});
}

View File

@@ -0,0 +1,45 @@
import { useRouter } from 'next/router';
import { XIcon } from '@heroicons/react/outline';
import Head from 'next/head';
import Link from 'next/link';
export default function Error() {
const router = useRouter();
const { error } = router.query;
return (
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<Head>
<title>{error} - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
<XIcon className="h-6 w-6 text-red-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
{error}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
An error occurred when logging you in. Head back to the login screen and try again.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import Head from 'next/head';
import { getCsrfToken } from 'next-auth/client';
export default function Login({ csrfToken }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<Head>
<title>Login</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" method="post" action="/api/auth/callback/credentials">
<input name='csrfToken' type='hidden' defaultValue={csrfToken} hidden/>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input id="email" name="email" type="email" autoComplete="email" placeholder="john.doe@example.com" required className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input id="password" name="password" type="password" autoComplete="current-password" placeholder="•••••••••••••" required className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" />
</div>
</div>
<div>
<button type="submit" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Sign in
</button>
</div>
</form>
</div>
</div>
</div>
)
}
Login.getInitialProps = async ({ req, res }) => {
return {
csrfToken: await getCsrfToken({ req })
}
}

View File

@@ -0,0 +1,41 @@
import Head from 'next/head';
import Link from 'next/link';
import { CheckIcon } from '@heroicons/react/outline';
export default function Logout() {
return (
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<Head>
<title>Logged out - Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
You've been logged out
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
We hope to see you again soon!
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6">
<Link href="/auth/login">
<a className="inline-flex justify-center w-full rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:text-sm">
Go back to the login page
</a>
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,355 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useRef, useState } from 'react';
import Select, { OptionBase } from 'react-select';
import prisma from '../../../lib/prisma';
import { LocationType } from '../../../lib/location';
import Shell from '../../../components/Shell';
import { useSession, getSession } from 'next-auth/client';
import { LocationMarkerIcon, PlusCircleIcon, XIcon, PhoneIcon } from '@heroicons/react/outline';
export default function EventType(props) {
const router = useRouter();
const [ session, loading ] = useSession();
const [ showLocationModal, setShowLocationModal ] = useState(false);
const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined);
const [ locations, setLocations ] = useState(props.eventType.locations || []);
const titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>();
const descriptionRef = useRef<HTMLTextAreaElement>();
const lengthRef = useRef<HTMLInputElement>();
const isHiddenRef = useRef<HTMLInputElement>();
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
async function updateEventTypeHandler(event) {
event.preventDefault();
const enteredTitle = titleRef.current.value;
const enteredSlug = slugRef.current.value;
const enteredDescription = descriptionRef.current.value;
const enteredLength = lengthRef.current.value;
const enteredIsHidden = isHiddenRef.current.checked;
// TODO: Add validation
const response = await fetch('/api/availability/eventtype', {
method: 'PATCH',
body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations }),
headers: {
'Content-Type': 'application/json'
}
});
router.push('/availability');
}
async function deleteEventTypeHandler(event) {
event.preventDefault();
const response = await fetch('/api/availability/eventtype', {
method: 'DELETE',
body: JSON.stringify({id: props.eventType.id}),
headers: {
'Content-Type': 'application/json'
}
});
router.push('/availability');
}
// TODO: Tie into translations instead of abstracting to locations.ts
const locationOptions: OptionBase[] = [
{ value: LocationType.InPerson, label: 'In-person meeting' },
{ value: LocationType.Phone, label: 'Phone call', },
];
const openLocationModal = (type: LocationType) => {
setSelectedLocation(locationOptions.find( (option) => option.value === type));
setShowLocationModal(true);
}
const closeLocationModal = () => {
setSelectedLocation(undefined);
setShowLocationModal(false);
};
const LocationOptions = () => {
if (!selectedLocation) {
return null;
}
switch (selectedLocation.value) {
case LocationType.InPerson:
const address = locations.find(
(location) => location.type === LocationType.InPerson
)?.address;
return (
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">Set an address or place</label>
<div className="mt-1">
<input type="text" name="address" id="address" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" defaultValue={address} />
</div>
</div>
)
case LocationType.Phone:
return (
<p className="text-sm">Calendso will ask your invitee to enter a phone number before scheduling.</p>
)
}
return null;
};
const updateLocations = (e) => {
e.preventDefault();
let details = {};
if (e.target.location.value === LocationType.InPerson) {
details = { address: e.target.address.value };
}
const existingIdx = locations.findIndex( (loc) => e.target.location.value === loc.type );
if (existingIdx !== -1) {
let copy = locations;
copy[ existingIdx ] = { ...locations[ existingIdx ], ...details };
setLocations(copy);
} else {
setLocations(locations.concat({ type: e.target.location.value, ...details }));
}
setShowLocationModal(false);
};
const removeLocation = (selectedLocation) => {
setLocations(locations.filter( (location) => location.type !== selectedLocation.type ));
};
return (
<div>
<Head>
<title>{props.eventType.title} | Event Type | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading={'Event Type - ' + props.eventType.title}>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<form onSubmit={updateEventTypeHandler}>
<div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label>
<div className="mt-1">
<input ref={titleRef} type="text" name="title" id="title" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Quick Chat" defaultValue={props.eventType.title} />
</div>
</div>
<div className="mb-4">
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label>
<div className="mt-1">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
{location.hostname}/{props.user.username}/
</span>
<input
ref={slugRef}
type="text"
name="slug"
id="slug"
required
className="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"
defaultValue={props.eventType.slug}
/>
</div>
</div>
</div>
<div className="mb-4">
<label htmlFor="location" className="block text-sm font-medium text-gray-700">Location</label>
{locations.length === 0 && <div className="mt-1 mb-2">
<div className="flex rounded-md shadow-sm">
<Select
name="location"
id="location"
options={locationOptions}
isSearchable="false"
className="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"
onChange={(e) => openLocationModal(e.value)}
/>
</div>
</div>}
{locations.length > 0 && <ul className="w-96 mt-1">
{locations.map( (location) => (
<li key={location.type} className="bg-blue-50 mb-2 p-2 border">
<div className="flex justify-between">
{location.type === LocationType.InPerson && (
<div className="flex-grow flex">
<LocationMarkerIcon className="h-6 w-6" />
<span className="ml-2 text-sm">{location.address}</span>
</div>
)}
{location.type === LocationType.Phone && (
<div className="flex-grow flex">
<PhoneIcon className="h-6 w-6" />
<span className="ml-2 text-sm">Phone call</span>
</div>
)}
<div className="flex">
<button type="button" onClick={() => openLocationModal(location.type)} className="mr-2 text-sm text-blue-600">Edit</button>
<button onClick={() => removeLocation(location)}>
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 " />
</button>
</div>
</div>
</li>
))}
{locations.length > 0 && locations.length !== locationOptions.length && <li>
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowLocationModal(true)}>
<PlusCircleIcon className="h-6 w-6" />
<span className="ml-1">Add another location option</span>
</button>
</li>}
</ul>}
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label>
<div className="mt-1">
<textarea ref={descriptionRef} name="description" id="description" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="A quick video meeting." defaultValue={props.eventType.description}></textarea>
</div>
</div>
<div className="mb-4">
<label htmlFor="length" className="block text-sm font-medium text-gray-700">Length</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input ref={lengthRef} type="number" name="length" id="length" required className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" placeholder="15" defaultValue={props.eventType.length} />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
minutes
</div>
</div>
</div>
<div className="my-8">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
ref={isHiddenRef}
id="ishidden"
name="ishidden"
type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
defaultChecked={props.eventType.hidden}
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="ishidden" className="font-medium text-gray-700">
Hide this event type
</label>
<p className="text-gray-500">Hide the event type from your page, so it can only be booked through it's URL.</p>
</div>
</div>
</div>
<button type="submit" className="btn btn-primary">Update</button>
<Link href="/availability"><a className="ml-2 btn btn-white">Cancel</a></Link>
</form>
</div>
</div>
</div>
<div>
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Delete this event type
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>
Once you delete this event type, it will be permanently removed.
</p>
</div>
<div className="mt-5">
<button onClick={deleteEventTypeHandler} type="button" className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm">
Delete event type
</button>
</div>
</div>
</div>
</div>
</div>
{showLocationModal &&
<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"></div>
<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">
<LocationMarkerIcon 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">Edit location</h3>
</div>
</div>
<form onSubmit={updateLocations}>
<Select
name="location"
defaultValue={selectedLocation}
options={locationOptions}
isSearchable="false"
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"
onChange={setSelectedLocation}
/>
<LocationOptions />
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Update
</button>
<button onClick={closeLocationModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
</Shell>
</div>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: '/auth/login' } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
username: true
}
});
const eventType = await prisma.eventType.findUnique({
where: {
id: parseInt(context.query.type),
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true,
locations: true,
}
});
return {
props: {
user,
eventType
},
}
}

View File

@@ -0,0 +1,384 @@
import Head from 'next/head';
import Link from 'next/link';
import prisma from '../../lib/prisma';
import Modal from '../../components/Modal';
import Shell from '../../components/Shell';
import { useRouter } from 'next/router';
import { useRef } from 'react';
import { useState } from 'react';
import { useSession, getSession } from 'next-auth/client';
import { PlusIcon, ClockIcon } from '@heroicons/react/outline';
export default function Availability(props) {
const [ session, loading ] = useSession();
const router = useRouter();
const [showAddModal, setShowAddModal] = useState(false);
const [successModalOpen, setSuccessModalOpen] = useState(false);
const [showChangeTimesModal, setShowChangeTimesModal] = useState(false);
const titleRef = useRef<HTMLInputElement>();
const slugRef = useRef<HTMLInputElement>();
const descriptionRef = useRef<HTMLTextAreaElement>();
const lengthRef = useRef<HTMLInputElement>();
const isHiddenRef = useRef<HTMLInputElement>();
const startHoursRef = useRef<HTMLInputElement>();
const startMinsRef = useRef<HTMLInputElement>();
const endHoursRef = useRef<HTMLInputElement>();
const endMinsRef = useRef<HTMLInputElement>();
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
function toggleAddModal() {
setShowAddModal(!showAddModal);
}
function toggleChangeTimesModal() {
setShowChangeTimesModal(!showChangeTimesModal);
}
const closeSuccessModal = () => { setSuccessModalOpen(false); router.replace(router.asPath); }
function convertMinsToHrsMins (mins) {
let h = Math.floor(mins / 60);
let m = mins % 60;
h = h < 10 ? '0' + h : h;
m = m < 10 ? '0' + m : m;
return `${h}:${m}`;
}
async function createEventTypeHandler(event) {
event.preventDefault();
const enteredTitle = titleRef.current.value;
const enteredSlug = slugRef.current.value;
const enteredDescription = descriptionRef.current.value;
const enteredLength = lengthRef.current.value;
const enteredIsHidden = isHiddenRef.current.checked;
// TODO: Add validation
const response = await fetch('/api/availability/eventtype', {
method: 'POST',
body: JSON.stringify({title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden}),
headers: {
'Content-Type': 'application/json'
}
});
if (enteredTitle && enteredLength) {
router.replace(router.asPath);
toggleAddModal();
}
}
async function updateStartEndTimesHandler(event) {
event.preventDefault();
const enteredStartHours = parseInt(startHoursRef.current.value);
const enteredStartMins = parseInt(startMinsRef.current.value);
const enteredEndHours = parseInt(endHoursRef.current.value);
const enteredEndMins = parseInt(endMinsRef.current.value);
const startMins = enteredStartHours * 60 + enteredStartMins;
const endMins = enteredEndHours * 60 + enteredEndMins;
// TODO: Add validation
const response = await fetch('/api/availability/day', {
method: 'PATCH',
body: JSON.stringify({start: startMins, end: endMins}),
headers: {
'Content-Type': 'application/json'
}
});
setShowChangeTimesModal(false);
setSuccessModalOpen(true);
}
return(
<div>
<Head>
<title>Availability | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading="Availability">
<div className="mb-4 sm:flex sm:items-center sm:justify-between">
<h3 className="text-lg leading-6 font-medium text-white">
Event Types
</h3>
<div className="mt-3 sm:mt-0 sm:ml-4">
<button onClick={toggleAddModal} type="button" className="btn-sm btn-white">
New event type
</button>
</div>
</div>
<div className="flex flex-col mb-8">
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Length
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{props.types.map((eventType) =>
<tr>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{eventType.title}
{eventType.hidden &&
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Hidden
</span>
}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{eventType.description}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{eventType.length} minutes
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link href={"/" + props.user.username + "/" + eventType.slug}><a target="_blank" className="text-blue-600 hover:text-blue-900 mr-2">View</a></Link>
<Link href={"/availability/event/" + eventType.id}><a className="text-blue-600 hover:text-blue-900">Edit</a></Link>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Change the start and end times of your day
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>
Currently, your day is set to start at {convertMinsToHrsMins(props.user.startTime)} and end at {convertMinsToHrsMins(props.user.endTime)}.
</p>
</div>
<div className="mt-5">
<button onClick={toggleChangeTimesModal} type="button" className="btn btn-primary">
Change available times
</button>
</div>
</div>
</div>
{showAddModal &&
<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"></div>
<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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<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 a new event type
</h3>
<div>
<p className="text-sm text-gray-500">
Create a new event type for people to book times with.
</p>
</div>
</div>
</div>
<form onSubmit={createEventTypeHandler}>
<div>
<div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700">Title</label>
<div className="mt-1">
<input ref={titleRef} type="text" name="title" id="title" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Quick Chat" />
</div>
</div>
<div className="mb-4">
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">URL</label>
<div className="mt-1">
<div className="flex rounded-md shadow-sm">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500 sm:text-sm">
{location.hostname}/{props.user.username}/
</span>
<input
ref={slugRef}
type="text"
name="slug"
id="slug"
required
className="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"
/>
</div>
</div>
</div>
<div className="mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Description</label>
<div className="mt-1">
<textarea ref={descriptionRef} name="description" id="description" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="A quick video meeting."></textarea>
</div>
</div>
<div className="mb-4">
<label htmlFor="length" className="block text-sm font-medium text-gray-700">Length</label>
<div className="mt-1 relative rounded-md shadow-sm">
<input ref={lengthRef} type="number" name="length" id="length" required className="focus:ring-blue-500 focus:border-blue-500 block w-full pr-20 sm:text-sm border-gray-300 rounded-md" placeholder="15" />
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 text-sm">
minutes
</div>
</div>
</div>
</div>
<div className="my-8">
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
ref={isHiddenRef}
id="ishidden"
name="ishidden"
type="checkbox"
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="ishidden" className="font-medium text-gray-700">
Hide this event type
</label>
<p className="text-gray-500">Hide the event type from your page, so it can only be booked through it's URL.</p>
</div>
</div>
</div>
{/* TODO: Add an error message when required input fields empty*/}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Create
</button>
<button onClick={toggleAddModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
{showChangeTimesModal &&
<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"></div>
<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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start mb-4">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<ClockIcon className="h-6 w-6 text-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">
Change your available times
</h3>
<div>
<p className="text-sm text-gray-500">
Set the start and end time of your day.
</p>
</div>
</div>
</div>
<form onSubmit={updateStartEndTimesHandler}>
<div className="flex mb-4">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">Start time</label>
<div>
<label htmlFor="hours" className="sr-only">Hours</label>
<input ref={startHoursRef} type="number" name="hours" id="hours" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="9" defaultValue={convertMinsToHrsMins(props.user.startTime).split(":")[0]} />
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="minutes" className="sr-only">Minutes</label>
<input ref={startMinsRef} type="number" name="minutes" id="minutes" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="30" defaultValue={convertMinsToHrsMins(props.user.startTime).split(":")[1]} />
</div>
</div>
<div className="flex">
<label className="w-1/4 pt-2 block text-sm font-medium text-gray-700">End time</label>
<div>
<label htmlFor="hours" className="sr-only">Hours</label>
<input ref={endHoursRef} type="number" name="hours" id="hours" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="17" defaultValue={convertMinsToHrsMins(props.user.endTime).split(":")[0]} />
</div>
<span className="mx-2 pt-1">:</span>
<div>
<label htmlFor="minutes" className="sr-only">Minutes</label>
<input ref={endMinsRef} type="number" name="minutes" id="minutes" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="30" defaultValue={convertMinsToHrsMins(props.user.endTime).split(":")[1]} />
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit" className="btn btn-primary">
Update
</button>
<button onClick={toggleChangeTimesModal} type="button" className="btn btn-white mr-2">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
}
<Modal heading="Start and end times changed" description="The start and end times for your day have been changed successfully." open={successModalOpen} handleClose={closeSuccessModal} />
</Shell>
</div>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: '/auth/login' } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
username: true,
startTime: true,
endTime: true
}
});
const types = await prisma.eventType.findMany({
where: {
userId: user.id,
},
select: {
id: true,
title: true,
slug: true,
description: true,
length: true,
hidden: true
}
});
return {
props: {user, types}, // will be passed to the page component as props
}
}

View File

@@ -0,0 +1,318 @@
import Head from 'next/head';
import Link from 'next/link';
import prisma from '../lib/prisma';
import Shell from '../components/Shell';
import { signIn, useSession, getSession } from 'next-auth/client';
import { ClockIcon, CheckIcon, InformationCircleIcon } from '@heroicons/react/outline';
import DonateBanner from '../components/DonateBanner';
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default function Home(props) {
const [session, loading] = useSession();
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
function convertMinsToHrsMins(mins) {
let h = Math.floor(mins / 60);
let m = mins % 60;
h = h < 10 ? '0' + h : h;
m = m < 10 ? '0' + m : m;
return `${h}:${m}`;
}
const stats = [
{ name: 'Event Types', stat: props.eventTypeCount },
{ name: 'Integrations', stat: props.integrationCount },
{ name: 'Available Hours', stat: (props.user.endTime - props.user.startTime) / 60 + ' hours' },
];
let timeline = [];
if (session) {
timeline = [
{
id: 1,
content: 'Add your first',
target: 'integration',
href: '/integrations',
icon: props.integrationCount != 0 ? CheckIcon : InformationCircleIcon,
iconBackground: props.integrationCount != 0 ? 'bg-green-400' : 'bg-gray-400',
},
{
id: 2,
content: 'Add one or more',
target: 'event types',
href: '/availability',
icon: props.eventTypeCount != 0 ? CheckIcon : InformationCircleIcon,
iconBackground: props.eventTypeCount != 0 ? 'bg-green-400' : 'bg-gray-400',
},
{
id: 3,
content: 'Complete your',
target: 'profile',
href: '/settings/profile',
icon: session.user.image ? CheckIcon : InformationCircleIcon,
iconBackground: session.user.image ? 'bg-green-400' : 'bg-gray-400',
},
];
} else {
timeline = [];
}
return (
<div>
<Head>
<title>Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading="Dashboard">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<div className="rounded-lg bg-white shadow">
<div className="pt-5 pb-2 px-6 sm:flex sm:items-center sm:justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Your stats
</h3>
</div>
<dl className="grid grid-cols-1 overflow-hidden divide-y divide-gray-200 md:grid-cols-3 md:divide-y-0 md:divide-x">
{stats.map((item) => (
<div key={item.name} className="px-4 py-5 sm:p-6">
<dt className="text-base font-normal text-gray-900">{item.name}</dt>
<dd className="mt-1 flex justify-between items-baseline md:block lg:flex">
<div className="flex items-baseline text-2xl font-semibold text-blue-600">
{item.stat}
</div>
</dd>
</div>
))}
</dl>
</div>
<div className="mt-8 bg-white shadow overflow-hidden sm:rounded-md">
<div className="pt-5 pb-2 px-6 sm:flex sm:items-center sm:justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Your event types
</h3>
</div>
<ul className="divide-y divide-gray-200">
{props.eventTypes.map((type) => (
<li key={type.id}>
<div className="px-4 py-4 flex items-center sm:px-6">
<div className="min-w-0 flex-1 sm:flex sm:items-center sm:justify-between">
<div className="truncate">
<div className="flex text-sm">
<p className="font-medium text-blue-600 truncate">{type.title}</p>
<p className="ml-1 flex-shrink-0 font-normal text-gray-500">in {type.description}</p>
</div>
<div className="mt-2 flex">
<div className="flex items-center text-sm text-gray-500">
<ClockIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400" aria-hidden="true" />
<p>
{type.length} minutes
</p>
</div>
</div>
</div>
</div>
<div className="ml-5 flex-shrink-0">
<Link href={"/" + session.user.username + "/" + type.slug}><a target="_blank" className="text-blue-600 hover:text-blue-900 mr-2 font-medium">View</a></Link>
</div>
</div>
</li>
))}
</ul>
</div>
<div className="mt-8 bg-white shadow overflow-hidden sm:rounded-md p-6">
<div className="flex">
<div className="w-1/2 self-center">
<h2 className="text-2xl font-semibold">Getting started</h2>
<p className="text-gray-600 text-sm">Steps you should take to get started with Calendso.</p>
</div>
<div className="w-1/2">
<div className="flow-root">
<ul className="-mb-8">
{timeline.map((event, eventIdx) => (
<li key={event.id}>
<div className="relative pb-8">
{eventIdx !== timeline.length - 1 ? (
<span className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200" aria-hidden="true" />
) : null}
<div className="relative flex space-x-3">
<div>
<span
className={classNames(
event.iconBackground,
'h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white'
)}
>
<event.icon className="h-5 w-5 text-white" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1 pt-1.5 flex justify-between space-x-4">
<div>
<p className="text-sm text-gray-500">
{event.content}{' '}
<Link href={event.href}>
<a className="font-medium text-gray-900">
{event.target}
</a>
</Link>
</p>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
<div>
<div className="bg-white rounded-lg shadow px-5 py-6 md:py-7 sm:px-6">
<div className="mb-4 sm:flex sm:items-center sm:justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Your day
</h3>
<div className="mt-3 sm:mt-0 sm:ml-4">
<Link href="/availability">
<a className="text-sm text-gray-400">Configure</a>
</Link>
</div>
</div>
<div>
<p className="text-2xl font-semibold text-gray-600">Offering time slots between <span className="text-blue-600">{convertMinsToHrsMins(props.user.startTime)}</span> and <span className="text-blue-600">{convertMinsToHrsMins(props.user.endTime)}</span></p>
</div>
</div>
<div className="mt-8 bg-white rounded-lg shadow px-5 py-6 md:py-7 sm:px-6">
<div className="mb-8 sm:flex sm:items-center sm:justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Your integrations
</h3>
<div className="mt-3 sm:mt-0 sm:ml-4">
<Link href="/integrations">
<a className="text-sm text-gray-400">View more</a>
</Link>
</div>
</div>
<ul className="divide-y divide-gray-200">
{props.credentials.map((integration) =>
<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 == 'office365_calendar' && <img className="h-10 w-10 mr-2" src="integrations/office-365.png" alt="Office 365 / Outlook.com Calendar" />}
<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 == 'google_calendar' && <p className="text-sm font-medium text-gray-900">Google Calendar</p>}
<p className="text-sm text-gray-500">Calendar Integration</p>
</div>
</li>
)}
{props.credentials.length == 0 &&
<div className="text-center text-gray-400 py-2">
<p>You haven't added any integrations.</p>
</div>
}
</ul>
</div>
<div className="mt-8 bg-white rounded-lg shadow px-5 py-6 md:py-7 sm:px-6">
<div className="mb-4 sm:flex sm:items-center sm:justify-between">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Your event types
</h3>
<div className="mt-3 sm:mt-0 sm:ml-4">
<Link href="/availability">
<a className="text-sm text-gray-400">View more</a>
</Link>
</div>
</div>
<ul className="divide-y divide-gray-200">
{props.eventTypes.map((type) => (
<li
key={type.id}
className="relative bg-white py-5 hover:bg-gray-50 focus-within:ring-2 focus-within:ring-inset focus-within:ring-indigo-600"
>
<div className="flex justify-between space-x-3">
<div className="min-w-0 flex-1">
<a href="#" className="block focus:outline-none">
<span className="absolute inset-0" aria-hidden="true" />
<p className="text-sm font-medium text-gray-900 truncate">{type.title}</p>
<p className="text-sm text-gray-500 truncate">{type.description}</p>
</a>
</div>
<span className="flex-shrink-0 whitespace-nowrap text-sm text-gray-500">
{type.length} minutes
</span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
<DonateBanner />
</Shell>
</div>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
let user = [];
let credentials = [];
let eventTypes = [];
let eventTypeCount = 0;
let integrationCount = 0;
if (session) {
user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
startTime: true,
endTime: true
}
});
credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
select: {
type: true
}
});
eventTypes = await prisma.eventType.findMany({
where: {
userId: user.id,
}
});
eventTypeCount = await prisma.eventType.count({
where: {
userId: session.user.id
}
});
integrationCount = await prisma.credential.count({
where: {
userId: session.user.id
}
});
}
return {
props: { user, credentials, eventTypes, eventTypeCount, integrationCount }, // will be passed to the page component as props
}
}

View File

@@ -0,0 +1,130 @@
import Head from 'next/head';
import prisma from '../../lib/prisma';
import { getIntegrationName, getIntegrationType } from '../../lib/integrations';
import Shell from '../../components/Shell';
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useSession, getSession } from 'next-auth/client';
export default function integration(props) {
const router = useRouter();
const [session, loading] = useSession();
const [showAPIKey, setShowAPIKey] = useState(false);
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
function toggleShowAPIKey() {
setShowAPIKey(!showAPIKey);
}
async function deleteIntegrationHandler(event) {
event.preventDefault();
const response = await fetch('/api/integrations', {
method: 'DELETE',
body: JSON.stringify({id: props.integration.id}),
headers: {
'Content-Type': 'application/json'
}
});
router.push('/integrations');
}
return(
<div>
<Head>
<title>{getIntegrationName(props.integration.type)} | Integrations | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading={getIntegrationName(props.integration.type)}>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Integration Details
</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
Information about your {getIntegrationName(props.integration.type)} integration.
</p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<dl className="grid gap-y-8">
<div>
<dt className="text-sm font-medium text-gray-500">
Integration name
</dt>
<dd className="mt-1 text-sm text-gray-900">
{getIntegrationName(props.integration.type)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">
Integration type
</dt>
<dd className="mt-1 text-sm text-gray-900">
{getIntegrationType(props.integration.type)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">
API Key
</dt>
<dd className="mt-1 text-sm text-gray-900">
{!showAPIKey ?
<span>&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;</span>
:
<div>
<textarea name="apikey" rows={6} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" readOnly>{JSON.stringify(props.integration.key)}</textarea>
</div>}
<button onClick={toggleShowAPIKey} className="ml-2 font-medium text-blue-600 hover:text-blue-700">{!showAPIKey ? 'Show' : 'Hide'}</button>
</dd>
</div>
</dl>
</div>
</div>
<div>
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Delete this integration
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>
Once you delete this integration, it will be permanently removed.
</p>
</div>
<div className="mt-5">
<button onClick={deleteIntegrationHandler} type="button" className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm">
Delete integration
</button>
</div>
</div>
</div>
</div>
</div>
</Shell>
</div>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
const integration = await prisma.credential.findFirst({
where: {
id: parseInt(context.query.integration),
},
select: {
id: true,
type: true,
key: true
}
});
return {
props: {integration}, // will be passed to the page component as props
}
}

View File

@@ -0,0 +1,222 @@
import Head from 'next/head';
import Link from 'next/link';
import prisma from '../../lib/prisma';
import Shell from '../../components/Shell';
import { useState } from 'react';
import { useSession, getSession } from 'next-auth/client';
import { CheckCircleIcon, XCircleIcon, ChevronRightIcon, PlusIcon } from '@heroicons/react/solid';
import { InformationCircleIcon } from '@heroicons/react/outline';
export default function Home({ integrations }) {
const [session, loading] = useSession();
const [showAddModal, setShowAddModal] = useState(false);
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
function toggleAddModal() {
setShowAddModal(!showAddModal);
}
function integrationHandler(type) {
fetch('/api/integrations/' + type.replace('_', '') + '/add')
.then((response) => response.json())
.then((data) => window.location.href = data.url);
}
return (
<div>
<Head>
<title>Integrations | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<Shell heading="Integrations">
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
{integrations.filter( (ig) => ig.credential ).length !== 0 ? <ul className="divide-y divide-gray-200">
{integrations.filter(ig => ig.credential).map( (ig) => (<li>
<Link href={"/integrations/" + ig.credential.id}>
<a className="block hover:bg-gray-50">
<div className="flex items-center px-4 py-4 sm:px-6">
<div className="min-w-0 flex-1 flex items-center">
<div className="flex-shrink-0">
<img className="h-10 w-10 mr-2" src={ig.imageSrc} alt={ig.title} />
</div>
<div className="min-w-0 flex-1 px-4 md:grid md:grid-cols-2 md:gap-4">
<div>
<p className="text-sm font-medium text-blue-600 truncate">{ig.title}</p>
<p className="flex items-center text-sm text-gray-500">
{ig.type.endsWith('_calendar') && <span className="truncate">Calendar Integration</span>}
</p>
</div>
<div className="hidden md:block">
{ig.credential.key && <p className="mt-2 flex items-center text text-gray-500">
<CheckCircleIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-green-400" />
Connected
</p>}
{!ig.credential.key && <p className="mt-3 flex items-center text text-gray-500">
<XCircleIcon className="flex-shrink-0 mr-1.5 h-5 w-5 text-yellow-400" />
Not connected
</p>}
</div>
</div>
<div>
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
</div>
</div>
</div>
</a>
</Link>
</li>))}
</ul>
:
<div className="bg-white shadow sm:rounded-lg">
<div className="flex">
<div className="py-9 pl-8">
<InformationCircleIcon className="text-blue-600 w-16" />
</div>
<div className="py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">
You don't have any integrations added.
</h3>
<div className="mt-2 text-sm text-gray-500">
<p>
You currently do not have any integrations set up. Add your first integration to get started.
</p>
</div>
<div className="mt-3 text-sm">
<button onClick={toggleAddModal} className="font-medium text-blue-600 hover:text-blue-500"> Add your first integration <span aria-hidden="true">&rarr;</span></button>
</div>
</div>
</div>
</div>
}
</div>
{showAddModal &&
<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">
{/* <!--
Background overlay, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
--> */}
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
{/* <!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
--> */}
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start">
<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 a new integration
</h3>
<div>
<p className="text-sm text-gray-400">
Link a new integration to your account.
</p>
</div>
</div>
</div>
<div className="my-4">
<ul className="divide-y divide-gray-200">
{integrations.filter( (integration) => integration.installed ).map( (integration) => (<li className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
</div>
<div className="w-10/12">
<h2 className="text-gray-800 font-medium">{ integration.title }</h2>
<p className="text-gray-400 text-sm">{ integration.description }</p>
</div>
<div className="w-2/12 text-right pt-2">
<button onClick={() => integrationHandler(integration.type)} className="font-medium text-blue-600 hover:text-blue-500">Add</button>
</div>
</li>))}
</ul>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button onClick={toggleAddModal} type="button" className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
</div>
}
</Shell>
</div>
);
}
const validJson = (jsonString: string) => {
try {
const o = JSON.parse(jsonString);
if (o && typeof o === "object") {
return o;
}
}
catch (e) {}
return false;
}
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: '/auth/login' } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true
}
});
const credentials = await prisma.credential.findMany({
where: {
userId: user.id,
},
select: {
id: true,
type: true,
key: true
}
});
const integrations = [ {
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
credential: credentials.find( (integration) => integration.type === "google_calendar" ) || null,
type: "google_calendar",
title: "Google Calendar",
imageSrc: "integrations/google-calendar.png",
description: "For personal and business accounts",
}, {
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
type: "office365_calendar",
credential: credentials.find( (integration) => integration.type === "office365_calendar" ) || null,
title: "Office 365 / Outlook.com Calendar",
imageSrc: "integrations/office-365.png",
description: "For personal and business accounts",
} ];
return {
props: {integrations},
}
}

View File

@@ -0,0 +1,105 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRef, useState } from 'react';
import prisma from '../../lib/prisma';
import Modal from '../../components/Modal';
import Shell from '../../components/Shell';
import SettingsShell from '../../components/Settings';
import { signIn, useSession, getSession } from 'next-auth/client';
export default function Settings(props) {
const [ session, loading ] = useSession();
const [successModalOpen, setSuccessModalOpen] = useState(false);
const oldPasswordRef = useRef<HTMLInputElement>();
const newPasswordRef = useRef<HTMLInputElement>();
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
const closeSuccessModal = () => { setSuccessModalOpen(false); }
async function changePasswordHandler(event) {
event.preventDefault();
const enteredOldPassword = oldPasswordRef.current.value;
const enteredNewPassword = newPasswordRef.current.value;
// TODO: Add validation
const response = await fetch('/api/auth/changepw', {
method: 'PATCH',
body: JSON.stringify({oldPassword: enteredOldPassword, newPassword: enteredNewPassword}),
headers: {
'Content-Type': 'application/json'
}
});
setSuccessModalOpen(true);
}
return(
<Shell heading="Password">
<Head>
<title>Change Password | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<SettingsShell>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={changePasswordHandler}>
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Change Password</h2>
<p className="mt-1 text-sm text-gray-500">
Change the password for your Calendso account.
</p>
</div>
<div className="mt-6 flex">
<div className="w-1/2 mr-2">
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">Current Password</label>
<div className="mt-1">
<input ref={oldPasswordRef} type="password" name="current_password" id="current_password" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Your old password" />
</div>
</div>
<div className="w-1/2 ml-2">
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">New Password</label>
<div className="mt-1">
<input ref={newPasswordRef} type="password" name="new_password" id="new_password" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Your super secure new password" />
</div>
</div>
</div>
<hr className="mt-8" />
<div className="py-4 flex justify-end">
<button type="submit" className="ml-2 bg-blue-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Save
</button>
</div>
</div>
</form>
<Modal heading="Password updated successfully" description="Your password has been successfully changed." open={successModalOpen} handleClose={closeSuccessModal} />
</SettingsShell>
</Shell>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: '/auth/login' } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
username: true,
name: true
}
});
return {
props: {user}, // will be passed to the page component as props
}
}

View File

@@ -0,0 +1,184 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRef, useState } from 'react';
import { useRouter } from 'next/router';
import prisma from '../../lib/prisma';
import Modal from '../../components/Modal';
import Shell from '../../components/Shell';
import SettingsShell from '../../components/Settings';
import Avatar from '../../components/Avatar';
import { signIn, useSession, getSession } from 'next-auth/client';
import TimezoneSelect from 'react-timezone-select';
export default function Settings(props) {
const [ session, loading ] = useSession();
const router = useRouter();
const [successModalOpen, setSuccessModalOpen] = useState(false);
const usernameRef = useRef<HTMLInputElement>();
const nameRef = useRef<HTMLInputElement>();
const descriptionRef = useRef<HTMLTextAreaElement>();
const avatarRef = useRef<HTMLInputElement>();
const [ selectedTimeZone, setSelectedTimeZone ] = useState({ value: props.user.timeZone });
if (loading) {
return <p className="text-gray-400">Loading...</p>;
}
const closeSuccessModal = () => { setSuccessModalOpen(false); }
async function updateProfileHandler(event) {
event.preventDefault();
const enteredUsername = usernameRef.current.value;
const enteredName = nameRef.current.value;
const enteredDescription = descriptionRef.current.value;
const enteredAvatar = avatarRef.current.value;
const enteredTimeZone = selectedTimeZone.value;
// TODO: Add validation
const response = await fetch('/api/user/profile', {
method: 'PATCH',
body: JSON.stringify({username: enteredUsername, name: enteredName, description: enteredDescription, avatar: enteredAvatar, timeZone: enteredTimeZone}),
headers: {
'Content-Type': 'application/json'
}
});
router.replace(router.asPath);
setSuccessModalOpen(true);
}
return(
<Shell heading="Profile">
<Head>
<title>Profile | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<SettingsShell>
<form className="divide-y divide-gray-200 lg:col-span-9" onSubmit={updateProfileHandler}>
<div className="py-6 px-4 sm:p-6 lg:pb-8">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Profile</h2>
<p className="mt-1 text-sm text-gray-500">
Review and change your public page details.
</p>
</div>
<div className="mt-6 flex flex-col lg:flex-row">
<div className="flex-grow space-y-6">
<div className="flex">
<div className="w-1/2 mr-2">
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<div className="mt-1 rounded-md shadow-sm flex">
<span className="bg-gray-50 border border-r-0 border-gray-300 rounded-l-md px-3 inline-flex items-center text-gray-500 sm:text-sm">
{window.location.hostname}/
</span>
<input ref={usernameRef} type="text" name="username" id="username" autoComplete="username" required className="focus:ring-blue-500 focus:border-blue-500 flex-grow block w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300" defaultValue={props.user.username} />
</div>
</div>
<div className="w-1/2 ml-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Full name</label>
<input ref={nameRef} type="text" name="name" id="name" autoComplete="given-name" placeholder="Your name" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" defaultValue={props.user.name} />
</div>
</div>
<div>
<label htmlFor="about" className="block text-sm font-medium text-gray-700">
About
</label>
<div className="mt-1">
<textarea ref={descriptionRef} id="about" name="about" placeholder="A little something about yourself." rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md">{props.user.bio}</textarea>
</div>
</div>
<div>
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
Timezone
</label>
<div className="mt-1">
<TimezoneSelect id="timeZone" value={selectedTimeZone} onChange={setSelectedTimeZone} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" />
</div>
</div>
</div>
<div className="mt-6 flex-grow lg:mt-0 lg:ml-6 lg:flex-grow-0 lg:flex-shrink-0">
<p className="mb-2 text-sm font-medium text-gray-700" aria-hidden="true">
Photo
</p>
<div className="mt-1 lg:hidden">
<div className="flex items-center">
<div className="flex-shrink-0 inline-block rounded-full overflow-hidden h-12 w-12" aria-hidden="true">
<Avatar user={props.user} className="rounded-full h-full w-full" />
</div>
{/* <div className="ml-5 rounded-md shadow-sm">
<div className="group relative border border-gray-300 rounded-md py-2 px-3 flex items-center justify-center hover:bg-gray-50 focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
<label htmlFor="user_photo" className="relative text-sm leading-4 font-medium text-gray-700 pointer-events-none">
<span>Change</span>
<span className="sr-only"> user photo</span>
</label>
<input id="user_photo" name="user_photo" type="file" className="absolute w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-md" />
</div>
</div> */}
</div>
</div>
<div className="hidden relative rounded-full overflow-hidden lg:block">
<Avatar
user={props.user}
className="relative rounded-full w-40 h-40"
fallback={<div className="relative bg-blue-600 rounded-full w-40 h-40"></div>}
/>
{/* <label htmlFor="user-photo" className="absolute inset-0 w-full h-full bg-black bg-opacity-75 flex items-center justify-center text-sm font-medium text-white opacity-0 hover:opacity-100 focus-within:opacity-100">
<span>Change</span>
<span className="sr-only"> user photo</span>
<input type="file" id="user-photo" name="user-photo" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer border-gray-300 rounded-md" />
</label> */}
</div>
<div className="mt-4">
<label htmlFor="avatar" className="block text-sm font-medium text-gray-700">Avatar URL</label>
<input ref={avatarRef} type="text" name="avatar" id="avatar" placeholder="URL" className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" defaultValue={props.user.avatar} />
</div>
</div>
</div>
<hr className="mt-8" />
<div className="py-4 flex justify-end">
<button type="submit" className="ml-2 bg-blue-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Save
</button>
</div>
</div>
</form>
<Modal heading="Profile updated successfully" description="Your user profile has been updated successfully." open={successModalOpen} handleClose={closeSuccessModal} />
</SettingsShell>
</Shell>
);
}
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { permanent: false, destination: '/auth/login' } };
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
username: true,
name: true,
email: true,
bio: true,
avatar: true,
timeZone: true,
}
});
return {
props: {user}, // will be passed to the page component as props
}
}

View File

@@ -0,0 +1,153 @@
import Head from 'next/head';
import Link from 'next/link';
import prisma from '../lib/prisma';
import { useRouter } from 'next/router';
import { CheckIcon } from '@heroicons/react/outline';
import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { createEvent } from 'ics';
dayjs.extend(utc);
export default function Success(props) {
const router = useRouter();
const { date, location } = router.query;
function eventLink(): string {
const start = Array.prototype.concat(...date.split('T').map(
(parts) => parts.split('-').length > 1 ? parts.split('-').map( (n) => parseInt(n, 10) ) : parts.split(':').map( (n) => parseInt(n, 10) )
));
let optional = {};
if (location) {
optional['location'] = location;
}
const event = createEvent({
start,
startInputType: 'utc',
title: props.eventType.title + ' with ' + props.user.name,
description: props.eventType.description,
duration: { minutes: props.eventType.length },
...optional
});
if (event.error) {
throw event.error;
}
return encodeURIComponent(event.value);
}
return(
<div>
<Head>
<title>Booking Confirmed | {props.eventType.title} with {props.user.name || props.user.username} | Calendso</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className="max-w-3xl mx-auto my-24">
<div className="fixed z-10 inset-0 overflow-y-auto">
<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 my-4 sm:my-0 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 overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6" role="dialog" aria-modal="true" aria-labelledby="modal-headline">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
Booking confirmed
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
You are scheduled in with {props.user.name || props.user.username}.
</p>
</div>
<div className="mt-4 border-t border-b py-4">
<h2 className="text-lg font-medium text-gray-600 mb-2">{props.eventType.title} with {props.user.name}</h2>
<p className="text-gray-500 mb-1">
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{props.eventType.length} minutes
</p>
{location && <p className="text-gray-500 mb-1">
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{location}
</p>}
<p className="text-gray-500">
<CalendarIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")}
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-6 text-center">
<span className="font-medium text-gray-500">Add to your calendar</span>
<div className="flex mt-2">
<Link href={`https://calendar.google.com/calendar/r/eventedit?dates=${dayjs(date).format('YYYYMMDDTHHmmss[Z]')}/${dayjs(date).add(props.eventType.length, 'minute').format('YYYYMMDDTHHmmss[Z]')}&text=${props.eventType.title} with ${props.user.name}&details=${props.eventType.description}` + ( location ? "&location=" + encodeURIComponent(location) : '')}>
<a className="mx-2 btn-wide btn-white">
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>
</a>
</Link>
<Link href={encodeURI("https://outlook.live.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + dayjs(date).add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + dayjs(date).format() + "&subject=" + props.eventType.title + " with " + props.user.name) + (location ? "&location=" + location : '')}>
<a className="mx-2 btn-wide btn-white">
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Microsoft Outlook</title><path d="M7.88 12.04q0 .45-.11.87-.1.41-.33.74-.22.33-.58.52-.37.2-.87.2t-.85-.2q-.35-.21-.57-.55-.22-.33-.33-.75-.1-.42-.1-.86t.1-.87q.1-.43.34-.76.22-.34.59-.54.36-.2.87-.2t.86.2q.35.21.57.55.22.34.31.77.1.43.1.88zM24 12v9.38q0 .46-.33.8-.33.32-.8.32H7.13q-.46 0-.8-.33-.32-.33-.32-.8V18H1q-.41 0-.7-.3-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h6.5V2.55q0-.44.3-.75.3-.3.75-.3h12.9q.44 0 .75.3.3.3.3.75V10.85l1.24.72h.01q.1.07.18.18.07.12.07.25zm-6-8.25v3h3v-3zm0 4.5v3h3v-3zm0 4.5v1.83l3.05-1.83zm-5.25-9v3h3.75v-3zm0 4.5v3h3.75v-3zm0 4.5v2.03l2.41 1.5 1.34-.8v-2.73zM9 3.75V6h2l.13.01.12.04v-2.3zM5.98 15.98q.9 0 1.6-.3.7-.32 1.19-.86.48-.55.73-1.28.25-.74.25-1.61 0-.83-.25-1.55-.24-.71-.71-1.24t-1.15-.83q-.68-.3-1.55-.3-.92 0-1.64.3-.71.3-1.2.85-.5.54-.75 1.3-.25.74-.25 1.63 0 .85.26 1.56.26.72.74 1.23.48.52 1.17.81.69.3 1.56.3zM7.5 21h12.39L12 16.08V17q0 .41-.3.7-.29.3-.7.3H7.5zm15-.13v-7.24l-5.9 3.54Z"/></svg>
</a>
</Link>
<Link href={encodeURI("https://outlook.office.com/calendar/0/deeplink/compose?body=" + props.eventType.description + "&enddt=" + dayjs(date).add(props.eventType.length, 'minute').format() + "&path=%2Fcalendar%2Faction%2Fcompose&rru=addevent&startdt=" + dayjs(date).format() + "&subject=" + props.eventType.title + " with " + props.user.name) + (location ? "&location=" + location : '')}>
<a className="mx-2 btn-wide btn-white">
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Microsoft Office</title><path d="M21.53 4.306v15.363q0 .807-.472 1.433-.472.627-1.253.85l-6.888 1.974q-.136.037-.29.055-.156.019-.293.019-.396 0-.72-.105-.321-.106-.656-.292l-4.505-2.544q-.248-.137-.391-.366-.143-.23-.143-.515 0-.434.304-.738.304-.305.739-.305h5.831V4.964l-4.38 1.563q-.533.187-.856.658-.322.472-.322 1.03v8.078q0 .496-.248.912-.25.416-.683.651l-2.072 1.13q-.286.148-.571.148-.497 0-.844-.347-.348-.347-.348-.844V6.563q0-.62.33-1.19.328-.571.874-.881L11.07.285q.248-.136.534-.21.285-.075.57-.075.211 0 .38.031.166.031.364.093l6.888 1.899q.384.11.7.329.317.217.547.52.23.305.353.67.125.367.125.764zm-1.588 15.363V4.306q0-.273-.16-.478-.163-.204-.423-.28l-3.388-.93q-.397-.111-.794-.23-.397-.117-.794-.216v19.68l4.976-1.427q.26-.074.422-.28.161-.204.161-.477z"/></svg>
</a>
</Link>
<Link href={"data:text/calendar," + eventLink()}>
<a className="mx-2 btn-wide btn-white" download={props.eventType.title + '.ics'}>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" className="inline-block w-4 h-4 mr-1 -mt-1"><title>Other</title><path d="M971.3,154.9c0-34.7-28.2-62.9-62.9-62.9H611.7c-1.3,0-2.6,0.1-3.9,0.2V10L28.7,87.3v823.4L607.8,990v-84.6c1.3,0.1,2.6,0.2,3.9,0.2h296.7c34.7,0,62.9-28.2,62.9-62.9V154.9z M607.8,636.1h44.6v-50.6h-44.6v-21.9h44.6v-50.6h-44.6v-92h277.9v230.2c0,3.8-3.1,7-7,7H607.8V636.1z M117.9,644.7l-50.6-2.4V397.5l50.6-2.2V644.7z M288.6,607.3c17.6,0.6,37.3-2.8,49.1-7.2l9.1,48c-11,5.1-35.6,9.9-66.9,8.3c-85.4-4.3-127.5-60.7-127.5-132.6c0-86.2,57.8-136.7,133.2-140.1c30.3-1.3,53.7,4,64.3,9.2l-12.2,48.9c-12.1-4.9-28.8-9.2-49.5-8.6c-45.3,1.2-79.5,30.1-79.5,87.4C208.8,572.2,237.8,605.7,288.6,607.3z M455.5,665.2c-32.4-1.6-63.7-11.3-79.1-20.5l12.6-50.7c16.8,9.1,42.9,18.5,70.4,19.4c30.1,1,46.3-10.7,46.3-29.3c0-17.8-14-28.1-48.8-40.6c-46.9-16.4-76.8-41.7-76.8-81.5c0-46.6,39.3-84.1,106.8-87.1c33.3-1.5,58.3,4.2,76.5,11.2l-15.4,53.3c-12.1-5.3-33.5-12.8-62.3-12c-28.3,0.8-41.9,13.6-41.9,28.1c0,17.8,16.1,25.5,53.6,39c52.9,18.5,78.4,45.3,78.4,86.4C575.6,629.7,536.2,669.2,455.5,665.2z M935.3,842.7c0,14.9-12.1,27-27,27H611.7c-1.3,0-2.6-0.2-3.9-0.4V686.2h270.9c19.2,0,34.9-15.6,34.9-34.9V398.4c0-19.2-15.6-34.9-34.9-34.9h-47.1v-32.3H808v32.3h-44.8v-32.3h-22.7v32.3h-43.3v-32.3h-22.7v32.3H628v-32.3h-20.2v-203c1.31.2,2.6-0.4,3.9-0.4h296.7c14.9,0,27,12.1,27,27L935.3,842.7L935.3,842.7z"/>
</svg>
</a>
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
)
}
export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({
where: {
username: context.query.user,
},
select: {
username: true,
name: true,
bio: true,
avatar: true,
eventTypes: true
}
});
const eventType = await prisma.eventType.findUnique({
where: {
id: parseInt(context.query.type),
},
select: {
id: true,
title: true,
description: true,
length: true
}
});
return {
props: {
user,
eventType
},
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,135 @@
Object.defineProperty(exports, "__esModule", { value: true });
const {
Decimal
} = require('@prisma/client/runtime/index-browser')
const Prisma = {}
exports.Prisma = Prisma
/**
* Prisma Client JS version: 2.23.0
* Query Engine version: adf5e8cba3daf12d456d911d72b6e9418681b28b
*/
Prisma.prismaVersion = {
client: "2.23.0",
engine: "adf5e8cba3daf12d456d911d72b6e9418681b28b"
}
Prisma.PrismaClientKnownRequestError = () => {
throw new Error(`PrismaClientKnownRequestError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)};
Prisma.PrismaClientUnknownRequestError = () => {
throw new Error(`PrismaClientUnknownRequestError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.PrismaClientRustPanicError = () => {
throw new Error(`PrismaClientRustPanicError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.PrismaClientInitializationError = () => {
throw new Error(`PrismaClientInitializationError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.PrismaClientValidationError = () => {
throw new Error(`PrismaClientValidationError is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.Decimal = Decimal
/**
* Re-export of sql-template-tag
*/
Prisma.sql = () => {
throw new Error(`sqltag is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.empty = () => {
throw new Error(`empty is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.join = () => {
throw new Error(`join is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.raw = () => {
throw new Error(`raw is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)}
Prisma.validator = () => (val) => val
/**
* Enums
*/
// Based on
// https://github.com/microsoft/TypeScript/issues/3192#issuecomment-261720275
function makeEnum(x) { return x; }
exports.Prisma.EventTypeScalarFieldEnum = makeEnum({
id: 'id',
title: 'title',
slug: 'slug',
description: 'description',
locations: 'locations',
length: 'length',
hidden: 'hidden',
userId: 'userId'
});
exports.Prisma.CredentialScalarFieldEnum = makeEnum({
id: 'id',
type: 'type',
key: 'key',
userId: 'userId'
});
exports.Prisma.UserScalarFieldEnum = makeEnum({
id: 'id',
username: 'username',
name: 'name',
email: 'email',
password: 'password',
bio: 'bio',
avatar: 'avatar',
timeZone: 'timeZone',
startTime: 'startTime',
endTime: 'endTime',
createdDate: 'createdDate'
});
exports.Prisma.SortOrder = makeEnum({
asc: 'asc',
desc: 'desc'
});
exports.Prisma.QueryMode = makeEnum({
default: 'default',
insensitive: 'insensitive'
});
exports.Prisma.ModelName = makeEnum({
EventType: 'EventType',
Credential: 'Credential',
User: 'User'
});
/**
* Create the Client
*/
class PrismaClient {
constructor() {
throw new Error(
`PrismaClient is unable to be run in the browser.
In case this error is unexpected for you, please report it in https://github.com/prisma/prisma/issues`,
)
}
}
exports.PrismaClient = PrismaClient
Object.assign(exports, Prisma)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
{
"name": ".prisma/client",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js"
}

Binary file not shown.

View File

@@ -0,0 +1,48 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model EventType {
id Int @default(autoincrement()) @id
title String
slug String
description String?
locations Json?
length Int
hidden Boolean @default(false)
user User? @relation(fields: [userId], references: [id])
userId Int?
}
model Credential {
id Int @default(autoincrement()) @id
type String
key Json
user User? @relation(fields: [userId], references: [id])
userId Int?
}
model User {
id Int @default(autoincrement()) @id
username String?
name String?
email String? @unique
password String?
bio String?
avatar String?
timeZone String @default("Europe/London")
startTime Int @default(0)
endTime Int @default(1440)
createdDate DateTime @default(now()) @map(name: "created")
eventTypes EventType[]
credentials Credential[]
@@map(name: "users")
}

View File

@@ -0,0 +1,48 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model EventType {
id Int @default(autoincrement()) @id
title String
slug String
description String?
locations Json?
length Int
hidden Boolean @default(false)
user User? @relation(fields: [userId], references: [id])
userId Int?
}
model Credential {
id Int @default(autoincrement()) @id
type String
key Json
user User? @relation(fields: [userId], references: [id])
userId Int?
}
model User {
id Int @default(autoincrement()) @id
username String?
name String?
email String? @unique
password String?
bio String?
avatar String?
timeZone String @default("Europe/London")
startTime Int @default(0)
endTime Int @default(1440)
createdDate DateTime @default(now()) @map(name: "created")
eventTypes EventType[]
credentials Credential[]
@@map(name: "users")
}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#26282C;}
</style>
<path class="st0" d="M27.5,88.2c-4.9,0-9.7-1.2-14-3.6c-4.2-2.4-7.6-5.8-9.9-10c-4.8-8.8-4.8-19.4,0-28.2c2.3-4.2,5.8-7.7,10-10
c4.3-2.4,9.1-3.7,14-3.6c6-0.1,11.8,1.7,16.5,5.3c4.7,3.6,8,8.7,9.9,15.4H42.8c-1.3-3-3.4-5.5-6.2-7.2c-2.6-1.6-5.6-2.5-8.7-2.5
c-2.8,0-5.6,0.7-8.1,2c-2.5,1.3-4.7,3.3-6.1,5.8c-3,5.3-3.1,11.9-0.2,17.3c1.4,2.5,3.4,4.5,5.8,6c2.4,1.5,5.2,2.3,8,2.2
c7.2,0,12.4-3.3,15.4-9.9h11.4c-1.1,4.2-3.1,8.1-5.8,11.5c-2.5,3-5.6,5.4-9.2,7C35.5,87.4,31.5,88.1,27.5,88.2L27.5,88.2z
M99.6,82.1C95,86.1,89,88.3,82.8,88.2c-4.9,0-9.7-1.2-14-3.6c-4.1-2.4-7.5-5.8-9.8-10c-2.5-4.5-3.7-9.5-3.6-14.6
c-0.1-15,11.9-27.2,26.9-27.3c0.1,0,0.2,0,0.3,0c6.4-0.2,12.6,2.1,17.5,6.2v-5.2h11.5v53.3H99.6V82.1z M83.5,43.7
c-3,0-5.9,0.7-8.4,2.2c-2.5,1.4-4.6,3.5-6,6c-1.5,2.5-2.2,5.4-2.2,8.4c-0.2,4.5,1.5,8.9,4.7,12.2c3.2,3.2,7.6,4.9,12.1,4.8
c2.9,0,5.8-0.7,8.3-2.2c2.5-1.4,4.5-3.5,5.9-6c2.9-5.3,2.9-11.7,0-17c-1.4-2.5-3.5-4.6-6-6C89.3,44.5,86.4,43.8,83.5,43.7L83.5,43.7
z M122,14.8h9.7v72.2H122C122,87.1,122,14.8,122,14.8z M149.8,65.2c0.5,2.3,1.5,4.4,3,6.3c1.4,1.8,3.3,3.2,5.4,4.1
c2.2,1,4.6,1.5,7,1.5c2.8,0.1,5.6-0.5,8.1-1.8c2.4-1.4,4.3-3.4,5.5-5.9h11.9c-2.7,6.5-6.2,11.2-10.4,14.2c-4.4,3.1-9.8,4.7-15.2,4.5
c-4.8,0.1-9.6-1.2-13.8-3.6c-4.1-2.4-7.5-5.8-9.7-10c-2.4-4.3-3.6-9.2-3.5-14.1c-0.1-4.9,1.1-9.8,3.5-14.1c2.3-4.2,5.7-7.6,9.8-10
c8.6-4.8,19.2-4.8,27.7,0.1c4.1,2.4,7.4,6,9.6,10.2c2.2,4.3,3.3,9.1,3.3,14c0,1-0.1,2.5-0.3,4.5H149.8z M165.2,43.6
c-3.4-0.1-6.8,0.9-9.6,2.9c-2.7,2-4.7,4.8-5.6,8h30.2c-0.9-3.2-2.9-6-5.6-8C171.9,44.5,168.6,43.5,165.2,43.6z M234.8,57.3
c0-4.7-1-8.2-3-10.5c-2-2.3-4.9-3.5-8.7-3.5c-2.3,0-4.6,0.7-6.5,2c-2,1.3-3.7,3.2-4.8,5.4c-1.2,2.3-1.8,4.8-1.8,7.4v28.9h-11.2V33.8
h10.5v4.8c3.9-3.9,9.3-6,14.9-5.8c3.9,0,7.7,1,11,3c3.3,2,6,4.8,7.9,8.2c1.9,3.5,2.9,7.5,2.9,11.5v31.6h-11.2L234.8,57.3L234.8,57.3
z M296.7,82.2c-2.4,1.9-5,3.4-7.9,4.4c-3,1-6.2,1.5-9.4,1.5c-4.8,0.1-9.6-1.2-13.8-3.7c-4.1-2.4-7.5-5.9-9.8-10.1
c-2.4-4.3-3.6-9.1-3.6-14.1c-0.1-5.1,1.1-10.1,3.5-14.7c2.3-4.2,5.7-7.7,9.8-10.1c4.3-2.5,9.2-3.8,14.1-3.7c3.1,0,6.1,0.5,9,1.4
c2.8,0.9,5.4,2.3,7.8,4.1V14.9h11.3v72.2h-11.1L296.7,82.2L296.7,82.2z M280.4,43.1c-3,0-5.9,0.7-8.4,2.2c-2.5,1.4-4.6,3.5-6,6
c-1.5,2.6-2.2,5.5-2.2,8.5c-0.2,4.6,1.5,9.1,4.7,12.4c3.2,3.2,7.5,5,12.1,4.8c2.9,0,5.8-0.7,8.3-2.3c2.5-1.5,4.5-3.6,5.9-6.1
c2.9-5.4,2.9-11.8,0-17.2C291.8,46.2,286.3,43,280.4,43.1z M335.8,88.2c-3.8,0.1-7.5-0.8-10.8-2.4c-3.1-1.6-5.7-4-7.5-7
c-1.9-3.3-2.9-7.1-3-10.9h11c0,2.7,1.1,5.3,3.1,7.1c2.1,1.7,4.8,2.6,7.5,2.5c1.8,0,3.6-0.2,5.3-0.8c1.3-0.4,2.4-1.2,3.3-2.2
c0.7-0.8,1.1-1.9,1.1-3c0.1-1.2-0.4-2.4-1.2-3.2c-0.5-0.5-1.1-0.8-1.7-1.2c-0.7-0.3-1.4-0.6-2.2-0.8c-1.8-0.4-3.9-0.9-6.1-1.3
c-1.8-0.2-3.5-0.6-5.1-1s-3.5-1-5-1.6c-0.8-0.2-1.6-0.6-2.4-1l-1-0.7c-0.4-0.4-0.8-0.7-1.2-1c-0.7-0.5-1.3-1.1-1.8-1.8
c-0.6-0.8-1.1-1.7-1.5-2.6c-0.9-2-1.3-4.2-1.2-6.4c0-3,0.9-6,2.7-8.5c1.8-2.5,4.3-4.5,7.2-5.7c3.3-1.4,6.8-2.1,10.3-2
c3.6-0.1,7.1,0.7,10.3,2.4c3,1.5,5.4,3.8,7.1,6.7c1.8,3.2,2.7,6.8,2.7,10.4h-10.7c-0.1-3-1-5.2-2.7-6.7s-4-2.3-7-2.3
s-5.2,0.5-6.8,1.5c-1.4,0.8-2.3,2.2-2.3,3.8c-0.1,1,0.3,2,1,2.6c0.6,0.5,1.2,0.9,1.8,1.2c0.6,0.3,1.3,0.5,2,0.7
c0.8,0.2,1.7,0.4,2.7,0.6l3.1,0.5c3.4,0.5,6.7,1.2,10,2.2c2.6,0.8,5,2.3,6.7,4.5c1.8,2.4,2.9,5.3,3,8.4l0.1,1.8
c0.1,3.2-0.9,6.4-2.8,9.1c-1.9,2.7-4.5,4.8-7.5,6C343.2,87.6,339.5,88.3,335.8,88.2L335.8,88.2z M412.4,74.6
c-2.4,4.2-5.9,7.7-10.2,10c-4.5,2.4-9.6,3.7-14.8,3.6c-4.9,0.1-9.7-1.2-14-3.6c-4.2-2.4-7.7-5.8-10.1-9.9c-2.5-4.2-3.8-9-3.7-13.8
c-0.1-5.1,1.1-10.1,3.6-14.6c2.4-4.2,6-7.7,10.2-10c4.4-2.4,9.3-3.6,14.3-3.6c5,0,9.9,1.2,14.3,3.6c4.2,2.3,7.8,5.7,10.2,9.9
c2.4,4.3,3.7,9.2,3.6,14.2C416.1,65.4,414.8,70.3,412.4,74.6L412.4,74.6z M387.8,43.3c-3,0-6,0.8-8.6,2.3c-2.6,1.5-4.8,3.6-6.3,6.2
c-1.5,2.6-2.3,5.5-2.3,8.5c0,4.6,1.8,9,5.2,12.2c3.2,3.4,7.6,5.3,12.3,5.3c3,0,6-0.8,8.5-2.4c2.6-1.5,4.7-3.7,6.2-6.3
c1.5-2.6,2.3-5.6,2.3-8.6c0-3-0.8-6-2.3-8.6c-1.5-2.6-3.7-4.7-6.3-6.2C393.9,44.1,390.9,43.3,387.8,43.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 427 97.5" style="enable-background:new 0 0 427 97.5;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#104D86;}
</style>
<path class="st0" d="M27.5,88.2c-4.9,0-9.7-1.2-14-3.6c-4.2-2.4-7.6-5.8-9.9-10c-4.8-8.8-4.8-19.4,0-28.2c2.3-4.2,5.8-7.7,10-10
c4.3-2.4,9.1-3.7,14-3.6c6-0.1,11.8,1.7,16.5,5.3s8,8.7,9.9,15.4H42.8c-1.3-3-3.4-5.5-6.2-7.2c-2.6-1.6-5.6-2.5-8.7-2.5
c-2.8,0-5.6,0.7-8.1,2s-4.7,3.3-6.1,5.8c-3,5.3-3.1,11.9-0.2,17.3c1.4,2.5,3.4,4.5,5.8,6s5.2,2.3,8,2.2c7.2,0,12.4-3.3,15.4-9.9
h11.4c-1.1,4.2-3.1,8.1-5.8,11.5c-2.5,3-5.6,5.4-9.2,7C35.5,87.4,31.5,88.1,27.5,88.2L27.5,88.2z M99.6,82.1
c-4.6,4-10.6,6.2-16.8,6.1c-4.9,0-9.7-1.2-14-3.6c-4.1-2.4-7.5-5.8-9.8-10c-2.5-4.5-3.7-9.5-3.6-14.6c-0.1-15,11.9-27.2,26.9-27.3
c0.1,0,0.2,0,0.3,0c6.4-0.2,12.6,2.1,17.5,6.2v-5.2h11.5V87h-12V82.1z M83.5,43.7c-3,0-5.9,0.7-8.4,2.2c-2.5,1.4-4.6,3.5-6,6
c-1.5,2.5-2.2,5.4-2.2,8.4c-0.2,4.5,1.5,8.9,4.7,12.2c3.2,3.2,7.6,4.9,12.1,4.8c2.9,0,5.8-0.7,8.3-2.2c2.5-1.4,4.5-3.5,5.9-6
c2.9-5.3,2.9-11.7,0-17c-1.4-2.5-3.5-4.6-6-6C89.3,44.5,86.4,43.8,83.5,43.7L83.5,43.7z M122,14.8h9.7V87H122
C122,87.1,122,14.8,122,14.8z M149.8,65.2c0.5,2.3,1.5,4.4,3,6.3c1.4,1.8,3.3,3.2,5.4,4.1c2.2,1,4.6,1.5,7,1.5
c2.8,0.1,5.6-0.5,8.1-1.8c2.4-1.4,4.3-3.4,5.5-5.9h11.9c-2.7,6.5-6.2,11.2-10.4,14.2c-4.4,3.1-9.8,4.7-15.2,4.5
c-4.8,0.1-9.6-1.2-13.8-3.6c-4.1-2.4-7.5-5.8-9.7-10c-2.4-4.3-3.6-9.2-3.5-14.1c-0.1-4.9,1.1-9.8,3.5-14.1c2.3-4.2,5.7-7.6,9.8-10
c8.6-4.8,19.2-4.8,27.7,0.1c4.1,2.4,7.4,6,9.6,10.2c2.2,4.3,3.3,9.1,3.3,14c0,1-0.1,2.5-0.3,4.5h-41.9V65.2z M165.2,43.6
c-3.4-0.1-6.8,0.9-9.6,2.9c-2.7,2-4.7,4.8-5.6,8h30.2c-0.9-3.2-2.9-6-5.6-8C171.9,44.5,168.6,43.5,165.2,43.6z M234.8,57.3
c0-4.7-1-8.2-3-10.5s-4.9-3.5-8.7-3.5c-2.3,0-4.6,0.7-6.5,2c-2,1.3-3.7,3.2-4.8,5.4c-1.2,2.3-1.8,4.8-1.8,7.4V87h-11.2V33.8h10.5
v4.8c3.9-3.9,9.3-6,14.9-5.8c3.9,0,7.7,1,11,3s6,4.8,7.9,8.2c1.9,3.5,2.9,7.5,2.9,11.5v31.6h-11.2L234.8,57.3L234.8,57.3z
M296.7,82.2c-2.4,1.9-5,3.4-7.9,4.4c-3,1-6.2,1.5-9.4,1.5c-4.8,0.1-9.6-1.2-13.8-3.7c-4.1-2.4-7.5-5.9-9.8-10.1
c-2.4-4.3-3.6-9.1-3.6-14.1c-0.1-5.1,1.1-10.1,3.5-14.7c2.3-4.2,5.7-7.7,9.8-10.1c4.3-2.5,9.2-3.8,14.1-3.7c3.1,0,6.1,0.5,9,1.4
c2.8,0.9,5.4,2.3,7.8,4.1V14.9h11.3v72.2h-11.1L296.7,82.2L296.7,82.2z M280.4,43.1c-3,0-5.9,0.7-8.4,2.2c-2.5,1.4-4.6,3.5-6,6
c-1.5,2.6-2.2,5.5-2.2,8.5c-0.2,4.6,1.5,9.1,4.7,12.4c3.2,3.2,7.5,5,12.1,4.8c2.9,0,5.8-0.7,8.3-2.3c2.5-1.5,4.5-3.6,5.9-6.1
c2.9-5.4,2.9-11.8,0-17.2C291.8,46.2,286.3,43,280.4,43.1z M335.8,88.2c-3.8,0.1-7.5-0.8-10.8-2.4c-3.1-1.6-5.7-4-7.5-7
c-1.9-3.3-2.9-7.1-3-10.9h11c0,2.7,1.1,5.3,3.1,7.1c2.1,1.7,4.8,2.6,7.5,2.5c1.8,0,3.6-0.2,5.3-0.8c1.3-0.4,2.4-1.2,3.3-2.2
c0.7-0.8,1.1-1.9,1.1-3c0.1-1.2-0.4-2.4-1.2-3.2c-0.5-0.5-1.1-0.8-1.7-1.2c-0.7-0.3-1.4-0.6-2.2-0.8c-1.8-0.4-3.9-0.9-6.1-1.3
c-1.8-0.2-3.5-0.6-5.1-1s-3.5-1-5-1.6c-0.8-0.2-1.6-0.6-2.4-1l-1-0.7c-0.4-0.4-0.8-0.7-1.2-1c-0.7-0.5-1.3-1.1-1.8-1.8
c-0.6-0.8-1.1-1.7-1.5-2.6c-0.9-2-1.3-4.2-1.2-6.4c0-3,0.9-6,2.7-8.5c1.8-2.5,4.3-4.5,7.2-5.7c3.3-1.4,6.8-2.1,10.3-2
c3.6-0.1,7.1,0.7,10.3,2.4c3,1.5,5.4,3.8,7.1,6.7c1.8,3.2,2.7,6.8,2.7,10.4H345c-0.1-3-1-5.2-2.7-6.7s-4-2.3-7-2.3s-5.2,0.5-6.8,1.5
c-1.4,0.8-2.3,2.2-2.3,3.8c-0.1,1,0.3,2,1,2.6c0.6,0.5,1.2,0.9,1.8,1.2c0.6,0.3,1.3,0.5,2,0.7c0.8,0.2,1.7,0.4,2.7,0.6l3.1,0.5
c3.4,0.5,6.7,1.2,10,2.2c2.6,0.8,5,2.3,6.7,4.5c1.8,2.4,2.9,5.3,3,8.4l0.1,1.8c0.1,3.2-0.9,6.4-2.8,9.1s-4.5,4.8-7.5,6
C343.2,87.6,339.5,88.3,335.8,88.2L335.8,88.2z M412.4,74.6c-2.4,4.2-5.9,7.7-10.2,10c-4.5,2.4-9.6,3.7-14.8,3.6
c-4.9,0.1-9.7-1.2-14-3.6c-4.2-2.4-7.7-5.8-10.1-9.9c-2.5-4.2-3.8-9-3.7-13.8c-0.1-5.1,1.1-10.1,3.6-14.6c2.4-4.2,6-7.7,10.2-10
c4.4-2.4,9.3-3.6,14.3-3.6s9.9,1.2,14.3,3.6c4.2,2.3,7.8,5.7,10.2,9.9c2.4,4.3,3.7,9.2,3.6,14.2C416.1,65.4,414.8,70.3,412.4,74.6
L412.4,74.6z M387.8,43.3c-3,0-6,0.8-8.6,2.3s-4.8,3.6-6.3,6.2c-1.5,2.6-2.3,5.5-2.3,8.5c0,4.6,1.8,9,5.2,12.2
c3.2,3.4,7.6,5.3,12.3,5.3c3,0,6-0.8,8.5-2.4c2.6-1.5,4.7-3.7,6.2-6.3c1.5-2.6,2.3-5.6,2.3-8.6s-0.8-6-2.3-8.6s-3.7-4.7-6.3-6.2
C393.9,44.1,390.9,43.3,387.8,43.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 566.93 106.87"><defs><style>.cls-1,.cls-2{fill:#fff;}.cls-1,.cls-3{fill-rule:evenodd;}.cls-3{fill:#2492eb;}</style></defs><path class="cls-1" d="M178.41,87.12a28.08,28.08,0,0,1-14-3.59,25.91,25.91,0,0,1-9.94-10,29.62,29.62,0,0,1,0-28.25,25.88,25.88,0,0,1,10-10,28,28,0,0,1,14-3.61A26.53,26.53,0,0,1,195,37q7,5.33,9.86,15.41H193.68a15.82,15.82,0,0,0-6.17-7.16,16.32,16.32,0,0,0-8.68-2.46,17.22,17.22,0,0,0-8.07,2,15.63,15.63,0,0,0-6.14,5.81,17.85,17.85,0,0,0-.19,17.26,16.41,16.41,0,0,0,5.79,6,14.94,14.94,0,0,0,8,2.23c7.23,0,12.37-3.29,15.37-9.89h11.35a31.36,31.36,0,0,1-5.77,11.52,24.73,24.73,0,0,1-9.16,7,28.61,28.61,0,0,1-11.59,2.34ZM250.48,81a24.7,24.7,0,0,1-16.77,6.15,28,28,0,0,1-14-3.59,26.15,26.15,0,0,1-9.84-10A29.21,29.21,0,0,1,206.22,59a27.1,27.1,0,0,1,27.2-27.29,25.66,25.66,0,0,1,17.49,6.25V32.72h11.5V86.07H250.48V81ZM234.34,42.66a16.53,16.53,0,0,0-8.42,2.2,16.21,16.21,0,0,0-6,6,16.38,16.38,0,0,0-2.22,8.37,16.59,16.59,0,0,0,4.71,12.16,16.38,16.38,0,0,0,12.11,4.75,16,16,0,0,0,8.27-2.19,16.31,16.31,0,0,0,5.94-6,17.58,17.58,0,0,0,0-17,16.15,16.15,0,0,0-6-6,16.33,16.33,0,0,0-8.4-2.22Zm38.57-28.87h9.67V86h-9.67V13.79Zm27.77,50.36a15.34,15.34,0,0,0,3,6.27,14.48,14.48,0,0,0,5.36,4.14,16.65,16.65,0,0,0,7,1.47,16.84,16.84,0,0,0,8.1-1.75,14.78,14.78,0,0,0,5.55-5.89h11.87q-4.08,9.74-10.36,14.24a25.3,25.3,0,0,1-15.16,4.49,27.13,27.13,0,0,1-13.8-3.59,25.74,25.74,0,0,1-9.74-10A28.65,28.65,0,0,1,289,59.4a28.65,28.65,0,0,1,3.51-14.09,25.62,25.62,0,0,1,9.79-10,28.15,28.15,0,0,1,27.74.09,25.36,25.36,0,0,1,9.57,10.24,30.18,30.18,0,0,1,3.26,14c0,1-.1,2.47-.29,4.51ZM316.1,42.53a15.83,15.83,0,0,0-9.56,2.92,14.91,14.91,0,0,0-5.58,8h30.18a15.2,15.2,0,0,0-5.59-8,15.63,15.63,0,0,0-9.45-2.92Zm69.63,13.73q0-7-3-10.46c-2-2.35-4.89-3.51-8.68-3.51a11.46,11.46,0,0,0-6.48,2,14.08,14.08,0,0,0-4.76,5.36A15.6,15.6,0,0,0,361,57.08V86H349.79V32.72h10.46v4.76a20.2,20.2,0,0,1,14.87-5.82,20.87,20.87,0,0,1,11,3,21.54,21.54,0,0,1,7.89,8.21,23.45,23.45,0,0,1,2.88,11.55V86h-11.2V56.26Zm61.81,24.91a25.73,25.73,0,0,1-7.91,4.44,28,28,0,0,1-9.35,1.51,26.4,26.4,0,0,1-13.75-3.69,26.94,26.94,0,0,1-9.78-10.07,28.29,28.29,0,0,1-3.57-14.06,30.39,30.39,0,0,1,3.46-14.67,26.14,26.14,0,0,1,9.79-10.14,27.27,27.27,0,0,1,14.14-3.69,28.53,28.53,0,0,1,9,1.4,25.51,25.51,0,0,1,7.78,4.08V13.84h11.3V86h-11.1V81.17ZM431.24,42a16.34,16.34,0,0,0-8.41,2.22,16.05,16.05,0,0,0-6,6,16.75,16.75,0,0,0-2.18,8.49,17.11,17.11,0,0,0,4.71,12.4,16.07,16.07,0,0,0,12.06,4.8,15.71,15.71,0,0,0,8.31-2.26,16.37,16.37,0,0,0,5.94-6.14,17.92,17.92,0,0,0,0-17.17A16.44,16.44,0,0,0,431.24,42Zm55.44,45.13a23.84,23.84,0,0,1-10.84-2.39,18.14,18.14,0,0,1-7.46-7,22.68,22.68,0,0,1-3-10.94h11a10,10,0,0,0,3.08,7.12A11,11,0,0,0,487,76.41a15.51,15.51,0,0,0,5.33-.82,7.27,7.27,0,0,0,3.31-2.18,4.68,4.68,0,0,0,1.1-3,4.06,4.06,0,0,0-1.24-3.19,8.52,8.52,0,0,0-1.72-1.16,13.45,13.45,0,0,0-2.17-.81c-1.82-.44-3.87-.86-6.15-1.29-1.76-.25-3.46-.58-5.1-1s-3.47-1-5-1.59A11.16,11.16,0,0,1,473,60.35l-1-.71a9.64,9.64,0,0,0-1.2-1,8.44,8.44,0,0,1-1.77-1.82,17.51,17.51,0,0,1-1.49-2.61,14.27,14.27,0,0,1-1.25-6.43A14.21,14.21,0,0,1,469,39.29a16.77,16.77,0,0,1,7.2-5.65,25.49,25.49,0,0,1,10.29-2A22.09,22.09,0,0,1,496.82,34a17.22,17.22,0,0,1,7.12,6.73,21.13,21.13,0,0,1,2.74,10.4H496c-.14-3-1.05-5.22-2.69-6.73s-4-2.26-7-2.26-5.24.51-6.76,1.54a4.49,4.49,0,0,0-2.28,3.84,3.12,3.12,0,0,0,1,2.64,8.49,8.49,0,0,0,1.84,1.18,10,10,0,0,0,2,.66c.81.21,1.69.43,2.69.62l3.12.53a65.57,65.57,0,0,1,10,2.21,14,14,0,0,1,6.74,4.46,14.62,14.62,0,0,1,3,8.37l.1,1.82A15.47,15.47,0,0,1,505,79.1a17.09,17.09,0,0,1-7.46,6,27.44,27.44,0,0,1-10.9,2.06Zm76.61-13.55a26.23,26.23,0,0,1-10.19,10,30.43,30.43,0,0,1-14.77,3.59,27.65,27.65,0,0,1-14-3.64,27.26,27.26,0,0,1-10.08-9.92,26.66,26.66,0,0,1-3.73-13.83,28.7,28.7,0,0,1,3.61-14.56,25.87,25.87,0,0,1,10.24-10,29.05,29.05,0,0,1,14.32-3.56,29.59,29.59,0,0,1,14.32,3.61,26.71,26.71,0,0,1,10.24,9.9,28.3,28.3,0,0,1,3.64,14.23,28.21,28.21,0,0,1-3.64,14.17ZM538.73,42.29a17.06,17.06,0,0,0-8.63,2.26,16.7,16.7,0,0,0-6.28,6.15,16.46,16.46,0,0,0-2.3,8.5,16.65,16.65,0,0,0,5.18,12.16A17,17,0,0,0,539,76.71a16.16,16.16,0,0,0,8.53-2.37A17.52,17.52,0,0,0,553.72,68,17,17,0,0,0,556,59.4a16.38,16.38,0,0,0-2.33-8.58,17.24,17.24,0,0,0-6.3-6.22A16.89,16.89,0,0,0,538.73,42.29Z" transform="translate(0)"/><rect class="cls-2" x="19.35" width="10.11" height="18.5" rx="5.05"/><rect class="cls-2" x="68.31" width="10.11" height="18.5" rx="5.05"/><path class="cls-1" d="M11,9.1h3v4.35A10.43,10.43,0,0,0,24.4,23.84h0A10.42,10.42,0,0,0,34.79,13.45V9.1H63v4.35A10.42,10.42,0,0,0,73.36,23.84h0A10.42,10.42,0,0,0,83.75,13.45V9.1h3a11,11,0,0,1,11,11V95.91a11,11,0,0,1-11,11H48.54L0,58.33V20.07a11,11,0,0,1,11-11ZM68.21,33.64H29.31a5,5,0,0,0-5,5v38.9a5,5,0,0,0,5,5H61.1L73.24,94.74V38.67A5,5,0,0,0,68.21,33.64Z" transform="translate(0)"/><polygon class="cls-3" points="48.54 58.33 0 58.33 48.54 106.87 48.54 58.33"/></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -0,0 +1,76 @@
@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

@@ -0,0 +1,14 @@
.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

@@ -0,0 +1,25 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './components/buttons.css';
@import './components/spinner.css';
body {
background-color: #f3f4f6;
}
.text-white-important {
color: white !important;
}
@layer utilities {
.transition-max-width {
-webkit-transition-property: max-width;
transition-property: max-width;
}
}
#timeZone input:focus {
box-shadow: none;
}

View File

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

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

3414
examples/calendso/yarn.lock Normal file

File diff suppressed because it is too large Load Diff