Add booking flow
parent
f260e295f5
commit
d769c3943c
|
@ -1,5 +1,8 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# .env file
|
||||||
|
.env
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
|
|
|
@ -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.
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
let prisma: PrismaClient
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
prisma = new PrismaClient()
|
||||||
|
} else {
|
||||||
|
if (!global.prisma) {
|
||||||
|
global.prisma = new PrismaClient()
|
||||||
|
}
|
||||||
|
prisma = global.prisma
|
||||||
|
}
|
||||||
|
|
||||||
|
export default prisma
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/types/global" />
|
13
package.json
13
package.json
|
@ -8,8 +8,21 @@
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "2.18.0",
|
||||||
|
"@tailwindcss/forms": "^0.2.1",
|
||||||
|
"dayjs": "^1.10.4",
|
||||||
|
"googleapis": "^67.1.1",
|
||||||
"next": "10.0.8",
|
"next": "10.0.8",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1"
|
"react-dom": "17.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^14.14.33",
|
||||||
|
"@types/react": "^17.0.3",
|
||||||
|
"autoprefixer": "^10.2.5",
|
||||||
|
"postcss": "^8.2.8",
|
||||||
|
"prisma": "2.18.0",
|
||||||
|
"tailwindcss": "^2.0.3",
|
||||||
|
"typescript": "^4.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
|
||||||
|
export default function User(props) {
|
||||||
|
const eventTypes = props.user.eventTypes.map(type =>
|
||||||
|
<Link href={props.user.username + '/' + type.id.toString()}>
|
||||||
|
<a>
|
||||||
|
<li key={type.id} className="px-6 py-4">
|
||||||
|
<div className="inline-block w-3 h-3 rounded-full bg-blue-600 mr-2"></div>
|
||||||
|
<h2 className="inline-block font-medium">{type.title}</h2>
|
||||||
|
<p className="inline-block text-gray-400 ml-2">{type.description}</p>
|
||||||
|
</li>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>{props.user.name} | Calendso</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<main className="max-w-2xl mx-auto my-24">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<img src={props.user.avatar} alt="Avatar" 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}</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>
|
||||||
|
</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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
import {useEffect, useState} from 'react'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import prisma from '../../lib/prisma'
|
||||||
|
const dayjs = require('dayjs')
|
||||||
|
const isSameOrBefore = require('dayjs/plugin/isSameOrBefore')
|
||||||
|
dayjs.extend(isSameOrBefore)
|
||||||
|
|
||||||
|
export default function Type(props) {
|
||||||
|
// Initialise state
|
||||||
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState(dayjs().month());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState([]);
|
||||||
|
|
||||||
|
// Handle month changes
|
||||||
|
const incrementMonth = () => {
|
||||||
|
setSelectedMonth(selectedMonth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrementMonth = () => {
|
||||||
|
setSelectedMonth(selectedMonth - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up calendar
|
||||||
|
var daysInMonth = dayjs().month(selectedMonth).daysInMonth()
|
||||||
|
var days = []
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = days.map((day) =>
|
||||||
|
<button onClick={(e) => setSelectedDate(dayjs().month(selectedMonth).date(day).format("YYYY-MM-DD"))} disabled={selectedMonth < 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
|
||||||
|
useEffect(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('http://localhost:3000/api/availability/bailey?date=' + dayjs(selectedDate).format("YYYY-MM-DD"))
|
||||||
|
const data = await res.json()
|
||||||
|
setBusy(data.primary.busy)
|
||||||
|
setLoading(false)
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
// Set up timeslots
|
||||||
|
let times = []
|
||||||
|
|
||||||
|
// If we're looking at availability throughout the current date, work out the current number of minutes elapsed throughout the day
|
||||||
|
if (selectedDate == dayjs().format("YYYY-MM-DD")) {
|
||||||
|
var i = (parseInt(dayjs().startOf('hour').format('H') * 60) + parseInt(dayjs().startOf('hour').format('m')));
|
||||||
|
} else {
|
||||||
|
var i = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Until day end, push new times every x minutes
|
||||||
|
for (;i < 1440; i += parseInt(props.eventType.length)) {
|
||||||
|
times.push(dayjs(selectedDate).hour(Math.floor(i / 60)).minute(i % 60).startOf(props.eventType.length, 'minute').add(props.eventType.length, 'minute').format("YYYY-MM-DD HH:mm:ss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
times.forEach(time => {
|
||||||
|
busy.forEach(busyTime => {
|
||||||
|
let startTime = dayjs(busyTime.start)
|
||||||
|
let endTime = dayjs(busyTime.end)
|
||||||
|
|
||||||
|
// Check if start times are the same
|
||||||
|
if (dayjs(time).format('HH:mm') == startTime.format('HH:mm')) {
|
||||||
|
const conflictIndex = times.indexOf(time);
|
||||||
|
if (conflictIndex > -1) {
|
||||||
|
times.splice(conflictIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Check if time is between start and end times
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display available times
|
||||||
|
const availableTimes = times.map((time) =>
|
||||||
|
<div>
|
||||||
|
<Link href={"/" + props.user.username + "/book?date=" + selectedDate + "T" + dayjs(time).format("HH:mm:ss") + "Z&type=" + props.eventType.id}>
|
||||||
|
<a key={time} 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).format("hh:mma")}</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>{props.eventType.title} | {props.user.name} | 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 overflow-hidden shadow rounded-lg">
|
||||||
|
<div className="sm:flex px-4 py-5 sm:p-6">
|
||||||
|
<div className={"sm:border-r " + (selectedDate ? 'sm:w-1/3' : 'sm:w-1/2')}>
|
||||||
|
<img src={props.user.avatar} alt="Avatar" 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-4">
|
||||||
|
<svg className="inline-block w-4 h-4 mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{props.eventType.length} minutes
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">{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 < dayjs().format('MM') && 'text-gray-400')} disabled={selectedMonth < dayjs().format('MM')}>
|
||||||
|
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={incrementMonth}>
|
||||||
|
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-y-4 text-center">
|
||||||
|
{calendar}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={"sm:pl-4 mt-8 sm:mt-0 text-center " + (selectedDate ? 'sm:w-1/3' : 'sm:w-1/2 hidden')}>
|
||||||
|
<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>
|
||||||
|
</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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,125 @@
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import prisma from '../../lib/prisma'
|
||||||
|
const dayjs = require('dayjs')
|
||||||
|
|
||||||
|
export default function Book(props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { date } = router.query
|
||||||
|
|
||||||
|
const bookingHandler = event => {
|
||||||
|
event.preventDefault()
|
||||||
|
const res = fetch(
|
||||||
|
'http://localhost:3000/api/book/bailey',
|
||||||
|
{
|
||||||
|
body: JSON.stringify({
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
method: 'POST'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
router.push("/success?date=" + date + "&type=" + props.eventType.id + "&user=" + props.user.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>Confirm your {props.eventType.title} with {props.user.name} | 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">
|
||||||
|
<img src={props.user.avatar} alt="Avatar" 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">
|
||||||
|
<svg className="inline-block w-4 h-4 mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{props.eventType.length} minutes
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-600 mb-4">
|
||||||
|
<svg className="inline-block w-4 h-4 mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{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" 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="text" name="email" id="email" 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>
|
||||||
|
<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.id}>
|
||||||
|
<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,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
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 { user } = req.query
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username: user,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
credentials: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let availability = [];
|
||||||
|
|
||||||
|
authorise(getAvailability)
|
||||||
|
|
||||||
|
// Set up Google API credentials
|
||||||
|
function authorise(callback) {
|
||||||
|
const {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web;
|
||||||
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||||
|
oAuth2Client.setCredentials(currentUser.credentials[0].key);
|
||||||
|
callback(oAuth2Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailability(auth) {
|
||||||
|
const calendar = google.calendar({version: 'v3', auth});
|
||||||
|
calendar.freebusy.query({
|
||||||
|
requestBody: {
|
||||||
|
timeMin: req.query.date + "T00:00:00.00Z",
|
||||||
|
timeMax: req.query.date + "T23:59:59.59Z",
|
||||||
|
items: [{
|
||||||
|
"id": "primary"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}, (err, apires) => {
|
||||||
|
if (err) return console.log('The API returned an error: ' + err);
|
||||||
|
availability = apires.data.calendars;
|
||||||
|
res.status(200).json(availability);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||||
|
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 { user } = req.query
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username: user,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
credentials: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authorise(bookEvent)
|
||||||
|
|
||||||
|
// Set up Google API credentials
|
||||||
|
function authorise(callback) {
|
||||||
|
const {client_secret, client_id, redirect_uris} = JSON.parse(credentials).web;
|
||||||
|
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
|
||||||
|
oAuth2Client.setCredentials(currentUser.credentials[0].key);
|
||||||
|
callback(oAuth2Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookEvent(auth) {
|
||||||
|
var event = {
|
||||||
|
'summary': 'Meeting with ' + req.body.name,
|
||||||
|
'description': req.body.notes,
|
||||||
|
'start': {
|
||||||
|
'dateTime': req.body.start,
|
||||||
|
'timeZone': 'Europe/London',
|
||||||
|
},
|
||||||
|
'end': {
|
||||||
|
'dateTime': req.body.end,
|
||||||
|
'timeZone': 'Europe/London',
|
||||||
|
},
|
||||||
|
'attendees': [
|
||||||
|
{'email': req.body.email},
|
||||||
|
],
|
||||||
|
'reminders': {
|
||||||
|
'useDefault': false,
|
||||||
|
'overrides': [
|
||||||
|
{'method': 'email', 'minutes': 60}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendar = google.calendar({version: 'v3', auth});
|
||||||
|
calendar.events.insert({
|
||||||
|
auth: auth,
|
||||||
|
calendarId: 'primary',
|
||||||
|
resource: event,
|
||||||
|
}, function(err, event) {
|
||||||
|
if (err) {
|
||||||
|
console.log('There was an error contacting the Calendar service: ' + err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({message: 'Event created'});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +0,0 @@
|
||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
|
||||||
|
|
||||||
export default (req, res) => {
|
|
||||||
res.status(200).json({ name: 'John Doe' })
|
|
||||||
}
|
|
|
@ -1,65 +0,0 @@
|
||||||
import Head from 'next/head'
|
|
||||||
import styles from '../styles/Home.module.css'
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<Head>
|
|
||||||
<title>Create Next App</title>
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<main className={styles.main}>
|
|
||||||
<h1 className={styles.title}>
|
|
||||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className={styles.description}>
|
|
||||||
Get started by editing{' '}
|
|
||||||
<code className={styles.code}>pages/index.js</code>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className={styles.grid}>
|
|
||||||
<a href="https://nextjs.org/docs" className={styles.card}>
|
|
||||||
<h3>Documentation →</h3>
|
|
||||||
<p>Find in-depth information about Next.js features and API.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://nextjs.org/learn" className={styles.card}>
|
|
||||||
<h3>Learn →</h3>
|
|
||||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<h3>Examples →</h3>
|
|
||||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
|
||||||
className={styles.card}
|
|
||||||
>
|
|
||||||
<h3>Deploy →</h3>
|
|
||||||
<p>
|
|
||||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className={styles.footer}>
|
|
||||||
<a
|
|
||||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Powered by{' '}
|
|
||||||
<img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import Head from 'next/head'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>Calendso</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<main className="text-center">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
Welcome to Calendso!
|
||||||
|
</h1>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,117 @@
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
const dayjs = require('dayjs')
|
||||||
|
|
||||||
|
export default function Success(props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { date } = router.query
|
||||||
|
|
||||||
|
return(
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>Booking Confirmed | {props.eventType.title} with {props.user.name} | 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 transition-opacity" aria-hidden="true">
|
||||||
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-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">
|
||||||
|
<svg className="h-6 w-6 text-green-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</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}.
|
||||||
|
</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-2">
|
||||||
|
<svg className="inline-block w-4 h-4 mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{props.eventType.length} minutes
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
<svg className="inline-block w-4 h-4 mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{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={encodeURI("https://calendar.google.com/calendar/render?action=TEMPLATE&dates=" + dayjs(date).format() + "%2F" + dayjs(date).add(props.eventType.length, 'minute').format() + "&details=" + props.eventType.title + " with " + props.user.name + "&text=" + props.eventType.description)}>
|
||||||
|
<a className="mx-2 btn-wide btn-white">
|
||||||
|
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google icon</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)}>
|
||||||
|
<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 icon</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)}>
|
||||||
|
<a className="mx-2 btn-wide btn-white">
|
||||||
|
<svg className="inline-block w-4 h-4 mr-1 -mt-1" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Microsoft Office icon</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>
|
||||||
|
</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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
// 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
|
||||||
|
description String?
|
||||||
|
length Int
|
||||||
|
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
|
||||||
|
bio String?
|
||||||
|
avatar String?
|
||||||
|
createdDate DateTime @default(now()) @map(name: "created")
|
||||||
|
eventTypes EventType[]
|
||||||
|
credentials Credential[]
|
||||||
|
@@map(name: "users")
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
@ -1,4 +0,0 @@
|
||||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.1 KiB |
|
@ -1,122 +0,0 @@
|
||||||
.container {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
padding: 5rem 0;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
width: 100%;
|
|
||||||
height: 100px;
|
|
||||||
border-top: 1px solid #eaeaea;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer img {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title a {
|
|
||||||
color: #0070f3;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title a:hover,
|
|
||||||
.title a:focus,
|
|
||||||
.title a:active {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.15;
|
|
||||||
font-size: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title,
|
|
||||||
.description {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code {
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
|
||||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
max-width: 800px;
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
margin: 1rem;
|
|
||||||
flex-basis: 45%;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: left;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
border: 1px solid #eaeaea;
|
|
||||||
border-radius: 10px;
|
|
||||||
transition: color 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover,
|
|
||||||
.card:focus,
|
|
||||||
.card:active {
|
|
||||||
color: #0070f3;
|
|
||||||
border-color: #0070f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h3 {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.grid {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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); }
|
||||||
|
}
|
|
@ -1,16 +1,21 @@
|
||||||
html,
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import './components/buttons.css';
|
||||||
|
@import './components/spinner.css';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding: 0;
|
background-color: #f3f4f6;
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
|
||||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
.text-white-important {
|
||||||
color: inherit;
|
color: white !important;
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
@layer utilities {
|
||||||
box-sizing: border-box;
|
.transition-max-width {
|
||||||
}
|
-webkit-transition-property: max-width;
|
||||||
|
transition-property: max-width;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/forms'),
|
||||||
|
],
|
||||||
|
}
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue