Merge pull request #290 from Malte-D/feature/custom-fields-on-the-booking-page
Feature/custom fields on the booking page
This commit is contained in:
commit
cd0974d8a6
6 changed files with 250 additions and 6 deletions
13
lib/eventTypeInput.ts
Normal file
13
lib/eventTypeInput.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
export enum EventTypeCustomInputType {
|
||||||
|
Text = 'text',
|
||||||
|
TextLong = 'textLong',
|
||||||
|
Number = 'number',
|
||||||
|
Bool = 'bool',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventTypeCustomInput {
|
||||||
|
id?: number;
|
||||||
|
type: EventTypeCustomInputType;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import PhoneInput from 'react-phone-number-input';
|
||||||
import {LocationType} from '../../lib/location';
|
import {LocationType} from '../../lib/location';
|
||||||
import Avatar from '../../components/Avatar';
|
import Avatar from '../../components/Avatar';
|
||||||
import Button from '../../components/ui/Button';
|
import Button from '../../components/ui/Button';
|
||||||
|
import {EventTypeCustomInputType} from "../../lib/eventTypeInput";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
@ -49,12 +50,31 @@ export default function Book(props) {
|
||||||
const bookingHandler = event => {
|
const bookingHandler = event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
let notes = "";
|
||||||
|
if (props.eventType.customInputs) {
|
||||||
|
notes = props.eventType.customInputs.map(input => {
|
||||||
|
const data = event.target["custom_" + input.id];
|
||||||
|
if (!!data) {
|
||||||
|
if (input.type === EventTypeCustomInputType.Bool) {
|
||||||
|
return input.label + "\n" + (data.value ? "Yes" : "No")
|
||||||
|
} else {
|
||||||
|
return input.label + "\n" + data.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).join("\n\n")
|
||||||
|
}
|
||||||
|
if (!!notes && !!event.target.notes.value) {
|
||||||
|
notes += "\n\nAdditional notes:\n" + event.target.notes.value;
|
||||||
|
} else {
|
||||||
|
notes += event.target.notes.value;
|
||||||
|
}
|
||||||
|
|
||||||
let payload = {
|
let payload = {
|
||||||
start: dayjs(date).format(),
|
start: dayjs(date).format(),
|
||||||
end: dayjs(date).add(props.eventType.length, 'minute').format(),
|
end: dayjs(date).add(props.eventType.length, 'minute').format(),
|
||||||
name: event.target.name.value,
|
name: event.target.name.value,
|
||||||
email: event.target.email.value,
|
email: event.target.email.value,
|
||||||
notes: event.target.notes.value,
|
notes: notes,
|
||||||
timeZone: preferredTimeZone,
|
timeZone: preferredTimeZone,
|
||||||
eventTypeId: props.eventType.id,
|
eventTypeId: props.eventType.id,
|
||||||
rescheduleUid: rescheduleUid
|
rescheduleUid: rescheduleUid
|
||||||
|
@ -143,9 +163,38 @@ export default function Book(props) {
|
||||||
<PhoneInput name="phone" placeholder="Enter phone number" id="phone" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" onChange={() => {}} />
|
<PhoneInput name="phone" placeholder="Enter phone number" id="phone" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" onChange={() => {}} />
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
{props.eventType.customInputs && props.eventType.customInputs.sort((a,b) => a.id - b.id).map(input => (
|
||||||
|
<div className="mb-4">
|
||||||
|
{input.type !== EventTypeCustomInputType.Bool &&
|
||||||
|
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700 mb-1">{input.label}</label>}
|
||||||
|
{input.type === EventTypeCustomInputType.TextLong &&
|
||||||
|
<textarea name={"custom_" + input.id} id={"custom_" + input.id}
|
||||||
|
required={input.required}
|
||||||
|
rows={3}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder=""/>}
|
||||||
|
{input.type === EventTypeCustomInputType.Text &&
|
||||||
|
<input type="text" name={"custom_" + input.id} id={"custom_" + input.id}
|
||||||
|
required={input.required}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder=""/>}
|
||||||
|
{input.type === EventTypeCustomInputType.Number &&
|
||||||
|
<input type="number" name={"custom_" + input.id} id={"custom_" + input.id}
|
||||||
|
required={input.required}
|
||||||
|
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
|
placeholder=""/>}
|
||||||
|
{input.type === EventTypeCustomInputType.Bool &&
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input type="checkbox" name={"custom_" + input.id} id={"custom_" + input.id}
|
||||||
|
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
|
||||||
|
placeholder=""/>
|
||||||
|
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700">{input.label}</label>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label>
|
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">Additional notes</label>
|
||||||
<textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Please share anything that will help prepare for our meeting." defaultValue={props.booking ? props.booking.description : ''}></textarea>
|
<textarea name="notes" id="notes" rows={3} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Please share anything that will help prepare for our meeting." defaultValue={props.booking ? props.booking.description : ''}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<Button type="submit" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button>
|
<Button type="submit" className="btn btn-primary">{rescheduleUid ? 'Reschedule' : 'Confirm'}</Button>
|
||||||
|
@ -188,6 +237,7 @@ export async function getServerSideProps(context) {
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
|
customInputs: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,28 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
length: parseInt(req.body.length),
|
length: parseInt(req.body.length),
|
||||||
hidden: req.body.hidden,
|
hidden: req.body.hidden,
|
||||||
locations: req.body.locations,
|
locations: req.body.locations,
|
||||||
eventName: req.body.eventName
|
eventName: req.body.eventName,
|
||||||
|
customInputs: !req.body.customInputs
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
createMany: {
|
||||||
|
data: req.body.customInputs.filter(input => !input.id).map(input => ({
|
||||||
|
type: input.type,
|
||||||
|
label: input.label,
|
||||||
|
required: input.required
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
update: req.body.customInputs.filter(input => !!input.id).map(input => ({
|
||||||
|
data: {
|
||||||
|
type: input.type,
|
||||||
|
label: input.label,
|
||||||
|
required: input.required
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: input.id
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.method == "POST") {
|
if (req.method == "POST") {
|
||||||
|
|
|
@ -6,16 +6,33 @@ import Select, {OptionBase} from 'react-select';
|
||||||
import prisma from '../../../lib/prisma';
|
import prisma from '../../../lib/prisma';
|
||||||
import {LocationType} from '../../../lib/location';
|
import {LocationType} from '../../../lib/location';
|
||||||
import Shell from '../../../components/Shell';
|
import Shell from '../../../components/Shell';
|
||||||
import {getSession, useSession} from 'next-auth/client';
|
import { useSession, getSession } from 'next-auth/client';
|
||||||
import {LocationMarkerIcon, PhoneIcon, PlusCircleIcon, XIcon,} from '@heroicons/react/outline';
|
import {
|
||||||
|
LocationMarkerIcon,
|
||||||
|
PlusCircleIcon,
|
||||||
|
XIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
} from '@heroicons/react/outline';
|
||||||
|
import {EventTypeCustomInput, EventTypeCustomInputType} from "../../../lib/eventTypeInput";
|
||||||
|
import {PlusIcon} from "@heroicons/react/solid";
|
||||||
|
|
||||||
export default function EventType(props) {
|
export default function EventType(props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const inputOptions: OptionBase[] = [
|
||||||
|
{ value: EventTypeCustomInputType.Text, label: 'Text' },
|
||||||
|
{ value: EventTypeCustomInputType.TextLong, label: 'Multiline Text' },
|
||||||
|
{ value: EventTypeCustomInputType.Number, label: 'Number', },
|
||||||
|
{ value: EventTypeCustomInputType.Bool, label: 'Checkbox', },
|
||||||
|
]
|
||||||
|
|
||||||
const [ session, loading ] = useSession();
|
const [ session, loading ] = useSession();
|
||||||
const [ showLocationModal, setShowLocationModal ] = useState(false);
|
const [ showLocationModal, setShowLocationModal ] = useState(false);
|
||||||
|
const [ showAddCustomModal, setShowAddCustomModal ] = useState(false);
|
||||||
const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined);
|
const [ selectedLocation, setSelectedLocation ] = useState<OptionBase | undefined>(undefined);
|
||||||
|
const [ selectedInputOption, setSelectedInputOption ] = useState<OptionBase>(inputOptions[0]);
|
||||||
const [ locations, setLocations ] = useState(props.eventType.locations || []);
|
const [ locations, setLocations ] = useState(props.eventType.locations || []);
|
||||||
|
const [customInputs, setCustomInputs] = useState<EventTypeCustomInput[]>(props.eventType.customInputs.sort((a, b) => a.id - b.id) || []);
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>();
|
const titleRef = useRef<HTMLInputElement>();
|
||||||
const slugRef = useRef<HTMLInputElement>();
|
const slugRef = useRef<HTMLInputElement>();
|
||||||
|
@ -41,7 +58,7 @@ export default function EventType(props) {
|
||||||
|
|
||||||
const response = await fetch('/api/availability/eventtype', {
|
const response = await fetch('/api/availability/eventtype', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, eventName: enteredEventName }),
|
body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations, eventName: enteredEventName, customInputs }),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
|
@ -80,6 +97,11 @@ export default function EventType(props) {
|
||||||
setShowLocationModal(false);
|
setShowLocationModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeAddCustomModal = () => {
|
||||||
|
setSelectedInputOption(inputOptions[0]);
|
||||||
|
setShowAddCustomModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
const LocationOptions = () => {
|
const LocationOptions = () => {
|
||||||
if (!selectedLocation) {
|
if (!selectedLocation) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -130,6 +152,21 @@ export default function EventType(props) {
|
||||||
setLocations(locations.filter( (location) => location.type !== selectedLocation.type ));
|
setLocations(locations.filter( (location) => location.type !== selectedLocation.type ));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCustom = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const customInput: EventTypeCustomInput = {
|
||||||
|
label: e.target.label.value,
|
||||||
|
required: e.target.required.checked,
|
||||||
|
type: e.target.type.value
|
||||||
|
};
|
||||||
|
|
||||||
|
setCustomInputs(customInputs.concat(customInput));
|
||||||
|
|
||||||
|
console.log(customInput)
|
||||||
|
setShowAddCustomModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -235,6 +272,44 @@ export default function EventType(props) {
|
||||||
<input ref={eventNameRef} type="text" name="title" id="title" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Meeting with {USER}" defaultValue={props.eventType.eventName} />
|
<input ref={eventNameRef} type="text" name="title" id="title" className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Meeting with {USER}" defaultValue={props.eventType.eventName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="additionalFields" className="block text-sm font-medium text-gray-700">Additional Inputs</label>
|
||||||
|
<ul className="w-96 mt-1">
|
||||||
|
{customInputs.map( (customInput) => (
|
||||||
|
<li key={customInput.type} className="bg-blue-50 mb-2 p-2 border">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<span className="ml-2 text-sm">Label: {customInput.label}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="ml-2 text-sm">Type: {customInput.type}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className="ml-2 text-sm">{customInput.required ? "Required" : "Optional"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<button type="button" onClick={() => {
|
||||||
|
}} className="mr-2 text-sm text-blue-600">Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => {
|
||||||
|
}}>
|
||||||
|
<XIcon className="h-6 w-6 border-l-2 pl-1 hover:text-red-500 "/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li>
|
||||||
|
<button type="button" className="sm:flex sm:items-start text-sm text-blue-600" onClick={() => setShowAddCustomModal(true)}>
|
||||||
|
<PlusCircleIcon className="h-6 w-6" />
|
||||||
|
<span className="ml-1">Add another input</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div className="my-8">
|
<div className="my-8">
|
||||||
<div className="relative flex items-start">
|
<div className="relative flex items-start">
|
||||||
<div className="flex items-center h-5">
|
<div className="flex items-center h-5">
|
||||||
|
@ -320,6 +395,66 @@ export default function EventType(props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
{showAddCustomModal &&
|
||||||
|
<div className="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"/>
|
||||||
|
|
||||||
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
|
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
|
<div className="sm:flex sm:items-start mb-4">
|
||||||
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
<PlusIcon className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">Add new custom input field</h3>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
This input will be shown when booking this event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={updateCustom}>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label htmlFor="type" className="block text-sm font-medium text-gray-700">Input type</label>
|
||||||
|
<Select
|
||||||
|
name="type"
|
||||||
|
defaultValue={selectedInputOption}
|
||||||
|
options={inputOptions}
|
||||||
|
isSearchable="false"
|
||||||
|
required
|
||||||
|
className="mb-2 flex-1 block w-full focus:ring-blue-500 focus:border-blue-500 min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300 mt-1"
|
||||||
|
onChange={setSelectedInputOption}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label htmlFor="label" className="block text-sm font-medium text-gray-700">Label</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<input type="text" name="label" id="label" required className="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input id="required" name="required" type="checkbox" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" defaultChecked={true}/>
|
||||||
|
<label htmlFor="required" className="block text-sm font-medium text-gray-700">
|
||||||
|
Is required
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={closeAddCustomModal} type="button" className="btn btn-white mr-2">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</Shell>
|
</Shell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -352,6 +487,7 @@ export async function getServerSideProps(context) {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
eventName: true,
|
eventName: true,
|
||||||
|
customInputs: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EventTypeCustomInput" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"eventTypeId" INTEGER NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"required" BOOLEAN NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EventTypeCustomInput" ADD FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -22,6 +22,7 @@ model EventType {
|
||||||
userId Int?
|
userId Int?
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
eventName String?
|
eventName String?
|
||||||
|
customInputs EventTypeCustomInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
|
@ -131,3 +132,13 @@ model SelectedCalendar {
|
||||||
externalId String
|
externalId String
|
||||||
@@id([userId,integration,externalId])
|
@@id([userId,integration,externalId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model EventTypeCustomInput {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
eventTypeId Int
|
||||||
|
eventType EventType @relation(fields: [eventTypeId], references: [id])
|
||||||
|
label String
|
||||||
|
type String
|
||||||
|
required Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue