From a6c3c7fbb3d25e5445094acdfe447a7e6c55b69e Mon Sep 17 00:00:00 2001
From: Alex van Andel
Date: Sat, 8 May 2021 19:03:47 +0000
Subject: [PATCH] Implemented configurable eventType phone or physical
locations.
---
lib/calendarClient.ts | 58 ++++++----
lib/location.ts | 6 +
package.json | 2 +
pages/[user]/book.tsx | 53 ++++++++-
pages/api/availability/eventtype.ts | 118 +++++++------------
pages/api/book/[user].ts | 1 +
pages/availability/event/[type].tsx | 168 +++++++++++++++++++++++++++-
pages/success.tsx | 24 ++--
prisma/schema.prisma | 1 +
yarn.lock | 37 +++++-
10 files changed, 349 insertions(+), 119 deletions(-)
create mode 100644 lib/location.ts
diff --git a/lib/calendarClient.ts b/lib/calendarClient.ts
index dae3ccb0..dcfaefc9 100644
--- a/lib/calendarClient.ts
+++ b/lib/calendarClient.ts
@@ -49,6 +49,7 @@ interface CalendarEvent {
timeZone: string;
endTime: string;
description?: string;
+ location?: string;
organizer: { name?: string, email: string };
attendees: { name?: string, email: string }[];
};
@@ -57,28 +58,37 @@ const MicrosoftOffice365Calendar = (credential) => {
const auth = o365Auth(credential);
- const translateEvent = (event: CalendarEvent) => ({
- 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
+ const translateEvent = (event: CalendarEvent) => {
+
+ let optional = {};
+ if (event.location) {
+ optional.location = { displayName: event.location };
+ }
+
+ return {
+ subject: event.title,
+ body: {
+ contentType: 'HTML',
+ content: event.description,
},
- type: "required"
- }))
- });
+ 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) => {
@@ -119,7 +129,7 @@ const MicrosoftOffice365Calendar = (credential) => {
'Content-Type': 'application/json',
},
body: JSON.stringify(translateEvent(event))
- }))
+ }).then(handleErrors))
}
};
@@ -165,6 +175,10 @@ const GoogleCalendar = (credential) => {
},
};
+ if (event.location) {
+ payload['location'] = event.location;
+ }
+
const calendar = google.calendar({version: 'v3', auth: myGoogleAuth });
calendar.events.insert({
auth: myGoogleAuth,
diff --git a/lib/location.ts b/lib/location.ts
new file mode 100644
index 00000000..b1ec56af
--- /dev/null
+++ b/lib/location.ts
@@ -0,0 +1,6 @@
+
+export enum LocationType {
+ InPerson = 'inPerson',
+ Phone = 'phone',
+}
+
diff --git a/package.json b/package.json
index 7deef52d..4eeb16ce 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,8 @@
"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": {
diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx
index ce56b0f6..ad59279e 100644
--- a/pages/[user]/book.tsx
+++ b/pages/[user]/book.tsx
@@ -1,22 +1,39 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
-import { ClockIcon, CalendarIcon } from '@heroicons/react/solid';
+import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid';
import prisma from '../../lib/prisma';
import {collectPageParameters, telemetryEventTypes, useTelemetry} from "../../lib/telemetry";
-import {useEffect} from "react";
-const dayjs = require('dayjs');
+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';
export default function Book(props) {
const router = useRouter();
const { date, user } = router.query;
+ const [ selectedLocation, setSelectedLocation ] = useState(props.eventType.locations.length === 1 ? props.eventType.locations[0].type : '');
const telemetry = useTelemetry();
useEffect(() => {
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.timeSelected, collectPageParameters()));
- })
+ });
+
+ const locationInfo = (type: LocationType) => props.eventType.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();
+
+ const locationText = selectedLocation === LocationType.Phone ? event.target.phone.value : locationInfo(selectedLocation).address;
+
telemetry.withJitsu(jitsu => jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters()));
const res = fetch(
'/api/book/' + user,
@@ -26,6 +43,7 @@ export default function Book(props) {
end: dayjs(date).add(props.eventType.length, 'minute').format(),
name: event.target.name.value,
email: event.target.email.value,
+ location: locationText,
notes: event.target.notes.value
}),
headers: {
@@ -34,7 +52,8 @@ export default function Book(props) {
method: 'POST'
}
);
- router.push("/success?date=" + date + "&type=" + props.eventType.id + "&user=" + props.user.username);
+
+ router.push(`/success?date=${date}&type=${props.eventType.id}&user=${props.user.username}&location=${encodeURIComponent(locationText)}`);
}
return (
@@ -55,6 +74,10 @@ export default function Book(props) {
{props.eventType.length} minutes
+ {selectedLocation === LocationType.InPerson &&
+
+ {locationInfo(selectedLocation).address}
+
}
{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")}
@@ -75,6 +98,23 @@ export default function Book(props) {
+ {props.eventType.locations.length > 1 && (
+
+ Location
+ {props.eventType.locations.map( (location) => (
+
+ ))}
+
+ )}
+ {selectedLocation === LocationType.Phone && ()}
@@ -117,7 +157,8 @@ export async function getServerSideProps(context) {
title: true,
slug: true,
description: true,
- length: true
+ length: true,
+ locations: true,
}
});
diff --git a/pages/api/availability/eventtype.ts b/pages/api/availability/eventtype.ts
index 3725120b..ea37b2fc 100644
--- a/pages/api/availability/eventtype.ts
+++ b/pages/api/availability/eventtype.ts
@@ -4,99 +4,61 @@ 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;
}
+ // TODO: Add user ID to user session object
+ const user = await prisma.user.findFirst({
+ where: {
+ email: session.user.email,
+ },
+ select: {
+ id: true
+ }
+ });
- if (req.method == "POST") {
- // TODO: Add user ID to user session object
- const user = await prisma.user.findFirst({
- where: {
- email: session.user.email,
- },
- select: {
- id: true
- }
- });
-
- if (!user) { res.status(404).json({message: 'User not found'}); return; }
-
- const title = req.body.title;
- const slug = req.body.slug;
- const description = req.body.description;
- const length = parseInt(req.body.length);
- const hidden = req.body.hidden;
-
- const createEventType = await prisma.eventType.create({
- data: {
- title: title,
- slug: slug,
- description: description,
- length: length,
- hidden: hidden,
- userId: user.id,
- },
- });
-
- res.status(200).json({message: 'Event created successfully'});
+ if (!user) {
+ res.status(404).json({message: 'User not found'});
+ return;
}
- if (req.method == "PATCH") {
- // TODO: Add user ID to user session object
- const user = await prisma.user.findFirst({
- where: {
- email: session.user.email,
- },
- select: {
- id: true
- }
- });
+ if (req.method == "PATCH" || req.method == "POST") {
- if (!user) { res.status(404).json({message: 'User not found'}); return; }
+ 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,
+ };
- const id = req.body.id;
- const title = req.body.title;
- const slug = req.body.slug;
- const description = req.body.description;
- const length = parseInt(req.body.length);
- const hidden = req.body.hidden;
-
- const updateEventType = await prisma.eventType.update({
- where: {
- id: id,
- },
- data: {
- title: title,
- slug: slug,
- description: description,
- length: length,
- hidden: hidden
- },
- });
-
- res.status(200).json({message: 'Event updated successfully'});
+ if (req.method == "POST") {
+ const createEventType = await prisma.eventType.create({
+ data: {
+ userId: 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") {
- // TODO: Add user ID to user session object
- const user = await prisma.user.findFirst({
- where: {
- email: session.user.email,
- },
- select: {
- id: true
- }
- });
-
- if (!user) { res.status(404).json({message: 'User not found'}); return; }
-
- const id = req.body.id;
const deleteEventType = await prisma.eventType.delete({
where: {
- id: id,
+ id: req.body.id,
},
});
diff --git a/pages/api/book/[user].ts b/pages/api/book/[user].ts
index dd00c245..d832901c 100644
--- a/pages/api/book/[user].ts
+++ b/pages/api/book/[user].ts
@@ -21,6 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
startTime: req.body.start,
endTime: req.body.end,
timeZone: currentUser.timeZone,
+ location: req.body.location,
attendees: [
{ email: req.body.email, name: req.body.name }
]
diff --git a/pages/availability/event/[type].tsx b/pages/availability/event/[type].tsx
index 8829c333..80215c99 100644
--- a/pages/availability/event/[type].tsx
+++ b/pages/availability/event/[type].tsx
@@ -1,14 +1,22 @@
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
-import { useRef } from 'react';
+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
(undefined);
+ const [ locations, setLocations ] = useState(props.eventType.locations || []);
+
const titleRef = useRef();
const slugRef = useRef();
const descriptionRef = useRef();
@@ -27,12 +35,11 @@ export default function EventType(props) {
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}),
+ body: JSON.stringify({id: props.eventType.id, title: enteredTitle, slug: enteredSlug, description: enteredDescription, length: enteredLength, hidden: enteredIsHidden, locations }),
headers: {
'Content-Type': 'application/json'
}
@@ -55,6 +62,72 @@ export default function EventType(props) {
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 (
+
+
+
+
+
+
+ )
+ case LocationType.Phone:
+
+ return (
+ Calendso will ask your invitee to enter a phone number before scheduling.
+ )
+ }
+ 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 (
@@ -92,6 +165,53 @@ export default function EventType(props) {
+
+
+ {locations.length === 0 &&
+
+
+
}
+ {locations.length > 0 &&
+ {locations.map( (location) => (
+ -
+
+ {location.type === LocationType.InPerson && (
+
+
+ {location.address}
+
+ )}
+ {location.type === LocationType.Phone && (
+
+ )}
+
+
+
+
+
+
+ ))}
+ {locations.length > 0 && locations.length !== locationOptions.length && -
+
+
}
+
}
+
@@ -153,6 +273,45 @@ export default function EventType(props) {
+ {showLocationModal &&
+
+
+
+
+
+
+
+
+
+
+
+
+
Edit location
+
+
+
+
+
+
+ }
);
@@ -182,7 +341,8 @@ export async function getServerSideProps(context) {
slug: true,
description: true,
length: true,
- hidden: true
+ hidden: true,
+ locations: true,
}
});
diff --git a/pages/success.tsx b/pages/success.tsx
index fd1c2407..7283d3e1 100644
--- a/pages/success.tsx
+++ b/pages/success.tsx
@@ -3,13 +3,13 @@ import Link from 'next/link';
import prisma from '../lib/prisma';
import { useRouter } from 'next/router';
import { CheckIcon } from '@heroicons/react/outline';
-import { ClockIcon, CalendarIcon } from '@heroicons/react/solid';
+import { ClockIcon, CalendarIcon, LocationMarkerIcon } from '@heroicons/react/solid';
const dayjs = require('dayjs');
const ics = require('ics');
export default function Success(props) {
const router = useRouter();
- const { date } = router.query;
+ const { date, location } = router.query;
function eventLink(): string {
@@ -17,12 +17,18 @@ export default function Success(props) {
(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 = ics.createEvent({
start,
startInputType: 'utc',
title: props.eventType.title + ' with ' + props.user.name,
description: props.eventType.description,
- duration: { minutes: props.eventType.length }
+ duration: { minutes: props.eventType.length },
+ ...optional
});
if (event.error) {
@@ -60,10 +66,14 @@ export default function Success(props) {
{props.eventType.title} with {props.user.name}
-
+
{props.eventType.length} minutes
+
+
+ {location}
+
{dayjs(date).format("hh:mma, dddd DD MMMM YYYY")}
@@ -74,17 +84,17 @@ export default function Success(props) {
Add to your calendar
-
+
-
+
-
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 288682e7..f72eaee2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -15,6 +15,7 @@ model EventType {
title String
slug String
description String?
+ locations Json?
length Int
hidden Boolean @default(false)
user User? @relation(fields: [userId], references: [id])
diff --git a/yarn.lock b/yarn.lock
index b3f1af68..fc932f71 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -756,6 +756,11 @@ classnames@2.2.6:
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
+classnames@^2.2.5:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
+ integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
+
cli-highlight@^2.1.10:
version "2.1.11"
resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.11.tgz#49736fa452f0aaf4fae580e30acb26828d2dc1bf"
@@ -859,6 +864,11 @@ core-util-is@~1.0.0:
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+country-flag-icons@^1.0.2:
+ version "1.2.10"
+ resolved "https://registry.yarnpkg.com/country-flag-icons/-/country-flag-icons-1.2.10.tgz#c60fdf25883abacd28fbbf3842b920890f944591"
+ integrity sha512-nG+kGe4wVU9M+EsLUhP4buSuNdBH0leTm0Fv6RToXxO9BbbxUKV9VUq+9AcztnW7nEnweK7WYdtJsfyNLmQugQ==
+
create-ecdh@^4.0.0:
version "4.0.4"
resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e"
@@ -1564,6 +1574,13 @@ inherits@2.0.3:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+input-format@^0.3.6:
+ version "0.3.6"
+ resolved "https://registry.yarnpkg.com/input-format/-/input-format-0.3.6.tgz#b9b167dbd16435eb3c0012347964b230ea0024c8"
+ integrity sha512-SbUu43CDVV5GlC8Xi6NYBUoiU+tLpN/IMYyQl0mzSXDiU1w0ql8wpcwjDOFpaCVLySLoreLUimhI82IA5y42Pw==
+ dependencies:
+ prop-types "^15.7.2"
+
is-arguments@^1.0.4:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.0.tgz#62353031dfbee07ceb34656a6bde59efecae8dd9"
@@ -1827,6 +1844,11 @@ jws@^4.0.0:
jwa "^2.0.0"
safe-buffer "^5.0.1"
+libphonenumber-js@^1.9.17:
+ version "1.9.17"
+ resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.9.17.tgz#fef2e6fd7a981be69ba358c24495725ee8daf331"
+ integrity sha512-ElJki901OynMg1l+evooPH1VyHrECuLqpgc12z2BkK25dFU5lUKTuMHEYV2jXxvtns/PIuJax56cBeoSK7ANow==
+
loader-utils@1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7"
@@ -2483,7 +2505,7 @@ process@0.11.10, process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
-prop-types@15.7.2, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2:
+prop-types@15.7.2, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -2607,12 +2629,23 @@ react-is@16.13.1, react-is@^16.7.0, react-is@^16.8.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
+react-phone-number-input@^3.1.21:
+ version "3.1.21"
+ resolved "https://registry.yarnpkg.com/react-phone-number-input/-/react-phone-number-input-3.1.21.tgz#7c6de442d9d2ebd6e757e93c6603698aa008e82b"
+ integrity sha512-Q1CS7RKFE+DyiZxEKrs00wf7geQ4qBJpOflCVNtTXnO0a2iXG42HFF7gtUpKQpro8THr7ejNy8H+zm2zD+EgvQ==
+ dependencies:
+ classnames "^2.2.5"
+ country-flag-icons "^1.0.2"
+ input-format "^0.3.6"
+ libphonenumber-js "^1.9.17"
+ prop-types "^15.7.2"
+
react-refresh@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
-react-select@^4.2.1:
+react-select@^4.2.1, react-select@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-4.3.0.tgz#6bde634ae7a378b49f3833c85c126f533483fa2e"
integrity sha512-SBPD1a3TJqE9zoI/jfOLCAoLr/neluaeokjOixr3zZ1vHezkom8K0A9J4QG9IWDqIDE9K/Mv+0y1GjidC2PDtQ==