Adds Stripe integration (#717)
* Adds Stripe integration * Moves Stripe instrucctions to ee * Adds NEXT_PUBLIC_APP_URL variable * Adds fallback for NEXT_PUBLIC_APP_URL * Throws error objects instead * Improved error handling * Removes deprecated method * Bug fixing * Payment refactoring * PaymentPage fixes * Fixes preview links * More preview link fixes * Fixes client links Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
parent
43563bc8d5
commit
3add84a279
52 changed files with 2214 additions and 636 deletions
|
@ -3,6 +3,7 @@ DATABASE_URL="postgresql://postgres:@localhost:5432/calendso?schema=public"
|
||||||
|
|
||||||
GOOGLE_API_CREDENTIALS='secret'
|
GOOGLE_API_CREDENTIALS='secret'
|
||||||
BASE_URL='http://localhost:3000'
|
BASE_URL='http://localhost:3000'
|
||||||
|
NEXT_PUBLIC_APP_URL='http://localhost:3000'
|
||||||
|
|
||||||
# @see: https://github.com/calendso/calendso/issues/263
|
# @see: https://github.com/calendso/calendso/issues/263
|
||||||
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
# Required for Vercel hosting - set NEXTAUTH_URL to equal your BASE_URL
|
||||||
|
@ -37,6 +38,14 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
|
||||||
# ApiKey for cronjobs
|
# ApiKey for cronjobs
|
||||||
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
|
||||||
|
|
||||||
|
# Stripe Config
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLIC_KEY= # pk_test_...
|
||||||
|
STRIPE_PRIVATE_KEY= # sk_test_...
|
||||||
|
STRIPE_CLIENT_ID= # ca_...
|
||||||
|
STRIPE_WEBHOOK_SECRET= # whsec_...
|
||||||
|
PAYMENT_FEE_PERCENTAGE=0.005 # Take 0.5% commission
|
||||||
|
PAYMENT_FEE_FIXED=10 # Take 10 additional cents commission
|
||||||
|
|
||||||
# Application Key for symmetric encryption and decryption
|
# Application Key for symmetric encryption and decryption
|
||||||
# must be 32 bytes for AES256 encryption algorithm
|
# must be 32 bytes for AES256 encryption algorithm
|
||||||
CALENDSO_ENCRYPTION_KEY=
|
CALENDSO_ENCRYPTION_KEY=
|
||||||
|
|
|
@ -450,7 +450,7 @@ paths:
|
||||||
properties: {}
|
properties: {}
|
||||||
'500':
|
'500':
|
||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
'/api/book/{user}':
|
'/api/book/event':
|
||||||
post:
|
post:
|
||||||
description: Creates a booking in the user's calendar.
|
description: Creates a booking in the user's calendar.
|
||||||
summary: Creates a booking for a user
|
summary: Creates a booking for a user
|
||||||
|
@ -480,10 +480,17 @@ paths:
|
||||||
guests:
|
guests:
|
||||||
type: array
|
type: array
|
||||||
items: {}
|
items: {}
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
items: {}
|
||||||
|
user:
|
||||||
|
type: string
|
||||||
notes:
|
notes:
|
||||||
type: string
|
type: string
|
||||||
location:
|
location:
|
||||||
type: string
|
type: string
|
||||||
|
paymentUid:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: No Content
|
description: No Content
|
||||||
|
|
|
@ -8,7 +8,6 @@ import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/t
|
||||||
import { SelectorIcon } from "@heroicons/react/outline";
|
import { SelectorIcon } from "@heroicons/react/outline";
|
||||||
import {
|
import {
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ChatAltIcon,
|
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
|
@ -268,7 +267,11 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean })
|
||||||
"w-64 z-10 absolute mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none"
|
"w-64 z-10 absolute mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none"
|
||||||
)}>
|
)}>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<a href={"/" + user?.username} className="flex px-4 py-2 text-sm text-neutral-500">
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
href={`${process.env.NEXT_PUBLIC_APP_URL}/${user?.username || ""}`}
|
||||||
|
className="flex px-4 py-2 text-sm text-neutral-500">
|
||||||
View public page <ExternalLinkIcon className="ml-1 mt-1 w-3 h-3 text-neutral-400" />
|
View public page <ExternalLinkIcon className="ml-1 mt-1 w-3 h-3 text-neutral-400" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -309,25 +312,6 @@ function UserDropdown({ small, bottom }: { small?: boolean; bottom?: boolean })
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item>
|
|
||||||
{({ active }) => (
|
|
||||||
<a
|
|
||||||
href="mailto:feedback@cal.com"
|
|
||||||
className={classNames(
|
|
||||||
active ? "bg-gray-100 text-gray-900" : "text-neutral-700",
|
|
||||||
"flex px-4 py-2 text-sm font-medium"
|
|
||||||
)}>
|
|
||||||
<ChatAltIcon
|
|
||||||
className={classNames(
|
|
||||||
"text-neutral-400 group-hover:text-neutral-500",
|
|
||||||
"mr-2 flex-shrink-0 h-5 w-5"
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Feedback
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
|
|
|
@ -1,96 +0,0 @@
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import getSlots from "../../lib/slots";
|
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
|
||||||
import isBetween from "dayjs/plugin/isBetween";
|
|
||||||
import utc from "dayjs/plugin/utc";
|
|
||||||
dayjs.extend(isBetween);
|
|
||||||
dayjs.extend(utc);
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
eventLength: number;
|
|
||||||
minimumBookingNotice?: number;
|
|
||||||
date: Dayjs;
|
|
||||||
workingHours: [];
|
|
||||||
organizerTimeZone: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Slots = ({ eventLength, minimumBookingNotice, date, workingHours, organizerTimeZone }: Props) => {
|
|
||||||
minimumBookingNotice = minimumBookingNotice || 0;
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { user } = router.query;
|
|
||||||
const [slots, setSlots] = useState([]);
|
|
||||||
const [isFullyBooked, setIsFullyBooked] = useState(false);
|
|
||||||
const [hasErrors, setHasErrors] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSlots([]);
|
|
||||||
setIsFullyBooked(false);
|
|
||||||
setHasErrors(false);
|
|
||||||
fetch(
|
|
||||||
`/api/availability/${user}?dateFrom=${date.startOf("day").format()}&dateTo=${date
|
|
||||||
.endOf("day")
|
|
||||||
.format()}`
|
|
||||||
)
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then(handleAvailableSlots)
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
setHasErrors(true);
|
|
||||||
});
|
|
||||||
}, [date]);
|
|
||||||
|
|
||||||
const handleAvailableSlots = (busyTimes: []) => {
|
|
||||||
const times = getSlots({
|
|
||||||
frequency: eventLength,
|
|
||||||
inviteeDate: date,
|
|
||||||
workingHours,
|
|
||||||
minimumBookingNotice,
|
|
||||||
organizerTimeZone,
|
|
||||||
});
|
|
||||||
|
|
||||||
const timesLengthBeforeConflicts: number = times.length;
|
|
||||||
|
|
||||||
// Check for conflicts
|
|
||||||
for (let i = times.length - 1; i >= 0; i -= 1) {
|
|
||||||
busyTimes.every((busyTime): boolean => {
|
|
||||||
const startTime = dayjs(busyTime.start).utc();
|
|
||||||
const endTime = dayjs(busyTime.end).utc();
|
|
||||||
// Check if start times are the same
|
|
||||||
if (times[i].utc().isSame(startTime)) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
}
|
|
||||||
// Check if time is between start and end times
|
|
||||||
else if (times[i].utc().isBetween(startTime, endTime)) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
}
|
|
||||||
// Check if slot end time is between start and end time
|
|
||||||
else if (times[i].utc().add(eventLength, "minutes").isBetween(startTime, endTime)) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
}
|
|
||||||
// Check if startTime is between slot
|
|
||||||
else if (startTime.isBetween(times[i].utc(), times[i].utc().add(eventLength, "minutes"))) {
|
|
||||||
times.splice(i, 1);
|
|
||||||
} else {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (times.length === 0 && timesLengthBeforeConflicts !== 0) {
|
|
||||||
setIsFullyBooked(true);
|
|
||||||
}
|
|
||||||
// Display available times
|
|
||||||
setSlots(times);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
slots,
|
|
||||||
isFullyBooked,
|
|
||||||
hasErrors,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Slots;
|
|
|
@ -6,7 +6,7 @@ import dayjs, { Dayjs } from "dayjs";
|
||||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
|
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, CreditCardIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||||
import DatePicker from "@components/booking/DatePicker";
|
import DatePicker from "@components/booking/DatePicker";
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
import PoweredByCalendso from "@components/ui/PoweredByCalendso";
|
import PoweredByCalendso from "@components/ui/PoweredByCalendso";
|
||||||
|
@ -18,6 +18,7 @@ import { HeadSeo } from "@components/seo/head-seo";
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(customParseFormat);
|
dayjs.extend(customParseFormat);
|
||||||
|
@ -127,6 +128,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{eventType.length} minutes
|
{eventType.length} minutes
|
||||||
</div>
|
</div>
|
||||||
|
{eventType.price > 0 && (
|
||||||
|
<div>
|
||||||
|
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<FormattedNumber
|
||||||
|
value={eventType.price / 100.0}
|
||||||
|
style="currency"
|
||||||
|
currency={eventType.currency.toUpperCase()}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -159,6 +172,18 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: AvailabilityPage
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{eventType.length} minutes
|
{eventType.length} minutes
|
||||||
</p>
|
</p>
|
||||||
|
{eventType.price > 0 && (
|
||||||
|
<p className="px-2 py-1 mb-1 -ml-2 text-gray-500">
|
||||||
|
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<FormattedNumber
|
||||||
|
value={eventType.price / 100.0}
|
||||||
|
style="currency"
|
||||||
|
currency={eventType.currency.toUpperCase()}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<TimezoneDropdown />
|
<TimezoneDropdown />
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid";
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
ExclamationIcon,
|
||||||
|
LocationMarkerIcon,
|
||||||
|
} from "@heroicons/react/solid";
|
||||||
import { EventTypeCustomInputType } from "@prisma/client";
|
import { EventTypeCustomInputType } from "@prisma/client";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import "react-phone-number-input/style.css";
|
import "react-phone-number-input/style.css";
|
||||||
import PhoneInput from "react-phone-number-input";
|
import PhoneInput from "react-phone-number-input";
|
||||||
|
@ -15,8 +21,17 @@ import { timeZone } from "@lib/clock";
|
||||||
import useTheme from "@lib/hooks/useTheme";
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
import AvatarGroup from "@components/ui/AvatarGroup";
|
import AvatarGroup from "@components/ui/AvatarGroup";
|
||||||
import { parseZone } from "@lib/parseZone";
|
import { parseZone } from "@lib/parseZone";
|
||||||
|
import { createPaymentLink } from "@ee/lib/stripe/client";
|
||||||
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
import { BookPageProps } from "../../../pages/[user]/book";
|
||||||
|
import { TeamBookingPageProps } from "../../../pages/team/[slug]/book";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
import createBooking from "@lib/mutations/bookings/create-booking";
|
||||||
|
import { BookingCreateBody } from "@lib/types/booking";
|
||||||
|
|
||||||
const BookingPage = (props: any): JSX.Element => {
|
type BookingPageProps = BookPageProps | TeamBookingPageProps;
|
||||||
|
|
||||||
|
const BookingPage = (props: BookingPageProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { rescheduleUid } = router.query;
|
const { rescheduleUid } = router.query;
|
||||||
const themeLoaded = useTheme(props.profile.theme);
|
const themeLoaded = useTheme(props.profile.theme);
|
||||||
|
@ -54,7 +69,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||||
[LocationType.Zoom]: "Zoom Video",
|
[LocationType.Zoom]: "Zoom Video",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingHandler = (event) => {
|
const _bookingHandler = (event) => {
|
||||||
const book = async () => {
|
const book = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
@ -79,7 +94,7 @@ const BookingPage = (props: any): JSX.Element => {
|
||||||
notes += event.target.notes.value;
|
notes += event.target.notes.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload: BookingCreateBody = {
|
||||||
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,
|
||||||
|
@ -87,13 +102,10 @@ const BookingPage = (props: any): JSX.Element => {
|
||||||
notes: notes,
|
notes: notes,
|
||||||
guests: guestEmails,
|
guests: guestEmails,
|
||||||
eventTypeId: props.eventType.id,
|
eventTypeId: props.eventType.id,
|
||||||
rescheduleUid: rescheduleUid,
|
|
||||||
timeZone: timeZone(),
|
timeZone: timeZone(),
|
||||||
};
|
};
|
||||||
|
if (typeof rescheduleUid === "string") payload.rescheduleUid = rescheduleUid;
|
||||||
if (router.query.user) {
|
if (typeof router.query.user === "string") payload.user = router.query.user;
|
||||||
payload.user = router.query.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedLocation) {
|
if (selectedLocation) {
|
||||||
switch (selectedLocation) {
|
switch (selectedLocation) {
|
||||||
|
@ -115,33 +127,49 @@ const BookingPage = (props: any): JSX.Element => {
|
||||||
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
jitsu.track(telemetryEventTypes.bookingConfirmed, collectPageParameters())
|
||||||
);
|
);
|
||||||
|
|
||||||
/*const res = await */ fetch("/api/book/event", {
|
const content = await createBooking(payload).catch((e) => {
|
||||||
body: JSON.stringify(payload),
|
console.error(e.message);
|
||||||
headers: {
|
setLoading(false);
|
||||||
"Content-Type": "application/json",
|
setError(true);
|
||||||
},
|
|
||||||
method: "POST",
|
|
||||||
});
|
});
|
||||||
// TODO When the endpoint is fixed, change this to await the result again
|
|
||||||
//if (res.ok) {
|
if (content?.id) {
|
||||||
let successUrl = `/success?date=${encodeURIComponent(date)}&type=${props.eventType.id}&user=${
|
const params: { [k: string]: any } = {
|
||||||
props.profile.slug
|
date,
|
||||||
}&reschedule=${!!rescheduleUid}&name=${payload.name}`;
|
type: props.eventType.id,
|
||||||
|
user: props.profile.slug,
|
||||||
|
reschedule: !!rescheduleUid,
|
||||||
|
name: payload.name,
|
||||||
|
};
|
||||||
|
|
||||||
if (payload["location"]) {
|
if (payload["location"]) {
|
||||||
if (payload["location"].includes("integration")) {
|
if (payload["location"].includes("integration")) {
|
||||||
successUrl += "&location=" + encodeURIComponent("Web conferencing details to follow.");
|
params.location = "Web conferencing details to follow.";
|
||||||
} else {
|
} else {
|
||||||
successUrl += "&location=" + encodeURIComponent(payload["location"]);
|
params.location = payload["location"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const query = stringify(params);
|
||||||
|
let successUrl = `/success?${query}`;
|
||||||
|
|
||||||
|
if (content?.paymentUid) {
|
||||||
|
successUrl = createPaymentLink(content?.paymentUid, payload.name, date, false);
|
||||||
|
}
|
||||||
|
|
||||||
await router.push(successUrl);
|
await router.push(successUrl);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
book();
|
book();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bookingHandler = useCallback(_bookingHandler, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
themeLoaded && (
|
themeLoaded && (
|
||||||
<div>
|
<div>
|
||||||
|
@ -176,6 +204,18 @@ const BookingPage = (props: any): JSX.Element => {
|
||||||
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<ClockIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
{props.eventType.length} minutes
|
{props.eventType.length} minutes
|
||||||
</p>
|
</p>
|
||||||
|
{props.eventType.price > 0 && (
|
||||||
|
<p className="text-gray-500 mb-1 px-2 py-1 -ml-2">
|
||||||
|
<CreditCardIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<FormattedNumber
|
||||||
|
value={props.eventType.price / 100.0}
|
||||||
|
style="currency"
|
||||||
|
currency={props.eventType.currency.toUpperCase()}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{selectedLocation === LocationType.InPerson && (
|
{selectedLocation === LocationType.InPerson && (
|
||||||
<p className="text-gray-500 mb-2">
|
<p className="text-gray-500 mb-2">
|
||||||
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
<LocationMarkerIcon className="inline-block w-4 h-4 mr-1 -mt-1" />
|
||||||
|
|
|
@ -1,7 +1,28 @@
|
||||||
import { EventType, SchedulingType } from "@prisma/client";
|
import { SchedulingType } from "@prisma/client";
|
||||||
import { ClockIcon, InformationCircleIcon, UserIcon, UsersIcon } from "@heroicons/react/solid";
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "@heroicons/react/solid";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
|
||||||
|
const eventTypeData = Prisma.validator<Prisma.EventTypeArgs>()({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
length: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
|
schedulingType: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type EventType = Prisma.EventTypeGetPayload<typeof eventTypeData>;
|
||||||
|
|
||||||
export type EventTypeDescriptionProps = {
|
export type EventTypeDescriptionProps = {
|
||||||
eventType: EventType;
|
eventType: EventType;
|
||||||
|
@ -27,6 +48,18 @@ export const EventTypeDescription = ({ eventType, className }: EventTypeDescript
|
||||||
1-on-1
|
1-on-1
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{eventType.price > 0 && (
|
||||||
|
<li className="flex whitespace-nowrap">
|
||||||
|
<CreditCardIcon className="inline mt-0.5 mr-1.5 h-4 w-4 text-neutral-400" aria-hidden="true" />
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<FormattedNumber
|
||||||
|
value={eventType.price / 100.0}
|
||||||
|
style="currency"
|
||||||
|
currency={eventType.currency.toUpperCase()}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{eventType.description && (
|
{eventType.description && (
|
||||||
<li className="flex">
|
<li className="flex">
|
||||||
<InformationCircleIcon
|
<InformationCircleIcon
|
||||||
|
|
11
ee/README.md
11
ee/README.md
|
@ -14,3 +14,14 @@ Welcome to the Enterprise Edition ("/ee") of Cal.com.
|
||||||
The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the place for all the **Pro** features from our [hosted](https://cal.com/pricing) plan and [enterprise-grade](https://cal.com/enterprise) features such as SSO, SAML, ADFS, OIDC, SCIM, SIEM, HRIS and much more.
|
The [/ee](https://github.com/calendso/calendso/tree/main/ee) subfolder is the place for all the **Pro** features from our [hosted](https://cal.com/pricing) plan and [enterprise-grade](https://cal.com/enterprise) features such as SSO, SAML, ADFS, OIDC, SCIM, SIEM, HRIS and much more.
|
||||||
|
|
||||||
> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calendso/calendso)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://cal.com/enterprise) first❗_
|
> _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/calendso/calendso)). You are not allowed to use this code to host your own version of app.cal.com without obtaining a proper [license](https://cal.com/enterprise) first❗_
|
||||||
|
|
||||||
|
## Setting up Stripe
|
||||||
|
|
||||||
|
1. Create a stripe account or use an existing one. For testing, you should use all stripe dashboard functions with the Test-Mode toggle in the top right activated.
|
||||||
|
2. Open [Stripe ApiKeys](https://dashboard.stripe.com/apikeys) save the token starting with `pk_...` to `NEXT_PUBLIC_STRIPE_PUBLIC_KEY` and `sk_...` to `STRIPE_PRIVATE_KEY` in the .env file.
|
||||||
|
3. Open [Stripe Connect Settings](https://dashboard.stripe.com/settings/connect) and activate OAuth for Standard Accounts
|
||||||
|
4. Add `<CALENDSO URL>/api/integrations/stripepayment/callback` as redirect URL.
|
||||||
|
5. Copy your client*id (`ca*...`) to `STRIPE_CLIENT_ID` in the .env file.
|
||||||
|
6. Open [Stripe Webhooks](https://dashboard.stripe.com/webhooks) and add `<CALENDSO URL>/api/integrations/stripepayment/webhook` as webhook for connected applications.
|
||||||
|
7. Select all `payment_intent` events for the webhook.
|
||||||
|
8. Copy the webhook secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET` in the .env file.
|
||||||
|
|
122
ee/components/stripe/Payment.tsx
Normal file
122
ee/components/stripe/Payment.tsx
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
import { CardElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||||
|
import Button from "@components/ui/Button";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useDarkMode from "@lib/core/browser/useDarkMode";
|
||||||
|
import { PaymentData } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
|
const CARD_OPTIONS = {
|
||||||
|
iconStyle: "solid" as const,
|
||||||
|
classes: {
|
||||||
|
base: "block p-2 w-full border-solid border-2 border-gray-300 rounded-md shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus-within:ring-black focus-within:border-black sm:text-sm",
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
color: "#000",
|
||||||
|
iconColor: "#000",
|
||||||
|
fontFamily: "ui-sans-serif, system-ui",
|
||||||
|
fontSmoothing: "antialiased",
|
||||||
|
fontSize: "16px",
|
||||||
|
"::placeholder": {
|
||||||
|
color: "#888888",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
payment: {
|
||||||
|
data: PaymentData;
|
||||||
|
};
|
||||||
|
eventType: { id: number };
|
||||||
|
user: { username: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
type States =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "processing" }
|
||||||
|
| { status: "error"; error: Error }
|
||||||
|
| { status: "ok" };
|
||||||
|
|
||||||
|
export default function PaymentComponent(props: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { name, date } = router.query;
|
||||||
|
const [state, setState] = useState<States>({ status: "idle" });
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const { isDarkMode } = useDarkMode();
|
||||||
|
|
||||||
|
if (isDarkMode) {
|
||||||
|
CARD_OPTIONS.style.base.color = "#fff";
|
||||||
|
CARD_OPTIONS.style.base.iconColor = "#fff";
|
||||||
|
CARD_OPTIONS.style.base["::placeholder"].color = "#fff";
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = async (event) => {
|
||||||
|
// Listen for changes in the CardElement
|
||||||
|
// and display any errors as the customer types their card details
|
||||||
|
setState({ status: "idle" });
|
||||||
|
if (event.emtpy || event.error)
|
||||||
|
setState({ status: "error", error: new Error(event.error?.message || "Missing card fields") });
|
||||||
|
};
|
||||||
|
const handleSubmit = async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!stripe || !elements) return;
|
||||||
|
const card = elements.getElement(CardElement);
|
||||||
|
if (!card) return;
|
||||||
|
setState({ status: "processing" });
|
||||||
|
const payload = await stripe.confirmCardPayment(props.payment.data.client_secret!, {
|
||||||
|
payment_method: {
|
||||||
|
card,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (payload.error) {
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: new Error(`Payment failed: ${payload.error.message}`),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const params: { [k: string]: any } = {
|
||||||
|
date,
|
||||||
|
type: props.eventType.id,
|
||||||
|
user: props.user.username,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload["location"]) {
|
||||||
|
if (payload["location"].includes("integration")) {
|
||||||
|
params.location = "Web conferencing details to follow.";
|
||||||
|
} else {
|
||||||
|
params.location = payload["location"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = stringify(params);
|
||||||
|
const successUrl = `/success?${query}`;
|
||||||
|
|
||||||
|
await router.push(successUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<form id="payment-form" className="mt-4" onSubmit={handleSubmit}>
|
||||||
|
<CardElement id="card-element" options={CARD_OPTIONS} onChange={handleChange} />
|
||||||
|
<div className="flex mt-2 justify-center">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={["processing", "error"].includes(state.status)}
|
||||||
|
loading={state.status === "processing"}
|
||||||
|
id="submit">
|
||||||
|
<span id="button-text">
|
||||||
|
{state.status === "processing" ? <div className="spinner" id="spinner" /> : "Pay now"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{state.status === "error" && (
|
||||||
|
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center" role="alert">
|
||||||
|
{state.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
126
ee/components/stripe/PaymentPage.tsx
Normal file
126
ee/components/stripe/PaymentPage.tsx
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import PaymentComponent from "@ee/components/stripe/Payment";
|
||||||
|
import getStripe from "@ee/lib/stripe/client";
|
||||||
|
import { PaymentPageProps } from "@ee/pages/payment/[uid]";
|
||||||
|
import { CreditCardIcon } from "@heroicons/react/solid";
|
||||||
|
import useTheme from "@lib/hooks/useTheme";
|
||||||
|
import { Elements } from "@stripe/react-stripe-js";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import toArray from "dayjs/plugin/toArray";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import Head from "next/head";
|
||||||
|
import React, { FC, useEffect, useState } from "react";
|
||||||
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(toArray);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
const PaymentPage: FC<PaymentPageProps> = (props) => {
|
||||||
|
const [is24h, setIs24h] = useState(false);
|
||||||
|
const [date, setDate] = useState(dayjs.utc(props.booking.startTime));
|
||||||
|
const { isReady } = useTheme(props.profile.theme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
|
||||||
|
setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const eventName = props.booking.title;
|
||||||
|
|
||||||
|
return isReady ? (
|
||||||
|
<div className="bg-neutral-50 dark:bg-neutral-900 h-screen">
|
||||||
|
<Head>
|
||||||
|
<title>Payment | {eventName} | Calendso</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<main className="max-w-3xl mx-auto py-24">
|
||||||
|
<div className="fixed z-50 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">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="inline-block align-bottom dark:bg-gray-800 bg-white rounded-sm px-8 pt-5 pb-4 text-left overflow-hidden border border-neutral-200 dark:border-neutral-700 transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:py-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">
|
||||||
|
<CreditCardIcon className="h-8 w-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
|
<h3
|
||||||
|
className="text-2xl leading-6 font-semibold dark:text-white text-neutral-900"
|
||||||
|
id="modal-headline">
|
||||||
|
Payment
|
||||||
|
</h3>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-gray-300">
|
||||||
|
You have also received an email with this link, if you want to pay later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-gray-700 dark:text-gray-300 border-t border-b dark:border-gray-900 py-4 grid grid-cols-3 text-left">
|
||||||
|
<div className="font-medium">What</div>
|
||||||
|
<div className="mb-6 col-span-2">{eventName}</div>
|
||||||
|
<div className="font-medium">When</div>
|
||||||
|
<div className="mb-6 col-span-2">
|
||||||
|
{date.format("dddd, DD MMMM YYYY")}
|
||||||
|
<br />
|
||||||
|
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
({localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{props.booking.location && (
|
||||||
|
<>
|
||||||
|
<div className="font-medium">Where</div>
|
||||||
|
<div className="col-span-2">{location}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="font-medium">Price</div>
|
||||||
|
<div className="mb-6 col-span-2">
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<FormattedNumber
|
||||||
|
value={props.eventType.price / 100.0}
|
||||||
|
style="currency"
|
||||||
|
currency={props.eventType.currency.toUpperCase()}
|
||||||
|
/>
|
||||||
|
</IntlProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{props.payment.success && !props.payment.refunded && (
|
||||||
|
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center">Paid</div>
|
||||||
|
)}
|
||||||
|
{!props.payment.success && (
|
||||||
|
<Elements stripe={getStripe(props.payment.data.stripe_publishable_key)}>
|
||||||
|
<PaymentComponent
|
||||||
|
payment={props.payment}
|
||||||
|
eventType={props.eventType}
|
||||||
|
user={props.user}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
)}
|
||||||
|
{props.payment.refunded && (
|
||||||
|
<div className="mt-4 text-gray-700 dark:text-gray-300 text-center">Refunded</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!props.profile.hideBranding && (
|
||||||
|
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white">
|
||||||
|
<a href="https://cal.com/signup">Create your own booking link with Cal.com</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaymentPage;
|
28
ee/lib/stripe/client.ts
Normal file
28
ee/lib/stripe/client.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { loadStripe, Stripe } from "@stripe/stripe-js";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
|
||||||
|
const stripePublicKey = process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!;
|
||||||
|
let stripePromise: Promise<Stripe | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a singleton to ensure we only instantiate Stripe once.
|
||||||
|
*/
|
||||||
|
const getStripe = (userPublicKey?: string) => {
|
||||||
|
if (!stripePromise) {
|
||||||
|
stripePromise = loadStripe(
|
||||||
|
userPublicKey || stripePublicKey /* , {
|
||||||
|
locale: "es-419" TODO: Handle multiple locales,
|
||||||
|
} */
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return stripePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPaymentLink(paymentUid: string, name?: string, date?: string, absolute = true): string {
|
||||||
|
let link = "";
|
||||||
|
if (absolute) link = process.env.NEXT_PUBLIC_APP_URL!;
|
||||||
|
const query = stringify({ date, name });
|
||||||
|
return link + `/payment/${paymentUid}?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getStripe;
|
174
ee/lib/stripe/server.ts
Normal file
174
ee/lib/stripe/server.ts
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
import { CalendarEvent, Person } from "@lib/calendarClient";
|
||||||
|
import EventOrganizerRefundFailedMail from "@lib/emails/EventOrganizerRefundFailedMail";
|
||||||
|
import EventPaymentMail from "@lib/emails/EventPaymentMail";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import { PaymentType } from "@prisma/client";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { JsonValue } from "type-fest";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { createPaymentLink } from "./client";
|
||||||
|
|
||||||
|
export type PaymentData = Stripe.Response<Stripe.PaymentIntent> & {
|
||||||
|
stripe_publishable_key: string;
|
||||||
|
stripeAccount: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StripeData = Stripe.OAuthToken & {
|
||||||
|
default_currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripePrivateKey = process.env.STRIPE_PRIVATE_KEY!;
|
||||||
|
const paymentFeePercentage = process.env.PAYMENT_FEE_PERCENTAGE!;
|
||||||
|
const paymentFeeFixed = process.env.PAYMENT_FEE_FIXED!;
|
||||||
|
|
||||||
|
const stripe = new Stripe(stripePrivateKey, {
|
||||||
|
apiVersion: "2020-08-27",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function handlePayment(
|
||||||
|
evt: CalendarEvent,
|
||||||
|
selectedEventType: {
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
},
|
||||||
|
stripeCredential: { key: JsonValue },
|
||||||
|
booking: {
|
||||||
|
user: { email: string; name: string; timeZone: string };
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
startTime: { toISOString: () => string };
|
||||||
|
endTime: { toISOString: () => string };
|
||||||
|
attendees: Person[];
|
||||||
|
location?: string;
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const paymentFee = Math.round(
|
||||||
|
selectedEventType.price * parseFloat(paymentFeePercentage || "0") + parseInt(paymentFeeFixed || "0")
|
||||||
|
);
|
||||||
|
const { stripe_user_id, stripe_publishable_key } = stripeCredential.key as Stripe.OAuthToken;
|
||||||
|
|
||||||
|
const params: Stripe.PaymentIntentCreateParams = {
|
||||||
|
amount: selectedEventType.price,
|
||||||
|
currency: selectedEventType.currency,
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
application_fee_amount: paymentFee,
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create(params, { stripeAccount: stripe_user_id });
|
||||||
|
|
||||||
|
const payment = await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
type: PaymentType.STRIPE,
|
||||||
|
uid: uuidv4(),
|
||||||
|
bookingId: booking.id,
|
||||||
|
amount: selectedEventType.price,
|
||||||
|
fee: paymentFee,
|
||||||
|
currency: selectedEventType.currency,
|
||||||
|
success: false,
|
||||||
|
refunded: false,
|
||||||
|
data: Object.assign({}, paymentIntent, {
|
||||||
|
stripe_publishable_key,
|
||||||
|
stripeAccount: stripe_user_id,
|
||||||
|
}) as PaymentData as unknown as JsonValue,
|
||||||
|
externalId: paymentIntent.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mail = new EventPaymentMail(
|
||||||
|
createPaymentLink(payment.uid, booking.user.name, booking.startTime.toISOString()),
|
||||||
|
evt,
|
||||||
|
booking.uid
|
||||||
|
);
|
||||||
|
await mail.sendEmail();
|
||||||
|
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refund(
|
||||||
|
booking: {
|
||||||
|
id: number;
|
||||||
|
uid: string;
|
||||||
|
startTime: Date;
|
||||||
|
payment: {
|
||||||
|
id: number;
|
||||||
|
success: boolean;
|
||||||
|
refunded: boolean;
|
||||||
|
externalId: string;
|
||||||
|
data: JsonValue;
|
||||||
|
type: PaymentType;
|
||||||
|
}[];
|
||||||
|
},
|
||||||
|
calEvent: CalendarEvent
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const payment = booking.payment.find((e) => e.success && !e.refunded);
|
||||||
|
if (!payment) return;
|
||||||
|
|
||||||
|
if (payment.type != PaymentType.STRIPE) {
|
||||||
|
await handleRefundError({
|
||||||
|
event: calEvent,
|
||||||
|
booking: booking,
|
||||||
|
reason: "cannot refund non Stripe payment",
|
||||||
|
paymentId: "unknown",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refund = await stripe.refunds.create(
|
||||||
|
{
|
||||||
|
payment_intent: payment.externalId,
|
||||||
|
},
|
||||||
|
{ stripeAccount: (payment.data as unknown as PaymentData)["stripeAccount"] }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!refund || refund.status === "failed") {
|
||||||
|
await handleRefundError({
|
||||||
|
event: calEvent,
|
||||||
|
booking: booking,
|
||||||
|
reason: refund?.failure_reason || "unknown",
|
||||||
|
paymentId: payment.externalId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.payment.update({
|
||||||
|
where: {
|
||||||
|
id: payment.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
refunded: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e, "Refund failed");
|
||||||
|
await handleRefundError({
|
||||||
|
event: calEvent,
|
||||||
|
booking: booking,
|
||||||
|
reason: e.message || "unknown",
|
||||||
|
paymentId: "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefundError(opts: {
|
||||||
|
event: CalendarEvent;
|
||||||
|
booking: { id: number; uid: string };
|
||||||
|
reason: string;
|
||||||
|
paymentId: string;
|
||||||
|
}) {
|
||||||
|
console.error(`refund failed: ${opts.reason} for booking '${opts.booking.id}'`);
|
||||||
|
try {
|
||||||
|
await new EventOrganizerRefundFailedMail(
|
||||||
|
opts.event,
|
||||||
|
opts.booking.uid,
|
||||||
|
opts.reason,
|
||||||
|
opts.paymentId
|
||||||
|
).sendEmail();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error while sending refund error email", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default stripe;
|
48
ee/pages/api/integrations/stripepayment/add.ts
Normal file
48
ee/pages/api/integrations/stripepayment/add.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { getSession } from "@lib/auth";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { stringify } from "querystring";
|
||||||
|
|
||||||
|
const client_id = process.env.STRIPE_CLIENT_ID;
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
// Check that user is authenticated
|
||||||
|
const session = await getSession({ req: req });
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(401).json({ message: "You must be logged in to do this" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: session.user?.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirect_uri = encodeURI(process.env.BASE_URL + "/api/integrations/stripepayment/callback");
|
||||||
|
const stripeConnectParams = {
|
||||||
|
client_id,
|
||||||
|
scope: "read_write",
|
||||||
|
response_type: "code",
|
||||||
|
"stripe_user[email]": user?.email,
|
||||||
|
"stripe_user[first_name]": user?.name,
|
||||||
|
redirect_uri,
|
||||||
|
};
|
||||||
|
const query = stringify(stripeConnectParams);
|
||||||
|
/**
|
||||||
|
* Choose Express or Stantard Stripe accounts
|
||||||
|
* @url https://stripe.com/docs/connect/accounts
|
||||||
|
*/
|
||||||
|
// const url = `https://connect.stripe.com/express/oauth/authorize?${query}`;
|
||||||
|
const url = `https://connect.stripe.com/oauth/authorize?${query}`;
|
||||||
|
|
||||||
|
res.status(200).json({ url });
|
||||||
|
}
|
||||||
|
}
|
37
ee/pages/api/integrations/stripepayment/callback.ts
Normal file
37
ee/pages/api/integrations/stripepayment/callback.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { getSession } from "@lib/auth";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import stripe, { StripeData } from "@ee/lib/stripe/server";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
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?.user) {
|
||||||
|
res.status(401).json({ message: "You must be logged in to do this" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await stripe.oauth.token({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: code.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: StripeData = { ...response, default_currency: "" };
|
||||||
|
if (response["stripe_user_id"]) {
|
||||||
|
const account = await stripe.accounts.retrieve(response["stripe_user_id"]);
|
||||||
|
data["default_currency"] = account.default_currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.credential.create({
|
||||||
|
data: {
|
||||||
|
type: "stripe_payment",
|
||||||
|
key: data as unknown as Prisma.InputJsonObject,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.redirect("/integrations");
|
||||||
|
}
|
135
ee/pages/api/integrations/stripepayment/webhook.ts
Normal file
135
ee/pages/api/integrations/stripepayment/webhook.ts
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
|
import EventManager from "@lib/events/EventManager";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import stripe from "@ee/lib/stripe/server";
|
||||||
|
import { buffer } from "micro";
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import { getErrorFromUnknown } from "pages/_error";
|
||||||
|
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handlePaymentSuccess(event: Stripe.Event) {
|
||||||
|
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||||
|
const payment = await prisma.payment.update({
|
||||||
|
where: {
|
||||||
|
externalId: paymentIntent.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
success: true,
|
||||||
|
booking: {
|
||||||
|
update: {
|
||||||
|
paid: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
bookingId: true,
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
confirmed: true,
|
||||||
|
attendees: true,
|
||||||
|
location: true,
|
||||||
|
userId: true,
|
||||||
|
id: true,
|
||||||
|
uid: true,
|
||||||
|
paid: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
credentials: true,
|
||||||
|
timeZone: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payment) throw new Error("No payment found");
|
||||||
|
|
||||||
|
const { booking } = payment;
|
||||||
|
|
||||||
|
if (!booking) throw new Error("No booking found");
|
||||||
|
|
||||||
|
const { user } = booking;
|
||||||
|
|
||||||
|
if (!user) throw new Error("No user found");
|
||||||
|
|
||||||
|
const evt: CalendarEvent = {
|
||||||
|
type: booking.title,
|
||||||
|
title: booking.title,
|
||||||
|
description: booking.description || undefined,
|
||||||
|
startTime: booking.startTime.toISOString(),
|
||||||
|
endTime: booking.endTime.toISOString(),
|
||||||
|
organizer: { email: user.email!, name: user.name!, timeZone: user.timeZone },
|
||||||
|
attendees: booking.attendees,
|
||||||
|
};
|
||||||
|
if (booking.location) evt.location = booking.location;
|
||||||
|
|
||||||
|
if (booking.confirmed) {
|
||||||
|
const eventManager = new EventManager(user.credentials);
|
||||||
|
const scheduleResult = await eventManager.create(evt, booking.uid);
|
||||||
|
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: {
|
||||||
|
id: payment.bookingId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
references: {
|
||||||
|
create: scheduleResult.referencesToCreate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const requestBuffer = await buffer(req);
|
||||||
|
const sig = req.headers["stripe-signature"];
|
||||||
|
let event;
|
||||||
|
|
||||||
|
if (!sig) {
|
||||||
|
res.status(400).send(`Webhook Error: missing Stripe signature`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookSecret) {
|
||||||
|
res.status(400).send(`Webhook Error: missing Stripe webhookSecret`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(requestBuffer.toString(), sig, webhookSecret);
|
||||||
|
|
||||||
|
// Handle the event
|
||||||
|
if (event.type === "payment_intent.succeeded") {
|
||||||
|
await handlePaymentSuccess(event);
|
||||||
|
} else {
|
||||||
|
console.error(`Unhandled event type ${event.type}`);
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
const err = getErrorFromUnknown(_err);
|
||||||
|
console.error(`Webhook Error: ${err.message}`);
|
||||||
|
res.status(err.statusCode ?? 500).send({
|
||||||
|
message: err.message,
|
||||||
|
stack: process.env.NODE_ENV === "production" ? undefined : err.stack,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a response to acknowledge receipt of the event
|
||||||
|
res.json({ received: true });
|
||||||
|
}
|
106
ee/pages/payment/[uid].tsx
Normal file
106
ee/pages/payment/[uid].tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { PaymentData } from "@ee/lib/stripe/server";
|
||||||
|
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
|
export type PaymentPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const rawPayment = await prisma.payment.findFirst({
|
||||||
|
where: {
|
||||||
|
uid: asStringOrThrow(context.query.uid),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
data: true,
|
||||||
|
success: true,
|
||||||
|
uid: true,
|
||||||
|
refunded: true,
|
||||||
|
bookingId: true,
|
||||||
|
booking: {
|
||||||
|
select: {
|
||||||
|
description: true,
|
||||||
|
title: true,
|
||||||
|
startTime: true,
|
||||||
|
attendees: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
eventTypeId: true,
|
||||||
|
location: true,
|
||||||
|
eventType: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
length: true,
|
||||||
|
eventName: true,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
userId: true,
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
hideBranding: true,
|
||||||
|
plan: true,
|
||||||
|
theme: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
hideBranding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rawPayment) {
|
||||||
|
return {
|
||||||
|
notFound: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, booking: _booking, ...restPayment } = rawPayment;
|
||||||
|
const payment = {
|
||||||
|
...restPayment,
|
||||||
|
data: data as unknown as PaymentData,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!_booking) return { notFound: true };
|
||||||
|
|
||||||
|
const { startTime, eventType, ...restBooking } = _booking;
|
||||||
|
const booking = {
|
||||||
|
...restBooking,
|
||||||
|
startTime: startTime.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!eventType) return { notFound: true };
|
||||||
|
|
||||||
|
const [user] = eventType.users;
|
||||||
|
if (!user) return { notFound: true };
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
name: eventType.team?.name || user?.name || null,
|
||||||
|
theme: (!eventType.team?.name && user?.theme) || null,
|
||||||
|
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
user,
|
||||||
|
eventType,
|
||||||
|
booking,
|
||||||
|
payment,
|
||||||
|
profile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,7 +1,7 @@
|
||||||
import EventOrganizerMail from "./emails/EventOrganizerMail";
|
import EventOrganizerMail from "./emails/EventOrganizerMail";
|
||||||
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
import EventOrganizerRescheduledMail from "./emails/EventOrganizerRescheduledMail";
|
||||||
import prisma from "./prisma";
|
import prisma from "./prisma";
|
||||||
import { Credential } from "@prisma/client";
|
import { Prisma, Credential } from "@prisma/client";
|
||||||
import CalEventParser from "./CalEventParser";
|
import CalEventParser from "./CalEventParser";
|
||||||
import { EventResult } from "@lib/events/EventManager";
|
import { EventResult } from "@lib/events/EventManager";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
|
@ -107,11 +107,10 @@ const o365Auth = (credential) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Person {
|
const userData = Prisma.validator<Prisma.UserArgs>()({
|
||||||
name?: string;
|
select: { name: true, email: true, timeZone: true },
|
||||||
email: string;
|
});
|
||||||
timeZone: string;
|
export type Person = Prisma.UserGetPayload<typeof userData>;
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
type: string;
|
type: string;
|
||||||
|
@ -140,6 +139,7 @@ export interface IntegrationCalendar {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BufferedBusyTime = { start: string; end: string };
|
||||||
export interface CalendarApiAdapter {
|
export interface CalendarApiAdapter {
|
||||||
createEvent(event: CalendarEvent): Promise<unknown>;
|
createEvent(event: CalendarEvent): Promise<unknown>;
|
||||||
|
|
||||||
|
@ -147,7 +147,11 @@ export interface CalendarApiAdapter {
|
||||||
|
|
||||||
deleteEvent(uid: string);
|
deleteEvent(uid: string);
|
||||||
|
|
||||||
getAvailability(dateFrom, dateTo, selectedCalendars: IntegrationCalendar[]): Promise<unknown>;
|
getAvailability(
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
selectedCalendars: IntegrationCalendar[]
|
||||||
|
): Promise<BufferedBusyTime[]>;
|
||||||
|
|
||||||
listCalendars(): Promise<IntegrationCalendar[]>;
|
listCalendars(): Promise<IntegrationCalendar[]>;
|
||||||
}
|
}
|
||||||
|
|
45
lib/core/browser/useDarkMode.tsx
Normal file
45
lib/core/browser/useDarkMode.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
interface UseDarkModeOutput {
|
||||||
|
isDarkMode: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
enable: () => void;
|
||||||
|
disable: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDarkMode(defaultValue?: boolean): UseDarkModeOutput {
|
||||||
|
const getPrefersScheme = (): boolean => {
|
||||||
|
// Prevents SSR issues
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return window.matchMedia(COLOR_SCHEME_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isDarkMode, setDarkMode] = useState<boolean>(getPrefersScheme());
|
||||||
|
|
||||||
|
// Update darkMode if os prefers changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setDarkMode(getPrefersScheme);
|
||||||
|
const matchMedia = window.matchMedia(COLOR_SCHEME_QUERY);
|
||||||
|
|
||||||
|
matchMedia.addEventListener("change", handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
matchMedia.removeEventListener("change", handler);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDarkMode,
|
||||||
|
toggle: () => setDarkMode((prev) => !prev),
|
||||||
|
enable: () => setDarkMode(true),
|
||||||
|
disable: () => setDarkMode(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDarkMode;
|
65
lib/emails/EventOrganizerRefundFailedMail.ts
Normal file
65
lib/emails/EventOrganizerRefundFailedMail.ts
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import toArray from "dayjs/plugin/toArray";
|
||||||
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
import EventOrganizerMail from "@lib/emails/EventOrganizerMail";
|
||||||
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(toArray);
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
|
export default class EventOrganizerRefundFailedMail extends EventOrganizerMail {
|
||||||
|
reason: string;
|
||||||
|
paymentId: string;
|
||||||
|
|
||||||
|
constructor(calEvent: CalendarEvent, uid: string, reason: string, paymentId: string) {
|
||||||
|
super(calEvent, uid, undefined);
|
||||||
|
this.reason = reason;
|
||||||
|
this.paymentId = paymentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBodyHeader(): string {
|
||||||
|
return "A refund failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getBodyText(): string {
|
||||||
|
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||||
|
return `The refund for the event ${this.calEvent.type} with ${
|
||||||
|
this.calEvent.attendees[0].name
|
||||||
|
} on ${organizerStart.format("LT dddd, LL")} failed. Please check with your payment provider and ${
|
||||||
|
this.calEvent.attendees[0].name
|
||||||
|
} how to handle this.<br>The error message was: '${this.reason}'<br>PaymentId: '${this.paymentId}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAdditionalBody(): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getImage(): string {
|
||||||
|
return `<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="height: 60px; width: 60px; color: #9b0125"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getSubject(): string {
|
||||||
|
const organizerStart: Dayjs = <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.organizer.timeZone);
|
||||||
|
return `Refund failed: ${this.calEvent.attendees[0].name} - ${organizerStart.format("LT dddd, LL")} - ${
|
||||||
|
this.calEvent.type
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
}
|
165
lib/emails/EventPaymentMail.ts
Normal file
165
lib/emails/EventPaymentMail.ts
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
import EventMail, { AdditionInformation } from "./EventMail";
|
||||||
|
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||||
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(localizedFormat);
|
||||||
|
|
||||||
|
export default class EventPaymentMail extends EventMail {
|
||||||
|
paymentLink: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
paymentLink: string,
|
||||||
|
calEvent: CalendarEvent,
|
||||||
|
uid: string,
|
||||||
|
additionInformation: AdditionInformation = null
|
||||||
|
) {
|
||||||
|
super(calEvent, uid, additionInformation);
|
||||||
|
this.paymentLink = paymentLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the email text as HTML representation.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getHtmlRepresentation(): string {
|
||||||
|
return (
|
||||||
|
`
|
||||||
|
<body style="background: #f4f5f7; font-family: Helvetica, sans-serif">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 450px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 2rem 2rem 2rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
style="height: 60px; width: 60px; color: #31c48d"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h1 style="font-weight: 500; color: #161e2e;">Your meeting is awaiting payment</h1>
|
||||||
|
<p style="color: #4b5563; margin-bottom: 30px;">You and any other attendees have been emailed with this information.</p>
|
||||||
|
<hr />
|
||||||
|
<table style="border-spacing: 20px; color: #161e2e; margin-bottom: 10px;">
|
||||||
|
<colgroup>
|
||||||
|
<col span="1" style="width: 40%;">
|
||||||
|
<col span="1" style="width: 60%;">
|
||||||
|
</colgroup>
|
||||||
|
<tr>
|
||||||
|
<td>What</td>
|
||||||
|
<td>${this.calEvent.type}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>When</td>
|
||||||
|
<td>${this.getInviteeStart().format("dddd, LL")}<br>${this.getInviteeStart().format("h:mma")} (${
|
||||||
|
this.calEvent.attendees[0].timeZone
|
||||||
|
})</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Who</td>
|
||||||
|
<td>${this.calEvent.organizer.name}<br /><small>${this.calEvent.organizer.email}</small></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Where</td>
|
||||||
|
<td>${this.getLocation()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Notes</td>
|
||||||
|
<td>${this.calEvent.description}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
` +
|
||||||
|
this.getAdditionalBody() +
|
||||||
|
"<br />" +
|
||||||
|
`
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; margin-top: 20px; color: #ccc; font-size: 12px;">
|
||||||
|
<img style="opacity: 0.25; width: 120px;" src="https://app.cal.com/cal-logo-word.svg" alt="Cal.com Logo"></div>
|
||||||
|
</body>
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the video call information to the mail body.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getLocation(): string {
|
||||||
|
if (this.additionInformation?.hangoutLink) {
|
||||||
|
return `<a href="${this.additionInformation?.hangoutLink}">${this.additionInformation?.hangoutLink}</a><br />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.additionInformation?.entryPoints && this.additionInformation?.entryPoints.length > 0) {
|
||||||
|
const locations = this.additionInformation?.entryPoints
|
||||||
|
.map((entryPoint) => {
|
||||||
|
return `
|
||||||
|
Join by ${entryPoint.entryPointType}: <br />
|
||||||
|
<a href="${entryPoint.uri}">${entryPoint.label}</a> <br />
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("<br />");
|
||||||
|
|
||||||
|
return `${locations}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.calEvent.location ? `${this.calEvent.location}<br /><br />` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getAdditionalBody(): string {
|
||||||
|
return `<a href="${this.paymentLink}">Pay now</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the payload object for the nodemailer.
|
||||||
|
*
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getNodeMailerPayload(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
to: `${this.calEvent.attendees[0].name} <${this.calEvent.attendees[0].email}>`,
|
||||||
|
from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`,
|
||||||
|
replyTo: this.calEvent.organizer.email,
|
||||||
|
subject: `Awaiting Payment: ${this.calEvent.type} with ${
|
||||||
|
this.calEvent.organizer.name
|
||||||
|
} on ${this.getInviteeStart().format("dddd, LL")}`,
|
||||||
|
html: this.getHtmlRepresentation(),
|
||||||
|
text: this.getPlainTextRepresentation(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected printNodeMailerError(error: string): void {
|
||||||
|
console.error("SEND_BOOKING_PAYMENT_ERROR", this.calEvent.attendees[0].email, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the inviteeStart value used at multiple points.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
protected getInviteeStart(): Dayjs {
|
||||||
|
return <Dayjs>dayjs(this.calEvent.startTime).tz(this.calEvent.attendees[0].timeZone);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,9 +4,20 @@ import { User, SchedulingType } from "@prisma/client";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
import isBetween from "dayjs/plugin/isBetween";
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import { FreeBusyTime } from "@components/ui/Schedule/Schedule";
|
||||||
dayjs.extend(isBetween);
|
dayjs.extend(isBetween);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
type AvailabilityUserResponse = {
|
||||||
|
busy: FreeBusyTime;
|
||||||
|
workingHours: {
|
||||||
|
daysOfWeek: number[];
|
||||||
|
timeZone: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type Slot = {
|
type Slot = {
|
||||||
time: Dayjs;
|
time: Dayjs;
|
||||||
users?: string[];
|
users?: string[];
|
||||||
|
@ -85,14 +96,18 @@ export const useSlots = (props: UseSlotsProps) => {
|
||||||
}, [date]);
|
}, [date]);
|
||||||
|
|
||||||
const handleAvailableSlots = async (res) => {
|
const handleAvailableSlots = async (res) => {
|
||||||
const responseBody = await res.json();
|
const responseBody: AvailabilityUserResponse = await res.json();
|
||||||
|
|
||||||
responseBody.workingHours.days = responseBody.workingHours.daysOfWeek;
|
const workingHours = {
|
||||||
|
days: responseBody.workingHours.daysOfWeek,
|
||||||
|
startTime: responseBody.workingHours.startTime,
|
||||||
|
endTime: responseBody.workingHours.endTime,
|
||||||
|
};
|
||||||
|
|
||||||
const times = getSlots({
|
const times = getSlots({
|
||||||
frequency: eventLength,
|
frequency: eventLength,
|
||||||
inviteeDate: date,
|
inviteeDate: date,
|
||||||
workingHours: [responseBody.workingHours],
|
workingHours: [workingHours],
|
||||||
minimumBookingNotice,
|
minimumBookingNotice,
|
||||||
organizerTimeZone: responseBody.workingHours.timeZone,
|
organizerTimeZone: responseBody.workingHours.timeZone,
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,8 @@ export function getIntegrationName(name: string) {
|
||||||
return "Zoom";
|
return "Zoom";
|
||||||
case "caldav_calendar":
|
case "caldav_calendar":
|
||||||
return "CalDav Server";
|
return "CalDav Server";
|
||||||
|
case "stripe_payment":
|
||||||
|
return "Stripe";
|
||||||
case "apple_calendar":
|
case "apple_calendar":
|
||||||
return "Apple Calendar";
|
return "Apple Calendar";
|
||||||
default:
|
default:
|
||||||
|
@ -15,9 +17,12 @@ export function getIntegrationName(name: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIntegrationType(name: string) {
|
export function getIntegrationType(name: string): string {
|
||||||
if (name.endsWith("_calendar")) {
|
if (name.endsWith("_calendar")) {
|
||||||
return "Calendar";
|
return "Calendar";
|
||||||
}
|
}
|
||||||
|
if (name.endsWith("_payment")) {
|
||||||
|
return "Payment";
|
||||||
|
}
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
|
|
73
lib/integrations/getIntegrations.ts
Normal file
73
lib/integrations/getIntegrations.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { validJson } from "@lib/jsonUtils";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
|
||||||
|
select: { id: true, type: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
|
||||||
|
|
||||||
|
function getIntegrations(credentials: CredentialData[]) {
|
||||||
|
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.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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/outlook.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
|
||||||
|
type: "zoom_video",
|
||||||
|
credential: credentials.find((integration) => integration.type === "zoom_video") || null,
|
||||||
|
title: "Zoom",
|
||||||
|
imageSrc: "integrations/zoom.svg",
|
||||||
|
description: "Video Conferencing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: true,
|
||||||
|
type: "caldav_calendar",
|
||||||
|
credential: credentials.find((integration) => integration.type === "caldav_calendar") || null,
|
||||||
|
title: "CalDav Server",
|
||||||
|
imageSrc: "integrations/caldav.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: true,
|
||||||
|
type: "apple_calendar",
|
||||||
|
credential: credentials.find((integration) => integration.type === "apple_calendar") || null,
|
||||||
|
title: "Apple Calendar",
|
||||||
|
imageSrc: "integrations/apple-calendar.svg",
|
||||||
|
description: "For personal and business calendars",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
installed: !!(
|
||||||
|
process.env.STRIPE_CLIENT_ID &&
|
||||||
|
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
|
||||||
|
process.env.STRIPE_PRIVATE_KEY
|
||||||
|
),
|
||||||
|
type: "stripe_payment",
|
||||||
|
credential: credentials.find((integration) => integration.type === "stripe_payment") || null,
|
||||||
|
title: "Stripe",
|
||||||
|
imageSrc: "integrations/stripe.svg",
|
||||||
|
description: "Receive payments",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return integrations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasIntegration(integrations: ReturnType<typeof getIntegrations>, type: string): boolean {
|
||||||
|
return !!integrations.find((i) => i.type === type && !!i.installed && !!i.credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getIntegrations;
|
10
lib/mutations/bookings/create-booking.ts
Normal file
10
lib/mutations/bookings/create-booking.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import * as fetch from "@lib/core/http/fetch-wrapper";
|
||||||
|
import { BookingCreateBody, BookingResponse } from "@lib/types/booking";
|
||||||
|
|
||||||
|
const createBooking = async (data: BookingCreateBody) => {
|
||||||
|
const response = await fetch.post<BookingCreateBody, BookingResponse>("/api/book/event", data);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createBooking;
|
21
lib/types/booking.ts
Normal file
21
lib/types/booking.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { LocationType } from "@lib/location";
|
||||||
|
import { Booking } from "@prisma/client";
|
||||||
|
|
||||||
|
export type BookingCreateBody = {
|
||||||
|
email: string;
|
||||||
|
end: string;
|
||||||
|
eventTypeId: number;
|
||||||
|
guests: string[];
|
||||||
|
location?: LocationType;
|
||||||
|
name: string;
|
||||||
|
notes: string;
|
||||||
|
rescheduleUid?: string;
|
||||||
|
start: string;
|
||||||
|
timeZone: string;
|
||||||
|
users?: string[];
|
||||||
|
user?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BookingResponse = Booking & {
|
||||||
|
paymentUid?: string;
|
||||||
|
};
|
|
@ -17,6 +17,14 @@ const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
|
||||||
|
|
||||||
const translator = short();
|
const translator = short();
|
||||||
|
|
||||||
|
export interface ZoomToken {
|
||||||
|
scope: "meeting:write";
|
||||||
|
expires_in: number;
|
||||||
|
token_type: "bearer";
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VideoCallData {
|
export interface VideoCallData {
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -40,13 +48,14 @@ function handleErrorsRaw(response) {
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomAuth = (credential) => {
|
const zoomAuth = (credential: Credential) => {
|
||||||
const isExpired = (expiryDate) => expiryDate < +new Date();
|
const credentialKey = credential.key as unknown as ZoomToken;
|
||||||
|
const isExpired = (expiryDate: number) => expiryDate < +new Date();
|
||||||
const authHeader =
|
const authHeader =
|
||||||
"Basic " +
|
"Basic " +
|
||||||
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
|
Buffer.from(process.env.ZOOM_CLIENT_ID + ":" + process.env.ZOOM_CLIENT_SECRET).toString("base64");
|
||||||
|
|
||||||
const refreshAccessToken = (refreshToken) =>
|
const refreshAccessToken = (refreshToken: string) =>
|
||||||
fetch("https://zoom.us/oauth/token", {
|
fetch("https://zoom.us/oauth/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -69,30 +78,30 @@ const zoomAuth = (credential) => {
|
||||||
key: responseBody,
|
key: responseBody,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
credential.key.access_token = responseBody.access_token;
|
credentialKey.access_token = responseBody.access_token;
|
||||||
credential.key.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
credentialKey.expires_in = Math.round(+new Date() / 1000 + responseBody.expires_in);
|
||||||
return credential.key.access_token;
|
return credentialKey.access_token;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getToken: () =>
|
getToken: () =>
|
||||||
!isExpired(credential.key.expires_in)
|
!isExpired(credentialKey.expires_in)
|
||||||
? Promise.resolve(credential.key.access_token)
|
? Promise.resolve(credentialKey.access_token)
|
||||||
: refreshAccessToken(credential.key.refresh_token),
|
: refreshAccessToken(credentialKey.refresh_token),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VideoApiAdapter {
|
interface VideoApiAdapter {
|
||||||
createMeeting(event: CalendarEvent): Promise<any>;
|
createMeeting(event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
updateMeeting(uid: string, event: CalendarEvent);
|
updateMeeting(uid: string, event: CalendarEvent): Promise<any>;
|
||||||
|
|
||||||
deleteMeeting(uid: string): Promise<unknown>;
|
deleteMeeting(uid: string): Promise<unknown>;
|
||||||
|
|
||||||
getAvailability(dateFrom, dateTo): Promise<any>;
|
getAvailability(dateFrom: string, dateTo: string): Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZoomVideo = (credential): VideoApiAdapter => {
|
const ZoomVideo = (credential: Credential): VideoApiAdapter => {
|
||||||
const auth = zoomAuth(credential);
|
const auth = zoomAuth(credential);
|
||||||
|
|
||||||
const translateEvent = (event: CalendarEvent) => {
|
const translateEvent = (event: CalendarEvent) => {
|
||||||
|
@ -148,7 +157,9 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log(err);
|
console.error(err);
|
||||||
|
/* Prevents booking failure when Zoom Token is expired */
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
createMeeting: (event: CalendarEvent) =>
|
createMeeting: (event: CalendarEvent) =>
|
||||||
|
@ -186,19 +197,19 @@ const ZoomVideo = (credential): VideoApiAdapter => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// factory
|
// factory
|
||||||
const videoIntegrations = (withCredentials): VideoApiAdapter[] =>
|
const videoIntegrations = (withCredentials: Credential[]): VideoApiAdapter[] =>
|
||||||
withCredentials
|
withCredentials.reduce<VideoApiAdapter[]>((acc, cred) => {
|
||||||
.map((cred) => {
|
|
||||||
switch (cred.type) {
|
switch (cred.type) {
|
||||||
case "zoom_video":
|
case "zoom_video":
|
||||||
return ZoomVideo(cred);
|
acc.push(ZoomVideo(cred));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return; // unknown credential, could be legacy? In any case, ignore
|
break;
|
||||||
}
|
}
|
||||||
})
|
return acc;
|
||||||
.filter(Boolean);
|
}, []);
|
||||||
|
|
||||||
const getBusyVideoTimes: (withCredentials) => Promise<unknown[]> = (withCredentials) =>
|
const getBusyVideoTimes: (withCredentials: Credential[]) => Promise<unknown[]> = (withCredentials) =>
|
||||||
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
|
Promise.all(videoIntegrations(withCredentials).map((c) => c.getAvailability())).then((results) =>
|
||||||
results.reduce((acc, availability) => acc.concat(availability), [])
|
results.reduce((acc, availability) => acc.concat(availability), [])
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,11 +3,14 @@ const withTM = require("next-transpile-modules")(["react-timezone-select"]);
|
||||||
|
|
||||||
// So we can test deploy previews preview
|
// So we can test deploy previews preview
|
||||||
if (process.env.VERCEL_URL && !process.env.BASE_URL) {
|
if (process.env.VERCEL_URL && !process.env.BASE_URL) {
|
||||||
process.env.BASE_URL = process.env.VERCEL_URL;
|
process.env.BASE_URL = "https://" + process.env.VERCEL_URL;
|
||||||
}
|
}
|
||||||
if (process.env.BASE_URL) {
|
if (process.env.BASE_URL) {
|
||||||
process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth";
|
process.env.NEXTAUTH_URL = process.env.BASE_URL + "/api/auth";
|
||||||
}
|
}
|
||||||
|
if (!process.env.NEXT_PUBLIC_APP_URL) {
|
||||||
|
process.env.NEXT_PUBLIC_APP_URL = process.env.BASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
if (!process.env.EMAIL_FROM) {
|
if (!process.env.EMAIL_FROM) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
@ -67,7 +70,4 @@ module.exports = withTM({
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
publicRuntimeConfig: {
|
|
||||||
BASE_URL: process.env.BASE_URL || "http://localhost:3000",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -33,7 +33,10 @@
|
||||||
"@radix-ui/react-slider": "^0.1.0",
|
"@radix-ui/react-slider": "^0.1.0",
|
||||||
"@radix-ui/react-switch": "^0.1.0",
|
"@radix-ui/react-switch": "^0.1.0",
|
||||||
"@radix-ui/react-tooltip": "^0.1.0",
|
"@radix-ui/react-tooltip": "^0.1.0",
|
||||||
|
"@stripe/react-stripe-js": "^1.4.1",
|
||||||
|
"@stripe/stripe-js": "^1.16.0",
|
||||||
"@tailwindcss/forms": "^0.3.3",
|
"@tailwindcss/forms": "^0.3.3",
|
||||||
|
"@types/stripe": "^8.0.417",
|
||||||
"async": "^3.2.1",
|
"async": "^3.2.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
|
@ -46,6 +49,7 @@
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lodash.merge": "^4.6.2",
|
"lodash.merge": "^4.6.2",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"micro": "^9.3.4",
|
||||||
"next": "^11.1.1",
|
"next": "^11.1.1",
|
||||||
"next-auth": "^3.28.0",
|
"next-auth": "^3.28.0",
|
||||||
"next-seo": "^4.26.0",
|
"next-seo": "^4.26.0",
|
||||||
|
@ -59,12 +63,14 @@
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
"react-easy-crop": "^3.5.2",
|
"react-easy-crop": "^3.5.2",
|
||||||
"react-hot-toast": "^2.1.0",
|
"react-hot-toast": "^2.1.0",
|
||||||
|
"react-intl": "^5.20.7",
|
||||||
"react-multi-email": "^0.5.3",
|
"react-multi-email": "^0.5.3",
|
||||||
"react-phone-number-input": "^3.1.25",
|
"react-phone-number-input": "^3.1.25",
|
||||||
"react-query": "^3.21.0",
|
"react-query": "^3.21.0",
|
||||||
"react-select": "^4.3.1",
|
"react-select": "^4.3.1",
|
||||||
"react-timezone-select": "^1.0.7",
|
"react-timezone-select": "^1.0.7",
|
||||||
"short-uuid": "^4.2.0",
|
"short-uuid": "^4.2.0",
|
||||||
|
"stripe": "^8.168.0",
|
||||||
"tsdav": "1.0.6",
|
"tsdav": "1.0.6",
|
||||||
"tslog": "^3.2.1",
|
"tslog": "^3.2.1",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2"
|
||||||
|
@ -79,6 +85,7 @@
|
||||||
"@types/react": "^17.0.18",
|
"@types/react": "^17.0.18",
|
||||||
"@types/react-dates": "^21.8.3",
|
"@types/react-dates": "^21.8.3",
|
||||||
"@types/react-select": "^4.0.17",
|
"@types/react-select": "^4.0.17",
|
||||||
|
"@types/uuid": "8.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
||||||
"@typescript-eslint/parser": "^4.29.2",
|
"@typescript-eslint/parser": "^4.29.2",
|
||||||
"autoprefixer": "^10.3.1",
|
"autoprefixer": "^10.3.1",
|
||||||
|
|
|
@ -110,6 +110,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
length: true,
|
length: true,
|
||||||
description: true,
|
description: true,
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
schedulingType: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
},
|
},
|
||||||
take: user.plan === "FREE" ? 1 : undefined,
|
take: user.plan === "FREE" ? 1 : undefined,
|
||||||
});
|
});
|
||||||
|
|
|
@ -56,6 +56,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
availability: true,
|
availability: true,
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
avatar: true,
|
avatar: true,
|
||||||
|
@ -92,6 +94,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
availability: true,
|
availability: true,
|
||||||
description: true,
|
description: true,
|
||||||
length: true,
|
length: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
avatar: true,
|
avatar: true,
|
||||||
|
|
|
@ -1,20 +1,25 @@
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import utc from "dayjs/plugin/utc";
|
|
||||||
import timezone from "dayjs/plugin/timezone";
|
|
||||||
import BookingPage from "@components/booking/pages/BookingPage";
|
import BookingPage from "@components/booking/pages/BookingPage";
|
||||||
|
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function Book(props: any): JSX.Element {
|
export type BookPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
export default function Book(props: BookPageProps) {
|
||||||
return <BookingPage {...props} />;
|
return <BookingPage {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
username: context.query.user,
|
username: asStringOrThrow(context.query.user),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
username: true,
|
username: true,
|
||||||
|
@ -26,9 +31,11 @@ export async function getServerSideProps(context) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!user) return { notFound: true };
|
||||||
|
|
||||||
const eventType = await prisma.eventType.findUnique({
|
const eventType = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: parseInt(context.query.type),
|
id: parseInt(asStringOrThrow(context.query.type)),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -43,6 +50,8 @@ export async function getServerSideProps(context) {
|
||||||
periodStartDate: true,
|
periodStartDate: true,
|
||||||
periodEndDate: true,
|
periodEndDate: true,
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
disableGuests: true,
|
disableGuests: true,
|
||||||
users: {
|
users: {
|
||||||
select: {
|
select: {
|
||||||
|
@ -57,6 +66,8 @@ export async function getServerSideProps(context) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!eventType) return { notFound: true };
|
||||||
|
|
||||||
const eventTypeObject = [eventType].map((e) => {
|
const eventTypeObject = [eventType].map((e) => {
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
|
@ -70,7 +81,7 @@ export async function getServerSideProps(context) {
|
||||||
if (context.query.rescheduleUid) {
|
if (context.query.rescheduleUid) {
|
||||||
booking = await prisma.booking.findFirst({
|
booking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uid: context.query.rescheduleUid,
|
uid: asStringOrThrow(context.query.rescheduleUid),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
description: true,
|
description: true,
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
import { getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
// import { getBusyVideoTimes } from "@lib/videoClient";
|
// import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { User } from "@prisma/client";
|
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const user = asStringOrNull(req.query.user);
|
const user = asStringOrNull(req.query.user);
|
||||||
|
@ -15,9 +14,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(400).json({ message: "Invalid time range given." });
|
return res.status(400).json({ message: "Invalid time range given." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser: User = await prisma.user.findUnique({
|
const rawUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
username: user,
|
username: user as string,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
@ -27,14 +26,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
id: true,
|
id: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
endTime: true,
|
endTime: true,
|
||||||
|
selectedCalendars: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCalendars = await prisma.selectedCalendar.findMany({
|
if (!rawUser) throw new Error("No user found");
|
||||||
where: {
|
|
||||||
userId: currentUser.id,
|
const { selectedCalendars, ...currentUser } = rawUser;
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const busyTimes = await getBusyCalendarTimes(
|
const busyTimes = await getBusyCalendarTimes(
|
||||||
currentUser.credentials,
|
currentUser.credentials,
|
||||||
|
|
|
@ -89,6 +89,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
periodEndDate: req.body.periodEndDate,
|
periodEndDate: req.body.periodEndDate,
|
||||||
periodCountCalendarDays: req.body.periodCountCalendarDays,
|
periodCountCalendarDays: req.body.periodCountCalendarDays,
|
||||||
minimumBookingNotice: req.body.minimumBookingNotice,
|
minimumBookingNotice: req.body.minimumBookingNotice,
|
||||||
|
price: req.body.price,
|
||||||
|
currency: req.body.currency,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (req.body.schedulingType) {
|
if (req.body.schedulingType) {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import prisma from "../../../lib/prisma";
|
||||||
import { CalendarEvent } from "@lib/calendarClient";
|
import { CalendarEvent } from "@lib/calendarClient";
|
||||||
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
import EventRejectionMail from "@lib/emails/EventRejectionMail";
|
||||||
import EventManager from "@lib/events/EventManager";
|
import EventManager from "@lib/events/EventManager";
|
||||||
|
import { refund } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||||
const session = await getSession({ req: req });
|
const session = await getSession({ req: req });
|
||||||
|
@ -45,6 +46,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
userId: true,
|
userId: true,
|
||||||
id: true,
|
id: true,
|
||||||
uid: true,
|
uid: true,
|
||||||
|
payment: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -84,6 +86,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
res.status(204).json({ message: "ok" });
|
res.status(204).json({ message: "ok" });
|
||||||
} else {
|
} else {
|
||||||
|
await refund(booking, evt);
|
||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
where: {
|
where: {
|
||||||
id: bookingId,
|
id: bookingId,
|
||||||
|
|
|
@ -1,14 +1,6 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import {
|
import { SchedulingType, Prisma } from "@prisma/client";
|
||||||
EventType,
|
|
||||||
User,
|
|
||||||
SchedulingType,
|
|
||||||
Credential,
|
|
||||||
SelectedCalendar,
|
|
||||||
Booking,
|
|
||||||
Prisma,
|
|
||||||
} from "@prisma/client";
|
|
||||||
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
import { CalendarEvent, getBusyCalendarTimes } from "@lib/calendarClient";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import short from "short-uuid";
|
import short from "short-uuid";
|
||||||
|
@ -16,13 +8,15 @@ import { getBusyVideoTimes } from "@lib/videoClient";
|
||||||
import { getEventName } from "@lib/event";
|
import { getEventName } from "@lib/event";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
import EventManager, { CreateUpdateResult, EventResult } from "@lib/events/EventManager";
|
import EventManager, { CreateUpdateResult, EventResult, PartialReference } from "@lib/events/EventManager";
|
||||||
|
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import isBetween from "dayjs/plugin/isBetween";
|
import isBetween from "dayjs/plugin/isBetween";
|
||||||
import dayjsBusinessDays from "dayjs-business-days";
|
import dayjsBusinessDays from "dayjs-business-days";
|
||||||
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
import EventOrganizerRequestMail from "@lib/emails/EventOrganizerRequestMail";
|
||||||
|
import { handlePayment } from "@ee/lib/stripe/server";
|
||||||
|
import { BookingCreateBody } from "@lib/types/booking";
|
||||||
|
|
||||||
dayjs.extend(dayjsBusinessDays);
|
dayjs.extend(dayjsBusinessDays);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
@ -32,7 +26,8 @@ dayjs.extend(timezone);
|
||||||
const translator = short();
|
const translator = short();
|
||||||
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
const log = logger.getChildLogger({ prefix: ["[api] book:user"] });
|
||||||
|
|
||||||
function isAvailable(busyTimes, time, length) {
|
type BufferedBusyTimes = { start: string; end: string }[];
|
||||||
|
function isAvailable(busyTimes: BufferedBusyTimes, time: string, length: number): boolean {
|
||||||
// Check for conflicts
|
// Check for conflicts
|
||||||
let t = true;
|
let t = true;
|
||||||
|
|
||||||
|
@ -88,15 +83,16 @@ function isOutOfBounds(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const eventTypeId = parseInt(req.body.eventTypeId as string);
|
const reqBody = req.body as BookingCreateBody;
|
||||||
|
const eventTypeId = reqBody.eventTypeId;
|
||||||
|
|
||||||
log.debug(`Booking eventType ${eventTypeId} started`);
|
log.debug(`Booking eventType ${eventTypeId} started`);
|
||||||
|
|
||||||
const isTimeInPast = (time) => {
|
const isTimeInPast = (time: string): boolean => {
|
||||||
return dayjs(time).isBefore(new Date(), "day");
|
return dayjs(time).isBefore(new Date(), "day");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isTimeInPast(req.body.start)) {
|
if (isTimeInPast(reqBody.start)) {
|
||||||
const error = {
|
const error = {
|
||||||
errorCode: "BookingDateInPast",
|
errorCode: "BookingDateInPast",
|
||||||
message: "Attempting to create a meeting in the past.",
|
message: "Attempting to create a meeting in the past.",
|
||||||
|
@ -106,19 +102,27 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
return res.status(400).json(error);
|
return res.status(400).json(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType: EventType = await prisma.eventType.findUnique({
|
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||||
where: {
|
|
||||||
id: eventTypeId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
users: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
name: true,
|
name: true,
|
||||||
username: true,
|
username: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
|
credentials: true,
|
||||||
|
bufferTime: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userData = Prisma.validator<Prisma.UserArgs>()({
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventType = await prisma.eventType.findUnique({
|
||||||
|
where: {
|
||||||
|
id: eventTypeId,
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
users: {
|
||||||
|
select: userSelect,
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
|
@ -137,71 +141,66 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
periodCountCalendarDays: true,
|
periodCountCalendarDays: true,
|
||||||
requiresConfirmation: true,
|
requiresConfirmation: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!eventType.users.length && eventType.userId) {
|
if (!eventType) return res.status(404).json({ message: "eventType.notFound" });
|
||||||
eventType.users.push(
|
|
||||||
await prisma.user.findUnique({
|
let users = eventType.users;
|
||||||
|
|
||||||
|
/* If this event was pre-relationship migration */
|
||||||
|
if (!users.length && eventType.userId) {
|
||||||
|
const evenTypeUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: eventType.userId,
|
id: eventType.userId,
|
||||||
},
|
},
|
||||||
select: {
|
select: userSelect,
|
||||||
id: true,
|
});
|
||||||
email: true,
|
if (!evenTypeUser) return res.status(404).json({ message: "eventTypeUser.notFound" });
|
||||||
name: true,
|
users.push(evenTypeUser);
|
||||||
username: true,
|
|
||||||
timeZone: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let users: User[] = eventType.users;
|
|
||||||
|
|
||||||
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||||
const selectedUsers = req.body.users || [];
|
const selectedUsers = reqBody.users || [];
|
||||||
// one of these things that can probably be done better
|
const selectedUsersDataWithBookingsCount = await prisma.user.findMany({
|
||||||
// prisma is not well documented.
|
|
||||||
users = await Promise.all(
|
|
||||||
selectedUsers.map(async (username) => {
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {
|
where: {
|
||||||
username,
|
username: { in: selectedUsers },
|
||||||
},
|
|
||||||
select: {
|
|
||||||
bookings: {
|
bookings: {
|
||||||
where: {
|
every: {
|
||||||
startTime: {
|
startTime: {
|
||||||
gt: new Date(),
|
gt: new Date(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
username: true,
|
||||||
},
|
_count: {
|
||||||
|
select: { bookings: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
username,
|
const bookingCounts = selectedUsersDataWithBookingsCount.map((userData) => ({
|
||||||
bookingCount: user.bookings.length,
|
username: userData.username,
|
||||||
};
|
bookingCount: userData._count?.bookings || 0,
|
||||||
})
|
}));
|
||||||
).then((bookingCounts) => {
|
|
||||||
if (!bookingCounts.length) {
|
if (!bookingCounts.length) users.slice(0, 1);
|
||||||
return users.slice(0, 1);
|
|
||||||
}
|
const [firstMostAvailableUser] = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
|
||||||
const sorted = bookingCounts.sort((a, b) => (a.bookingCount > b.bookingCount ? 1 : -1));
|
const luckyUser = users.find((user) => user.username === firstMostAvailableUser?.username);
|
||||||
return [users.find((user) => user.username === sorted[0].username)];
|
users = luckyUser ? [luckyUser] : users;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitee = [{ email: req.body.email, name: req.body.name, timeZone: req.body.timeZone }];
|
const invitee = [{ email: reqBody.email, name: reqBody.name, timeZone: reqBody.timeZone }];
|
||||||
const guests = req.body.guests.map((guest) => {
|
const guests = reqBody.guests.map((guest) => {
|
||||||
const g = {
|
const g = {
|
||||||
email: guest,
|
email: guest,
|
||||||
name: "",
|
name: "",
|
||||||
timeZone: req.body.timeZone,
|
timeZone: reqBody.timeZone,
|
||||||
};
|
};
|
||||||
return g;
|
return g;
|
||||||
});
|
});
|
||||||
|
@ -217,35 +216,43 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
const attendeesList = [...invitee, ...guests, ...teamMembers];
|
const attendeesList = [...invitee, ...guests, ...teamMembers];
|
||||||
|
|
||||||
const seed = `${users[0].username}:${dayjs(req.body.start).utc().format()}`;
|
const seed = `${users[0].username}:${dayjs(reqBody.start).utc().format()}`;
|
||||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||||
|
|
||||||
const evt: CalendarEvent = {
|
const evt: CalendarEvent = {
|
||||||
type: eventType.title,
|
type: eventType.title,
|
||||||
title: getEventName(req.body.name, eventType.title, eventType.eventName),
|
title: getEventName(reqBody.name, eventType.title, eventType.eventName),
|
||||||
description: req.body.notes,
|
description: reqBody.notes,
|
||||||
startTime: req.body.start,
|
startTime: reqBody.start,
|
||||||
endTime: req.body.end,
|
endTime: reqBody.end,
|
||||||
organizer: {
|
organizer: {
|
||||||
name: users[0].name,
|
name: users[0].name,
|
||||||
email: users[0].email,
|
email: users[0].email,
|
||||||
timeZone: users[0].timeZone,
|
timeZone: users[0].timeZone,
|
||||||
},
|
},
|
||||||
attendees: attendeesList,
|
attendees: attendeesList,
|
||||||
location: req.body.location, // Will be processed by the EventManager later.
|
location: reqBody.location, // Will be processed by the EventManager later.
|
||||||
};
|
};
|
||||||
|
|
||||||
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
|
if (eventType.schedulingType === SchedulingType.COLLECTIVE) {
|
||||||
evt.team = {
|
evt.team = {
|
||||||
members: users.map((user) => user.name || user.username),
|
members: users.map((user) => user.name || user.username || "Nameless"),
|
||||||
name: eventType.team.name,
|
name: eventType.team?.name || "Nameless",
|
||||||
}; // used for invitee emails
|
}; // used for invitee emails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize EventManager with credentials
|
// Initialize EventManager with credentials
|
||||||
const rescheduleUid = req.body.rescheduleUid;
|
const rescheduleUid = reqBody.rescheduleUid;
|
||||||
|
|
||||||
const bookingCreateInput: Prisma.BookingCreateInput = {
|
function createBooking() {
|
||||||
|
return prisma.booking.create({
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { email: true, name: true, timeZone: true },
|
||||||
|
},
|
||||||
|
attendees: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
uid,
|
uid,
|
||||||
title: evt.title,
|
title: evt.title,
|
||||||
startTime: dayjs(evt.startTime).toDate(),
|
startTime: dayjs(evt.startTime).toDate(),
|
||||||
|
@ -268,72 +275,61 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
id: users[0].id,
|
id: users[0].id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
|
||||||
let booking: Booking | null;
|
|
||||||
try {
|
|
||||||
booking = await prisma.booking.create({
|
|
||||||
data: bookingCreateInput,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
|
||||||
|
let booking: Booking | null = null;
|
||||||
|
try {
|
||||||
|
booking = await createBooking();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", e.message);
|
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", e.message);
|
||||||
if (e.code === "P2002") {
|
if (e.code === "P2002") {
|
||||||
return res.status(409).json({ message: "booking.conflict" });
|
res.status(409).json({ message: "booking.conflict" });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return res.status(500).end();
|
res.status(500).end();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let results: EventResult[] = [];
|
let results: EventResult[] = [];
|
||||||
let referencesToCreate = [];
|
let referencesToCreate: PartialReference[] = [];
|
||||||
|
type User = Prisma.UserGetPayload<typeof userData>;
|
||||||
const loadUser = async (id): Promise<User> =>
|
let user: User | null = null;
|
||||||
await prisma.user.findUnique({
|
for (const currentUser of users) {
|
||||||
where: {
|
if (!currentUser) {
|
||||||
id,
|
console.error(`currentUser not found`);
|
||||||
},
|
return;
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
credentials: true,
|
|
||||||
timeZone: true,
|
|
||||||
email: true,
|
|
||||||
username: true,
|
|
||||||
name: true,
|
|
||||||
bufferTime: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let user: User;
|
|
||||||
for (const currentUser of await Promise.all(users.map((user) => loadUser(user.id)))) {
|
|
||||||
if (!user) {
|
|
||||||
user = currentUser;
|
|
||||||
}
|
}
|
||||||
|
if (!user) user = currentUser;
|
||||||
|
|
||||||
const selectedCalendars: SelectedCalendar[] = await prisma.selectedCalendar.findMany({
|
const selectedCalendars = await prisma.selectedCalendar.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const credentials: Credential[] = currentUser.credentials;
|
const credentials = currentUser.credentials;
|
||||||
if (credentials) {
|
if (credentials) {
|
||||||
const calendarBusyTimes = await getBusyCalendarTimes(
|
const calendarBusyTimes = await getBusyCalendarTimes(
|
||||||
credentials,
|
credentials,
|
||||||
req.body.start,
|
reqBody.start,
|
||||||
req.body.end,
|
reqBody.end,
|
||||||
selectedCalendars
|
selectedCalendars
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoBusyTimes = await getBusyVideoTimes(credentials);
|
const videoBusyTimes = await getBusyVideoTimes(credentials);
|
||||||
calendarBusyTimes.push(...videoBusyTimes);
|
calendarBusyTimes.push(...videoBusyTimes);
|
||||||
|
|
||||||
const bufferedBusyTimes = calendarBusyTimes.map((a) => ({
|
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
|
||||||
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
start: dayjs(a.start).subtract(currentUser.bufferTime, "minute").toString(),
|
||||||
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let isAvailableToBeBooked = true;
|
let isAvailableToBeBooked = true;
|
||||||
try {
|
try {
|
||||||
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, req.body.start, eventType.length);
|
isAvailableToBeBooked = isAvailable(bufferedBusyTimes, reqBody.start, eventType.length);
|
||||||
} catch {
|
} catch {
|
||||||
log.debug({
|
log.debug({
|
||||||
message: "Unable set isAvailableToBeBooked. Using true. ",
|
message: "Unable set isAvailableToBeBooked. Using true. ",
|
||||||
|
@ -352,7 +348,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
let timeOutOfBounds = false;
|
let timeOutOfBounds = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
timeOutOfBounds = isOutOfBounds(req.body.start, {
|
timeOutOfBounds = isOutOfBounds(reqBody.start, {
|
||||||
periodType: eventType.periodType,
|
periodType: eventType.periodType,
|
||||||
periodDays: eventType.periodDays,
|
periodDays: eventType.periodDays,
|
||||||
periodEndDate: eventType.periodEndDate,
|
periodEndDate: eventType.periodEndDate,
|
||||||
|
@ -395,7 +391,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
|
|
||||||
log.error(`Booking ${user.name} failed`, error, results);
|
log.error(`Booking ${user.name} failed`, error, results);
|
||||||
}
|
}
|
||||||
} else if (!eventType.requiresConfirmation) {
|
} else if (!eventType.requiresConfirmation && !eventType.price) {
|
||||||
// Use EventManager to conditionally use all needed integrations.
|
// Use EventManager to conditionally use all needed integrations.
|
||||||
const createResults: CreateUpdateResult = await eventManager.create(evt, uid);
|
const createResults: CreateUpdateResult = await eventManager.create(evt, uid);
|
||||||
|
|
||||||
|
@ -416,6 +412,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
await new EventOrganizerRequestMail(evt, uid).sendEmail();
|
await new EventOrganizerRequestMail(evt, uid).sendEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof eventType.price === "number" && eventType.price > 0) {
|
||||||
|
try {
|
||||||
|
const [firstStripeCredential] = user.credentials.filter((cred) => cred.type == "stripe_payment");
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
/* @ts-ignore https://github.com/prisma/prisma/issues/9389 */
|
||||||
|
if (!booking.user) booking.user = user;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
/* @ts-ignore https://github.com/prisma/prisma/issues/9389 */
|
||||||
|
const payment = await handlePayment(evt, eventType, firstStripeCredential, booking);
|
||||||
|
|
||||||
|
res.status(201).json({ ...booking, message: "Payment required", paymentUid: payment.uid });
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`Creating payment failed`, e);
|
||||||
|
res.status(500).json({ message: "Payment Failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.debug(`Booking ${user.username} completed`);
|
log.debug(`Booking ${user.username} completed`);
|
||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { deleteEvent } from "@lib/calendarClient";
|
import { CalendarEvent, deleteEvent } from "@lib/calendarClient";
|
||||||
import { deleteMeeting } from "@lib/videoClient";
|
import { deleteMeeting } from "@lib/videoClient";
|
||||||
import async from "async";
|
import async from "async";
|
||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus } from "@prisma/client";
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
|
import { refund } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
// just bail if it not a DELETE
|
// just bail if it not a DELETE
|
||||||
|
@ -22,6 +23,9 @@ export default async function handler(req, res) {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
email: true,
|
||||||
|
timeZone: true,
|
||||||
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
attendees: true,
|
attendees: true,
|
||||||
|
@ -31,6 +35,14 @@ export default async function handler(req, res) {
|
||||||
type: true,
|
type: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
payment: true,
|
||||||
|
paid: true,
|
||||||
|
location: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
uid: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -60,6 +72,36 @@ export default async function handler(req, res) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (bookingToDelete && bookingToDelete.paid) {
|
||||||
|
const evt: CalendarEvent = {
|
||||||
|
type: bookingToDelete.title,
|
||||||
|
title: bookingToDelete.title,
|
||||||
|
description: bookingToDelete.description ?? "",
|
||||||
|
startTime: bookingToDelete.startTime.toISOString(),
|
||||||
|
endTime: bookingToDelete.endTime.toISOString(),
|
||||||
|
organizer: {
|
||||||
|
email: bookingToDelete.user?.email ?? "dev@calendso.com",
|
||||||
|
name: bookingToDelete.user?.name ?? "no user",
|
||||||
|
timeZone: bookingToDelete.user?.timeZone ?? "",
|
||||||
|
},
|
||||||
|
attendees: bookingToDelete.attendees,
|
||||||
|
location: bookingToDelete.location ?? "",
|
||||||
|
};
|
||||||
|
await refund(bookingToDelete, evt);
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: {
|
||||||
|
id: bookingToDelete.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
rejected: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// We skip the deletion of the event, because that would also delete the payment reference, which we should keep
|
||||||
|
await apiDeletes;
|
||||||
|
return res.status(200).json({ message: "Booking successfully deleted." });
|
||||||
|
}
|
||||||
|
|
||||||
const attendeeDeletes = prisma.attendee.deleteMany({
|
const attendeeDeletes = prisma.attendee.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
bookingId: bookingToDelete.id,
|
bookingId: bookingToDelete.id,
|
||||||
|
|
1
pages/api/integrations/stripepayment/add.ts
Normal file
1
pages/api/integrations/stripepayment/add.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "@ee/pages/api/integrations/stripepayment/add";
|
1
pages/api/integrations/stripepayment/callback.ts
Normal file
1
pages/api/integrations/stripepayment/callback.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "@ee/pages/api/integrations/stripepayment/callback";
|
1
pages/api/integrations/stripepayment/webhook.ts
Normal file
1
pages/api/integrations/stripepayment/webhook.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { default, config } from "@ee/pages/api/integrations/stripepayment/webhook";
|
|
@ -33,7 +33,7 @@ export default function Troubleshoot({ user }) {
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
.then((availableIntervals) => {
|
.then((availableIntervals) => {
|
||||||
setAvailability(availableIntervals);
|
setAvailability(availableIntervals.busy);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import Modal from "@components/Modal";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import Select, { OptionTypeBase } from "react-select";
|
import Select, { OptionTypeBase } from "react-select";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { Availability, EventTypeCustomInput, EventTypeCustomInputType, SchedulingType } from "@prisma/client";
|
import { EventTypeCustomInput, EventTypeCustomInputType, SchedulingType } from "@prisma/client";
|
||||||
import { LocationType } from "@lib/location";
|
import { LocationType } from "@lib/location";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
|
@ -28,7 +28,6 @@ import {
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import { validJson } from "@lib/jsonUtils";
|
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import "react-dates/initialize";
|
import "react-dates/initialize";
|
||||||
import "react-dates/lib/css/_datepicker.css";
|
import "react-dates/lib/css/_datepicker.css";
|
||||||
|
@ -38,7 +37,7 @@ import { Dialog, DialogTrigger } from "@components/Dialog";
|
||||||
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
import { EventTypeInput } from "@lib/types/event-type";
|
import { AdvancedOptions, EventTypeInput } from "@lib/types/event-type";
|
||||||
import updateEventType from "@lib/mutations/event-types/update-event-type";
|
import updateEventType from "@lib/mutations/event-types/update-event-type";
|
||||||
import deleteEventType from "@lib/mutations/event-types/delete-event-type";
|
import deleteEventType from "@lib/mutations/event-types/delete-event-type";
|
||||||
import showToast from "@lib/notification";
|
import showToast from "@lib/notification";
|
||||||
|
@ -47,8 +46,11 @@ import { defaultAvatarSrc } from "@lib/profile";
|
||||||
import * as RadioArea from "@components/ui/form/radio-area";
|
import * as RadioArea from "@components/ui/form/radio-area";
|
||||||
import classNames from "@lib/classNames";
|
import classNames from "@lib/classNames";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
import { FormattedNumber, IntlProvider } from "react-intl";
|
||||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
import getIntegrations, { hasIntegration } from "@lib/integrations/getIntegrations";
|
||||||
|
import Stripe from "stripe";
|
||||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
@ -70,7 +72,8 @@ const PERIOD_TYPES = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
const { eventType, locationOptions, availability, team, teamMembers } = props;
|
const { eventType, locationOptions, availability, team, teamMembers, hasPaymentIntegration, currency } =
|
||||||
|
props;
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||||
|
@ -172,14 +175,17 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
PERIOD_TYPES.find((s) => s.type === "unlimited")
|
PERIOD_TYPES.find((s) => s.type === "unlimited")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
|
||||||
|
|
||||||
const [hidden, setHidden] = useState<boolean>(eventType.hidden);
|
const [hidden, setHidden] = useState<boolean>(eventType.hidden);
|
||||||
|
|
||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
const slugRef = useRef<HTMLInputElement>(null);
|
const slugRef = useRef<HTMLInputElement>(null);
|
||||||
const requiresConfirmationRef = useRef<HTMLInputElement>(null);
|
const requiresConfirmationRef = useRef<HTMLInputElement>(null);
|
||||||
const eventNameRef = useRef<HTMLInputElement>(null);
|
const eventNameRef = useRef<HTMLInputElement>(null);
|
||||||
const periodDaysRef = useRef<HTMLInputElement>(null);
|
const periodDaysRef = useRef<HTMLInputElement>(null);
|
||||||
const periodDaysTypeRef = useRef<HTMLSelectElement>(null);
|
const periodDaysTypeRef = useRef<HTMLSelectElement>(null);
|
||||||
|
const priceRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedTimeZone(eventType.timeZone);
|
setSelectedTimeZone(eventType.timeZone);
|
||||||
|
@ -192,6 +198,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
|
|
||||||
const enteredTitle: string = titleRef.current.value;
|
const enteredTitle: string = titleRef.current.value;
|
||||||
const enteredSlug: string = slugRef.current.value;
|
const enteredSlug: string = slugRef.current.value;
|
||||||
|
const enteredPrice = requirePayment ? Math.round(parseFloat(priceRef.current.value) * 100) : 0;
|
||||||
|
|
||||||
const advancedOptionsPayload: AdvancedOptions = {};
|
const advancedOptionsPayload: AdvancedOptions = {};
|
||||||
if (requiresConfirmationRef.current) {
|
if (requiresConfirmationRef.current) {
|
||||||
|
@ -223,6 +230,8 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
users,
|
users,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
price: enteredPrice,
|
||||||
|
currency: currency,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateMutation.mutate(payload);
|
updateMutation.mutate(payload);
|
||||||
|
@ -861,6 +870,90 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasPaymentIntegration && (
|
||||||
|
<>
|
||||||
|
<hr className="border-neutral-200" />
|
||||||
|
<div className="block sm:flex">
|
||||||
|
<div className="min-w-44 mb-4 sm:mb-0">
|
||||||
|
<label
|
||||||
|
htmlFor="payment"
|
||||||
|
className="text-sm flex font-medium text-neutral-700 mt-2">
|
||||||
|
Payment
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="block sm:flex items-center">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="relative flex items-start">
|
||||||
|
<div className="flex items-center h-5">
|
||||||
|
<input
|
||||||
|
onChange={(event) => setRequirePayment(event.target.checked)}
|
||||||
|
id="requirePayment"
|
||||||
|
name="requirePayment"
|
||||||
|
type="checkbox"
|
||||||
|
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
||||||
|
defaultChecked={requirePayment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm">
|
||||||
|
<p className="text-neutral-900">
|
||||||
|
Require Payment (0.5% +{" "}
|
||||||
|
<IntlProvider locale="en">
|
||||||
|
<FormattedNumber
|
||||||
|
value={0.1}
|
||||||
|
style="currency"
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
</IntlProvider>{" "}
|
||||||
|
commission per transaction)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{requirePayment && (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="block sm:flex items-center">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mt-1 relative rounded-sm shadow-sm">
|
||||||
|
<input
|
||||||
|
ref={priceRef}
|
||||||
|
type="number"
|
||||||
|
name="price"
|
||||||
|
id="price"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
className="focus:ring-primary-500 focus:border-primary-500 block w-full pl-2 pr-12 sm:text-sm border-gray-300 rounded-sm"
|
||||||
|
placeholder="Price"
|
||||||
|
defaultValue={
|
||||||
|
eventType.price > 0 ? eventType.price / 100.0 : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<span className="text-gray-500 sm:text-sm" id="duration">
|
||||||
|
{new Intl.NumberFormat("en", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency,
|
||||||
|
maximumSignificantDigits: 1,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})
|
||||||
|
.format(0)
|
||||||
|
.replace("0", "")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Disclosure.Panel>
|
</Disclosure.Panel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -899,8 +992,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
window.location.hostname +
|
"https://cal.com/" +
|
||||||
"/" +
|
|
||||||
(team ? "team/" + team.slug : eventType.users[0].username) +
|
(team ? "team/" + team.slug : eventType.users[0].username) +
|
||||||
"/" +
|
"/" +
|
||||||
eventType.slug
|
eventType.slug
|
||||||
|
@ -1174,6 +1266,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
},
|
},
|
||||||
schedulingType: true,
|
schedulingType: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1208,24 +1302,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const integrations = [
|
const integrations = getIntegrations(credentials);
|
||||||
{
|
|
||||||
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
|
||||||
enabled: credentials.find((integration) => integration.type === "google_calendar") != null,
|
|
||||||
type: "google_calendar",
|
|
||||||
title: "Google Calendar",
|
|
||||||
imageSrc: "integrations/google-calendar.svg",
|
|
||||||
description: "For personal and business accounts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET),
|
|
||||||
type: "office365_calendar",
|
|
||||||
enabled: credentials.find((integration) => integration.type === "office365_calendar") != null,
|
|
||||||
title: "Office 365 / Outlook.com Calendar",
|
|
||||||
imageSrc: "integrations/outlook.svg",
|
|
||||||
description: "For personal and business accounts",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const locationOptions: OptionTypeBase[] = [
|
const locationOptions: OptionTypeBase[] = [
|
||||||
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
|
{ value: LocationType.InPerson, label: "Link or In-person meeting" },
|
||||||
|
@ -1233,27 +1310,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
{ value: LocationType.Zoom, label: "Zoom Video", disabled: true },
|
{ value: LocationType.Zoom, label: "Zoom Video", disabled: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasGoogleCalendarIntegration = integrations.find(
|
const hasPaymentIntegration = hasIntegration(integrations, "stripe_payment");
|
||||||
(i) => i.type === "google_calendar" && i.installed === true && i.enabled
|
if (hasIntegration(integrations, "google_calendar")) {
|
||||||
);
|
|
||||||
if (hasGoogleCalendarIntegration) {
|
|
||||||
locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" });
|
locationOptions.push({ value: LocationType.GoogleMeet, label: "Google Meet" });
|
||||||
}
|
}
|
||||||
|
const currency =
|
||||||
|
(credentials.find((integration) => integration.type === "stripe_payment")?.key as Stripe.OAuthToken)
|
||||||
|
?.default_currency || "usd";
|
||||||
|
|
||||||
const hasOfficeIntegration = integrations.find(
|
if (hasIntegration(integrations, "office365_calendar")) {
|
||||||
(i) => i.type === "office365_calendar" && i.installed === true && i.enabled
|
|
||||||
);
|
|
||||||
if (hasOfficeIntegration) {
|
|
||||||
// TODO: Add default meeting option of the office integration.
|
// TODO: Add default meeting option of the office integration.
|
||||||
// Assuming it's Microsoft Teams.
|
// Assuming it's Microsoft Teams.
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAvailability = (providesAvailability) =>
|
type Availability = typeof eventType["availability"];
|
||||||
providesAvailability.availability && providesAvailability.availability.length
|
const getAvailability = (availability: Availability) => (availability?.length ? availability : null);
|
||||||
? providesAvailability.availability
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const availability: Availability[] = getAvailability(eventType) || [];
|
const availability = getAvailability(eventType.availability) || [];
|
||||||
availability.sort((a, b) => a.startTime - b.startTime);
|
availability.sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
const eventTypeObject = Object.assign({}, eventType, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
|
@ -1276,6 +1349,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
availability,
|
availability,
|
||||||
team: eventTypeObject.team || null,
|
team: eventTypeObject.team || null,
|
||||||
teamMembers,
|
teamMembers,
|
||||||
|
hasPaymentIntegration,
|
||||||
|
currency,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -43,6 +43,7 @@ import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { Fragment, useRef } from "react";
|
import React, { Fragment, useRef } from "react";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
type PageProps = inferSSRProps<typeof getServerSideProps>;
|
type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
type EventType = PageProps["eventTypes"][number];
|
type EventType = PageProps["eventTypes"][number];
|
||||||
|
@ -68,15 +69,15 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
profile,
|
profile,
|
||||||
membershipCount,
|
membershipCount,
|
||||||
}: {
|
}: {
|
||||||
profile: Profile;
|
profile?: Profile;
|
||||||
membershipCount: MembershipCount;
|
membershipCount: MembershipCount;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex mb-4">
|
<div className="flex mb-4">
|
||||||
<Link href="/settings/teams">
|
<Link href="/settings/teams">
|
||||||
<a>
|
<a>
|
||||||
<Avatar
|
<Avatar
|
||||||
displayName={profile.name}
|
displayName={profile?.name || ""}
|
||||||
imageSrc={profile.image || undefined}
|
imageSrc={profile?.image || undefined}
|
||||||
size={8}
|
size={8}
|
||||||
className="inline mt-1 mr-2"
|
className="inline mt-1 mr-2"
|
||||||
/>
|
/>
|
||||||
|
@ -84,7 +85,7 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<Link href="/settings/teams">
|
<Link href="/settings/teams">
|
||||||
<a className="font-bold">{profile.name}</a>
|
<a className="font-bold">{profile?.name || ""}</a>
|
||||||
</Link>
|
</Link>
|
||||||
{membershipCount && (
|
{membershipCount && (
|
||||||
<span className="relative ml-2 text-xs text-neutral-500 -top-px">
|
<span className="relative ml-2 text-xs text-neutral-500 -top-px">
|
||||||
|
@ -98,9 +99,9 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{typeof window !== "undefined" && (
|
{typeof window !== "undefined" && profile?.slug && (
|
||||||
<Link href={profile.slug!}>
|
<Link href={profile.slug}>
|
||||||
<a className="block text-xs text-neutral-500">{`${window.location.host}/${profile.slug}`}</a>
|
<a className="block text-xs text-neutral-500">{`cal.com/${profile.slug}`}</a>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -114,7 +115,7 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
}: {
|
}: {
|
||||||
profile: PageProps["profiles"][number];
|
profile: PageProps["profiles"][number];
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
types: EventType[];
|
types: EventType["eventTypes"];
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mb-16 -mx-4 overflow-hidden bg-white border border-gray-200 rounded-sm sm:mx-0">
|
<div className="mb-16 -mx-4 overflow-hidden bg-white border border-gray-200 rounded-sm sm:mx-0">
|
||||||
<ul className="divide-y divide-neutral-200" data-testid="event-types">
|
<ul className="divide-y divide-neutral-200" data-testid="event-types">
|
||||||
|
@ -148,19 +149,19 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
|
|
||||||
<div className="flex-shrink-0 hidden mt-4 sm:flex sm:mt-0 sm:ml-5">
|
<div className="flex-shrink-0 hidden mt-4 sm:flex sm:mt-0 sm:ml-5">
|
||||||
<div className="flex items-center space-x-5 overflow-hidden">
|
<div className="flex items-center space-x-5 overflow-hidden">
|
||||||
{type.users.length > 1 && (
|
{type.users?.length > 1 && (
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
size={8}
|
size={8}
|
||||||
truncateAfter={4}
|
truncateAfter={4}
|
||||||
items={type.users.map((organizer) => ({
|
items={type.users.map((organizer) => ({
|
||||||
alt: organizer.name,
|
alt: organizer.name || "",
|
||||||
image: organizer.avatar,
|
image: organizer.avatar || "",
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content="Preview">
|
<Tooltip content="Preview">
|
||||||
<a
|
<a
|
||||||
href={`/${profile.slug}/${type.slug}`}
|
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="p-2 border border-transparent cursor-pointer group text-neutral-400 hover:border-gray-200">
|
className="p-2 border border-transparent cursor-pointer group text-neutral-400 hover:border-gray-200">
|
||||||
|
@ -173,7 +174,7 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showToast("Link copied!", "success");
|
showToast("Link copied!", "success");
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`${window.location.origin}/${profile.slug}/${type.slug}`
|
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="p-2 border border-transparent group text-neutral-400 hover:border-gray-200">
|
className="p-2 border border-transparent group text-neutral-400 hover:border-gray-200">
|
||||||
|
@ -210,7 +211,7 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<a
|
<a
|
||||||
href={`/${profile.slug}/${type.slug}`}
|
href={`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -231,7 +232,7 @@ const EventTypesPage = (props: PageProps) => {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showToast("Link copied!", "success");
|
showToast("Link copied!", "success");
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`${window.location.origin}/${profile.slug}/${type.slug}`
|
`${process.env.NEXT_PUBLIC_APP_URL}/${profile.slug}/${type.slug}`
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -535,6 +536,29 @@ export async function getServerSideProps(context) {
|
||||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This makes the select reusable and type safe.
|
||||||
|
* @url https://www.prisma.io/docs/concepts/components/prisma-client/advanced-type-safety/prisma-validator#using-the-prismavalidator
|
||||||
|
* */
|
||||||
|
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
length: true,
|
||||||
|
schedulingType: true,
|
||||||
|
slug: true,
|
||||||
|
hidden: true,
|
||||||
|
price: true,
|
||||||
|
currency: true,
|
||||||
|
users: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
avatar: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
|
@ -568,22 +592,7 @@ export async function getServerSideProps(context) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
eventTypes: {
|
eventTypes: {
|
||||||
select: {
|
select: eventTypeSelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
description: true,
|
|
||||||
length: true,
|
|
||||||
schedulingType: true,
|
|
||||||
slug: true,
|
|
||||||
hidden: true,
|
|
||||||
users: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
avatar: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -593,22 +602,7 @@ export async function getServerSideProps(context) {
|
||||||
where: {
|
where: {
|
||||||
team: null,
|
team: null,
|
||||||
},
|
},
|
||||||
select: {
|
select: eventTypeSelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
description: true,
|
|
||||||
length: true,
|
|
||||||
schedulingType: true,
|
|
||||||
slug: true,
|
|
||||||
hidden: true,
|
|
||||||
users: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
avatar: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -637,25 +631,10 @@ export async function getServerSideProps(context) {
|
||||||
where: {
|
where: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
},
|
},
|
||||||
select: {
|
select: eventTypeSelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
slug: true,
|
|
||||||
description: true,
|
|
||||||
length: true,
|
|
||||||
schedulingType: true,
|
|
||||||
hidden: true,
|
|
||||||
users: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
avatar: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type EventTypes = (Partial<typeof typesRaw[number]> & {
|
type EventTypeGroup = {
|
||||||
teamId?: number | null;
|
teamId?: number | null;
|
||||||
profile?: {
|
profile?: {
|
||||||
slug: typeof user["username"];
|
slug: typeof user["username"];
|
||||||
|
@ -666,37 +645,36 @@ export async function getServerSideProps(context) {
|
||||||
membershipCount: number;
|
membershipCount: number;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
};
|
};
|
||||||
eventTypes?: (Partial<typeof user["eventTypes"][number]> & { $disabled?: boolean })[];
|
eventTypes: (typeof user.eventTypes[number] & { $disabled?: boolean })[];
|
||||||
})[];
|
};
|
||||||
|
|
||||||
let eventTypes: EventTypes = [];
|
let eventTypeGroups: EventTypeGroup[] = [];
|
||||||
|
const eventTypesHashMap = user.eventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
|
||||||
|
const oldItem = hashMap[newItem.id] || {};
|
||||||
|
hashMap[newItem.id] = { ...oldItem, ...newItem };
|
||||||
|
return hashMap;
|
||||||
|
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
|
||||||
|
const mergedEventTypes = Object.values(eventTypesHashMap).map((et, index) => ({
|
||||||
|
...et,
|
||||||
|
$disabled: user.plan === "FREE" && index > 0,
|
||||||
|
}));
|
||||||
|
|
||||||
eventTypes.push({
|
eventTypeGroups.push({
|
||||||
teamId: null,
|
teamId: null,
|
||||||
profile: {
|
profile: {
|
||||||
slug: user.username,
|
slug: user.username,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
image: user.avatar,
|
image: user.avatar,
|
||||||
},
|
},
|
||||||
eventTypes: user.eventTypes.concat(typesRaw).map((type, index) =>
|
eventTypes: mergedEventTypes,
|
||||||
user.plan === "FREE" && index > 0
|
|
||||||
? {
|
|
||||||
...type,
|
|
||||||
$disabled: true,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...type,
|
|
||||||
$disabled: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
metadata: {
|
metadata: {
|
||||||
membershipCount: 1,
|
membershipCount: 1,
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
eventTypes = ([] as EventTypes).concat(
|
eventTypeGroups = ([] as EventTypeGroup[]).concat(
|
||||||
eventTypes,
|
eventTypeGroups,
|
||||||
user.teams.map((membership) => ({
|
user.teams.map((membership) => ({
|
||||||
teamId: membership.team.id,
|
teamId: membership.team.id,
|
||||||
profile: {
|
profile: {
|
||||||
|
@ -716,16 +694,16 @@ export async function getServerSideProps(context) {
|
||||||
createdDate: user.createdDate.toString(),
|
createdDate: user.createdDate.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const canAddEvents = user.plan !== "FREE" || eventTypes[0].eventTypes.length < 1;
|
const canAddEvents = user.plan !== "FREE" || eventTypeGroups[0].eventTypes.length < 1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
canAddEvents,
|
canAddEvents,
|
||||||
user: userObj,
|
user: userObj,
|
||||||
// don't display event teams without event types,
|
// don't display event teams without event types,
|
||||||
eventTypes: eventTypes.filter((groupBy) => !!groupBy.eventTypes?.length),
|
eventTypes: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
|
||||||
// so we can show a dropdown when the user has teams
|
// so we can show a dropdown when the user has teams
|
||||||
profiles: eventTypes.map((group) => ({
|
profiles: eventTypeGroups.map((group) => ({
|
||||||
teamId: group.teamId,
|
teamId: group.teamId,
|
||||||
...group.profile,
|
...group.profile,
|
||||||
...group.metadata,
|
...group.metadata,
|
||||||
|
|
|
@ -16,21 +16,11 @@ import AddAppleIntegration, {
|
||||||
ADD_APPLE_INTEGRATION_FORM_TITLE,
|
ADD_APPLE_INTEGRATION_FORM_TITLE,
|
||||||
} from "@lib/integrations/Apple/components/AddAppleIntegration";
|
} from "@lib/integrations/Apple/components/AddAppleIntegration";
|
||||||
import Button from "@components/ui/Button";
|
import Button from "@components/ui/Button";
|
||||||
|
import getIntegrations from "@lib/integrations/getIntegrations";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
export type Integration = {
|
export default function Home({ integrations }: inferSSRProps<typeof getServerSideProps>) {
|
||||||
installed: boolean;
|
|
||||||
credential: unknown;
|
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
imageSrc: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
integrations: Integration[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Home({ integrations }: Props) {
|
|
||||||
const [, loading] = useSession();
|
const [, loading] = useSession();
|
||||||
|
|
||||||
const [selectableCalendars, setSelectableCalendars] = useState([]);
|
const [selectableCalendars, setSelectableCalendars] = useState([]);
|
||||||
|
@ -475,21 +465,9 @@ export default function Home({ integrations }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const validJson = (jsonString: string) => {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
try {
|
|
||||||
const o = JSON.parse(jsonString);
|
|
||||||
if (o && typeof o === "object") {
|
|
||||||
return o;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
|
||||||
const session = await getSession(context);
|
const session = await getSession(context);
|
||||||
if (!session) {
|
if (!session?.user?.email) {
|
||||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
|
@ -498,62 +476,21 @@ export async function getServerSideProps(context) {
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
},
|
credentials: {
|
||||||
});
|
|
||||||
|
|
||||||
const credentials = await prisma.credential.findMany({
|
|
||||||
where: {
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
type: true,
|
type: true,
|
||||||
key: true,
|
key: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const integrations = [
|
if (!user) return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
{
|
|
||||||
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
|
const { credentials } = user;
|
||||||
credential: credentials.find((integration) => integration.type === "google_calendar") || null,
|
|
||||||
type: "google_calendar",
|
const integrations = getIntegrations(credentials);
|
||||||
title: "Google Calendar",
|
|
||||||
imageSrc: "integrations/google-calendar.svg",
|
|
||||||
description: "For personal and business calendars",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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/outlook.svg",
|
|
||||||
description: "For personal and business calendars",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: !!(process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET),
|
|
||||||
type: "zoom_video",
|
|
||||||
credential: credentials.find((integration) => integration.type === "zoom_video") || null,
|
|
||||||
title: "Zoom",
|
|
||||||
imageSrc: "integrations/zoom.svg",
|
|
||||||
description: "Video Conferencing",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: true,
|
|
||||||
type: "caldav_calendar",
|
|
||||||
credential: credentials.find((integration) => integration.type === "caldav_calendar") || null,
|
|
||||||
title: "CalDav Server",
|
|
||||||
imageSrc: "integrations/caldav.svg",
|
|
||||||
description: "For personal and business calendars",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
installed: true,
|
|
||||||
type: "apple_calendar",
|
|
||||||
credential: credentials.find((integration) => integration.type === "apple_calendar") || null,
|
|
||||||
title: "Apple Calendar",
|
|
||||||
imageSrc: "integrations/apple-calendar.svg",
|
|
||||||
description: "For personal and business calendars",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: { integrations },
|
props: { integrations },
|
||||||
|
|
9
pages/payment/[uid].tsx
Normal file
9
pages/payment/[uid].tsx
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import PaymentPage from "@ee/components/stripe/PaymentPage";
|
||||||
|
import { getServerSideProps } from "@ee/pages/payment/[uid]";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
|
export default function Payment(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
|
return <PaymentPage {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getServerSideProps };
|
|
@ -1,22 +1,25 @@
|
||||||
import prisma from "@lib/prisma";
|
|
||||||
import { EventType } from "@prisma/client";
|
|
||||||
import "react-phone-number-input/style.css";
|
|
||||||
import BookingPage from "@components/booking/pages/BookingPage";
|
import BookingPage from "@components/booking/pages/BookingPage";
|
||||||
import { InferGetServerSidePropsType } from "next";
|
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
|
import prisma from "@lib/prisma";
|
||||||
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
import { GetServerSidePropsContext } from "next";
|
||||||
|
import "react-phone-number-input/style.css";
|
||||||
|
|
||||||
export default function TeamBookingPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
export type TeamBookingPageProps = inferSSRProps<typeof getServerSideProps>;
|
||||||
|
|
||||||
|
export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||||
return <BookingPage {...props} />;
|
return <BookingPage {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const eventTypeId = parseInt(context.query.type);
|
const eventTypeId = parseInt(asStringOrThrow(context.query.type));
|
||||||
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
if (typeof eventTypeId !== "number" || eventTypeId % 1 !== 0) {
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType: EventType = await prisma.eventType.findUnique({
|
const eventType = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: eventTypeId,
|
id: eventTypeId,
|
||||||
},
|
},
|
||||||
|
@ -50,6 +53,8 @@ export async function getServerSideProps(context) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!eventType) return { notFound: true };
|
||||||
|
|
||||||
const eventTypeObject = [eventType].map((e) => {
|
const eventTypeObject = [eventType].map((e) => {
|
||||||
return {
|
return {
|
||||||
...e,
|
...e,
|
||||||
|
@ -63,7 +68,7 @@ export async function getServerSideProps(context) {
|
||||||
if (context.query.rescheduleUid) {
|
if (context.query.rescheduleUid) {
|
||||||
booking = await prisma.booking.findFirst({
|
booking = await prisma.booking.findFirst({
|
||||||
where: {
|
where: {
|
||||||
uid: context.query.rescheduleUid,
|
uid: asStringOrThrow(context.query.rescheduleUid),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
description: true,
|
description: true,
|
||||||
|
@ -82,7 +87,8 @@ export async function getServerSideProps(context) {
|
||||||
profile: {
|
profile: {
|
||||||
...eventTypeObject.team,
|
...eventTypeObject.team,
|
||||||
slug: "team/" + eventTypeObject.slug,
|
slug: "team/" + eventTypeObject.slug,
|
||||||
image: eventTypeObject.team.logo,
|
image: eventTypeObject.team?.logo || null,
|
||||||
|
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
|
||||||
},
|
},
|
||||||
eventType: eventTypeObject,
|
eventType: eventTypeObject,
|
||||||
booking,
|
booking,
|
||||||
|
|
35
prisma/migrations/20210813142905_event_payment/migration.sql
Normal file
35
prisma/migrations/20210813142905_event_payment/migration.sql
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PaymentType" AS ENUM ('STRIPE');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "paid" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "EventType" ADD COLUMN "currency" TEXT NOT NULL DEFAULT E'usd',
|
||||||
|
ADD COLUMN "price" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Payment" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"uid" TEXT NOT NULL,
|
||||||
|
"type" "PaymentType" NOT NULL,
|
||||||
|
"bookingId" INTEGER NOT NULL,
|
||||||
|
"amount" INTEGER NOT NULL,
|
||||||
|
"fee" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL,
|
||||||
|
"success" BOOLEAN NOT NULL,
|
||||||
|
"refunded" BOOLEAN NOT NULL,
|
||||||
|
"data" JSONB NOT NULL,
|
||||||
|
"externalId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Payment.uid_unique" ON "Payment"("uid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Payment.externalId_unique" ON "Payment"("externalId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Payment" ADD FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[userId,slug]` on the table `EventType` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "EventType.userId_slug_unique" ON "EventType"("userId", "slug");
|
|
@ -8,6 +8,7 @@ datasource db {
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["selectRelationCount"]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SchedulingType {
|
enum SchedulingType {
|
||||||
|
@ -42,6 +43,9 @@ model EventType {
|
||||||
minimumBookingNotice Int @default(120)
|
minimumBookingNotice Int @default(120)
|
||||||
schedulingType SchedulingType?
|
schedulingType SchedulingType?
|
||||||
Schedule Schedule[]
|
Schedule Schedule[]
|
||||||
|
price Int @default(0)
|
||||||
|
currency String @default("usd")
|
||||||
|
@@unique([userId, slug])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
|
@ -176,6 +180,8 @@ model Booking {
|
||||||
confirmed Boolean @default(true)
|
confirmed Boolean @default(true)
|
||||||
rejected Boolean @default(false)
|
rejected Boolean @default(false)
|
||||||
status BookingStatus @default(ACCEPTED)
|
status BookingStatus @default(ACCEPTED)
|
||||||
|
paid Boolean @default(false)
|
||||||
|
payment Payment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Schedule {
|
model Schedule {
|
||||||
|
@ -246,3 +252,22 @@ model ReminderMail {
|
||||||
elapsedMinutes Int
|
elapsedMinutes Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PaymentType {
|
||||||
|
STRIPE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
uid String @unique
|
||||||
|
type PaymentType
|
||||||
|
bookingId Int
|
||||||
|
booking Booking? @relation(fields: [bookingId], references: [id])
|
||||||
|
amount Int
|
||||||
|
fee Int
|
||||||
|
currency String
|
||||||
|
success Boolean
|
||||||
|
refunded Boolean
|
||||||
|
data Json
|
||||||
|
externalId String @unique
|
||||||
|
}
|
||||||
|
|
16
public/integrations/stripe.svg
Normal file
16
public/integrations/stripe.svg
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<svg width="183" height="76" viewBox="0 0 183 76" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M182.4 39.216C182.4 26.2453 176.117 16.0107 164.109 16.0107C152.051 16.0107 144.755 26.2453 144.755 39.1147C144.755 54.3653 153.368 62.0667 165.731 62.0667C171.76 62.0667 176.32 60.6987 179.765 58.7733V48.64C176.32 50.3627 172.368 51.4267 167.352 51.4267C162.437 51.4267 158.08 49.704 157.523 43.7253H182.299C182.299 43.0667 182.4 40.432 182.4 39.216ZM157.371 34.4027C157.371 28.6773 160.867 26.296 164.059 26.296C167.149 26.296 170.443 28.6773 170.443 34.4027H157.371Z" fill="#635BFF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M125.197 16.0107C120.232 16.0107 117.04 18.3413 115.267 19.9627L114.608 16.8213H103.461V75.8987L116.128 73.2134L116.179 58.8747C118.003 60.192 120.688 62.0667 125.147 62.0667C134.216 62.0667 142.475 54.7707 142.475 38.7094C142.424 24.016 134.064 16.0107 125.197 16.0107ZM122.157 50.92C119.168 50.92 117.395 49.856 116.179 48.5387L116.128 29.7413C117.445 28.272 119.269 27.2587 122.157 27.2587C126.768 27.2587 129.96 32.4267 129.96 39.064C129.96 45.8534 126.819 50.92 122.157 50.92Z" fill="#635BFF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M86.032 13.0213L98.7493 10.2853V0L86.032 2.68533V13.0213Z" fill="#635BFF"/>
|
||||||
|
<path d="M98.7493 16.872H86.032V61.2053H98.7493V16.872Z" fill="#635BFF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.4026 20.6213L71.592 16.872H60.6479V61.2053H73.3146V31.16C76.304 27.2587 81.3706 27.968 82.9413 28.5253V16.872C81.32 16.264 75.3919 15.1493 72.4026 20.6213Z" fill="#635BFF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M47.0693 5.87732L34.7067 8.51199L34.656 49.096C34.656 56.5946 40.28 62.1173 47.7787 62.1173C51.9333 62.1173 54.9733 61.3573 56.6453 60.4453V50.16C55.024 50.8187 47.0187 53.1493 47.0187 45.6507V27.664H56.6453V16.872H47.0187L47.0693 5.87732Z" fill="#635BFF"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.8187 29.7413C12.8187 27.7653 14.44 27.0054 17.1253 27.0054C20.976 27.0054 25.84 28.1707 29.6907 30.248V18.3413C25.4853 16.6693 21.3307 16.0107 17.1253 16.0107C6.84 16.0107 0 21.3813 0 30.3493C0 44.3333 19.2533 42.104 19.2533 48.1333C19.2533 50.464 17.2267 51.224 14.3893 51.224C10.184 51.224 4.81333 49.5014 0.557333 47.1707V59.2293C5.26933 61.256 10.032 62.1174 14.3893 62.1174C24.928 62.1174 32.1733 56.8987 32.1733 47.8293C32.1227 32.7307 12.8187 35.416 12.8187 29.7413Z" fill="#635BFF"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0">
|
||||||
|
<rect width="182.4" height="76" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
|
@ -133,67 +133,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* hide intercom chat bubble on mobile */
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
.intercom-launcher-frame{
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* add padding bottom to bottom nav on standalone mode */
|
||||||
|
@media all and (display-mode: standalone) {
|
||||||
|
.bottom-nav {
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hide intercom chat bubble on mobile */
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
.intercom-launcher-frame{
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.react-multi-email > [type='text'] {
|
.react-multi-email > [type='text'] {
|
||||||
@apply shadow-sm dark:bg-black dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md;
|
@apply shadow-sm dark:bg-gray-700 dark:text-white dark:border-gray-900 focus:ring-black focus:border-black block w-full sm:text-sm border-gray-300 rounded-md;
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
-webkit-box-flex: 1;
|
|
||||||
-ms-flex: 1 0 auto;
|
|
||||||
flex: 1 0 auto;
|
|
||||||
text-align: left;
|
|
||||||
line-height: 1.25rem;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
align-content: flex-start;
|
|
||||||
padding-top: 0.1rem !important;
|
|
||||||
padding-bottom: 0.1rem !important;
|
|
||||||
/* padding-left: 0.75rem !important; */
|
|
||||||
@apply dark:border-black border-white dark:bg-black bg-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email > [type='text']{
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email.focused{
|
|
||||||
@apply dark:bg-black
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email.focused > [type='text']{
|
|
||||||
border: 2px solid #000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email > [type='text']:focus{
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email > span[data-placeholder] {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
left: 0.8rem;
|
|
||||||
top: 0.75rem;
|
|
||||||
line-height: 1.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email.empty > span[data-placeholder] {
|
|
||||||
display: inline;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email.focused > span[data-placeholder] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-multi-email > input {
|
|
||||||
width: 100% !important;
|
|
||||||
display: inline-block !important;
|
|
||||||
@apply mt-1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-multi-email [data-tag] {
|
.react-multi-email [data-tag] {
|
||||||
|
@ -201,17 +164,43 @@
|
||||||
@apply inline-flex items-center px-2 py-1 my-1 mr-2 border border-transparent text-sm font-medium rounded-md text-gray-900 dark:text-white bg-neutral-200 hover:bg-neutral-100 dark:bg-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;
|
@apply inline-flex items-center px-2 py-1 my-1 mr-2 border border-transparent text-sm font-medium rounded-md text-gray-900 dark:text-white bg-neutral-200 hover:bg-neutral-100 dark:bg-black focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-multi-email [data-tag] [data-tag-item] {
|
.react-multi-email > span[data-placeholder] {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0.8rem;
|
||||||
|
top: 0.75rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-multi-email.empty > span[data-placeholder] {
|
||||||
|
display: inline;
|
||||||
|
color: #646b7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-multi-email.focused > span[data-placeholder] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-multi-email > input {
|
||||||
|
width: 100% !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
@apply mt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-multi-email [data-tag] {
|
||||||
|
@apply inline-flex items-center px-2 py-1 my-1 mr-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-gray-900 dark:text-white bg-neutral-200 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-multi-email [data-tag] [data-tag-item] {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-multi-email [data-tag] [data-tag-handle] {
|
.react-multi-email [data-tag] [data-tag-handle] {
|
||||||
margin-left: 0.833em;
|
margin-left: 0.833em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* !important to override react-select */
|
/* !important to override react-select */
|
||||||
.react-select__value-container{
|
.react-select__value-container{
|
||||||
|
|
|
@ -13,6 +13,9 @@
|
||||||
],
|
],
|
||||||
"@lib/*": [
|
"@lib/*": [
|
||||||
"lib/*"
|
"lib/*"
|
||||||
|
],
|
||||||
|
"@ee/*": [
|
||||||
|
"ee/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
213
yarn.lock
213
yarn.lock
|
@ -463,6 +463,76 @@
|
||||||
minimatch "^3.0.4"
|
minimatch "^3.0.4"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
|
"@formatjs/ecma402-abstract@1.9.8":
|
||||||
|
version "1.9.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.9.8.tgz#f3dad447fbc7f063f88e2a148b7a353161740e74"
|
||||||
|
integrity sha512-2U4n11bLmTij/k4ePCEFKJILPYwdMcJTdnKVBi+JMWBgu5O1N+XhCazlE6QXqVO1Agh2Doh0b/9Jf1mSmSVfhA==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/intl-localematcher" "0.2.20"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@formatjs/fast-memoize@1.2.0":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.0.tgz#1123bfcc5d21d761f15d8b1c32d10e1b6530355d"
|
||||||
|
integrity sha512-fObitP9Tlc31SKrPHgkPgQpGo4+4yXfQQITTCNH8AZdEqB7Mq4nPrjpUL/tNGN3lEeJcFxDbi0haX8HM7QvQ8w==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@formatjs/icu-messageformat-parser@2.0.11":
|
||||||
|
version "2.0.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.0.11.tgz#e4ba40b9a8aefc8bccfc96be5906d3bca305b4b3"
|
||||||
|
integrity sha512-5mWb8U8aulYGwnDZWrr+vdgn5PilvtrqQYQ1pvpgzQes/osi85TwmL2GqTGLlKIvBKD2XNA61kAqXYY95w4LWg==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/ecma402-abstract" "1.9.8"
|
||||||
|
"@formatjs/icu-skeleton-parser" "1.2.12"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@formatjs/icu-skeleton-parser@1.2.12":
|
||||||
|
version "1.2.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.2.12.tgz#45426eb1448c0c08c931eb9f0672283c0e4d0062"
|
||||||
|
integrity sha512-DTFxWmEA02ZNW6fsYjGYSADvtrqqjCYF7DSgCmMfaaE0gLP4pCdAgOPE+lkXXU+jP8iCw/YhMT2Seyk/C5lBWg==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/ecma402-abstract" "1.9.8"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@formatjs/intl-displaynames@5.2.3":
|
||||||
|
version "5.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-5.2.3.tgz#a0cebc81e89c5414177ade71a2f2388d799ee6e8"
|
||||||
|
integrity sha512-5BmhSurLbfgdeo0OBcNPPkIS8ikMMYaHe2NclxEQZqcMvrnQzNMNnUE2dDF5vZx+mkvKq77aQYzpc8RfqVsRCQ==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/ecma402-abstract" "1.9.8"
|
||||||
|
"@formatjs/intl-localematcher" "0.2.20"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@formatjs/intl-listformat@6.3.3":
|
||||||
|
version "6.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-6.3.3.tgz#0cb83a012c0ae46876e30589a086695298e0fb5c"
|
||||||
|
integrity sha512-3nzAKgVS5rePDa5HiH0OwZgAhqxLtzlMc9Pg4QgajRHSP1TqFiMmQnnn52wd3+xVTb7cjZVm3JBnTv51/MhTOg==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/ecma402-abstract" "1.9.8"
|
||||||
|
"@formatjs/intl-localematcher" "0.2.20"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@formatjs/intl-localematcher@0.2.20":
|
||||||
|
version "0.2.20"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.20.tgz#782aef53d1c1b6112ee67468dc59f9b8d1ba7b17"
|
||||||
|
integrity sha512-/Ro85goRZnCojzxOegANFYL0LaDIpdPjAukR7xMTjOtRx+3yyjR0ifGTOW3/Kjhmab3t6GnyHBYWZSudxEOxPA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
"@formatjs/intl@1.14.1":
|
||||||
|
version "1.14.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.14.1.tgz#03e12f7e2cf557defdd1a5aeb1c143efb8cfbc7b"
|
||||||
|
integrity sha512-mtL8oBgFwTu0GHFnxaF93fk/zNzNkPzl+27Fwg5AZ88pWHWb7037dpODzoCBnaIVk4FBO5emUn/6jI9Byj8hOw==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/ecma402-abstract" "1.9.8"
|
||||||
|
"@formatjs/fast-memoize" "1.2.0"
|
||||||
|
"@formatjs/icu-messageformat-parser" "2.0.11"
|
||||||
|
"@formatjs/intl-displaynames" "5.2.3"
|
||||||
|
"@formatjs/intl-listformat" "6.3.3"
|
||||||
|
intl-messageformat "9.9.1"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
"@hapi/accept@5.0.2":
|
"@hapi/accept@5.0.2":
|
||||||
version "5.0.2"
|
version "5.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-5.0.2.tgz#ab7043b037e68b722f93f376afb05e85c0699523"
|
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-5.0.2.tgz#ab7043b037e68b722f93f376afb05e85c0699523"
|
||||||
|
@ -1262,6 +1332,18 @@
|
||||||
resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20"
|
resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.3.tgz#1185726610acc37317ddab11c3c7f9066966bd20"
|
||||||
integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg==
|
integrity sha512-O3uyB/JbkAEMZaP3YqyHH7TMnex7tWyCbCI4EfJdOCoN6HIhqdJBWTM6aCCiWQ/5f5wxjgU735QAIpJbjDvmzg==
|
||||||
|
|
||||||
|
"@stripe/react-stripe-js@^1.4.1":
|
||||||
|
version "1.4.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.4.1.tgz#884d59286fff00ba77389b32c045516f65d7a340"
|
||||||
|
integrity sha512-FjcVrhf72+9fUL3Lz3xi02ni9tzH1A1x6elXlr6tvBDgSD55oPJuodoP8eC7xTnBIKq0olF5uJvgtkJyDCdzjA==
|
||||||
|
dependencies:
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
|
||||||
|
"@stripe/stripe-js@^1.16.0":
|
||||||
|
version "1.17.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.17.1.tgz#afcb7e86d0b05d1a7af53af89111abd2e8d437ae"
|
||||||
|
integrity sha512-c9MyDvdi5Xou0j0JPNy86NebtTDfh9o62Ifuzx6GSm2YO0oedBpy51WSyOue2L8Fb+mqESS5gd6mGVEIPUnXsA==
|
||||||
|
|
||||||
"@tailwindcss/forms@^0.3.3":
|
"@tailwindcss/forms@^0.3.3":
|
||||||
version "0.3.3"
|
version "0.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.3.3.tgz#a29d22668804f3dae293dcadbef1aa6315c45b64"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.3.3.tgz#a29d22668804f3dae293dcadbef1aa6315c45b64"
|
||||||
|
@ -1339,6 +1421,14 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/hoist-non-react-statics@^3.3.1":
|
||||||
|
version "3.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
|
||||||
|
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
hoist-non-react-statics "^3.3.0"
|
||||||
|
|
||||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
|
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
|
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762"
|
||||||
|
@ -1393,6 +1483,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.1.tgz#0611b37db4246c937feef529ddcc018cf8e35708"
|
||||||
integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
|
integrity sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==
|
||||||
|
|
||||||
|
"@types/node@>=8.1.0":
|
||||||
|
version "16.7.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.7.10.tgz#7aa732cc47341c12a16b7d562f519c2383b6d4fc"
|
||||||
|
integrity sha512-S63Dlv4zIPb8x6MMTgDq5WWRJQe56iBEY0O3SOFA9JrRienkOVDXSXBjjJw6HTNQYSE2JI6GMCR6LVbIMHJVvA==
|
||||||
|
|
||||||
"@types/node@^14.14.31":
|
"@types/node@^14.14.31":
|
||||||
version "14.17.15"
|
version "14.17.15"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.15.tgz#d5ebfb62a69074ebb85cbe0529ad917bb8f2bae8"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.15.tgz#d5ebfb62a69074ebb85cbe0529ad917bb8f2bae8"
|
||||||
|
@ -1467,7 +1562,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@^17.0.18":
|
"@types/react@*", "@types/react@17", "@types/react@^17.0.18":
|
||||||
version "17.0.20"
|
version "17.0.20"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.20.tgz#a4284b184d47975c71658cd69e759b6bd37c3b8c"
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.20.tgz#a4284b184d47975c71658cd69e759b6bd37c3b8c"
|
||||||
integrity sha512-wWZrPlihslrPpcKyCSlmIlruakxr57/buQN1RjlIeaaTWDLtJkTtRW429MoQJergvVKc4IWBpRhWw7YNh/7GVA==
|
integrity sha512-wWZrPlihslrPpcKyCSlmIlruakxr57/buQN1RjlIeaaTWDLtJkTtRW429MoQJergvVKc4IWBpRhWw7YNh/7GVA==
|
||||||
|
@ -1496,6 +1591,18 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
|
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
|
||||||
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
|
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
|
||||||
|
|
||||||
|
"@types/stripe@^8.0.417":
|
||||||
|
version "8.0.417"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/stripe/-/stripe-8.0.417.tgz#b651677a9fc33be8ce8fd5bceadd7ca077214244"
|
||||||
|
integrity sha512-PTuqskh9YKNENnOHGVJBm4sM0zE8B1jZw1JIskuGAPkMB+OH236QeN8scclhYGPA4nG6zTtPXgwpXdp+HPDTVw==
|
||||||
|
dependencies:
|
||||||
|
stripe "*"
|
||||||
|
|
||||||
|
"@types/uuid@8.3.1":
|
||||||
|
version "8.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.1.tgz#1a32969cf8f0364b3d8c8af9cc3555b7805df14f"
|
||||||
|
integrity sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==
|
||||||
|
|
||||||
"@types/yargs-parser@*":
|
"@types/yargs-parser@*":
|
||||||
version "20.2.1"
|
version "20.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129"
|
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129"
|
||||||
|
@ -1777,6 +1884,11 @@ arch@^2.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
|
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
|
||||||
integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==
|
integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==
|
||||||
|
|
||||||
|
arg@4.1.0:
|
||||||
|
version "4.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0"
|
||||||
|
integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==
|
||||||
|
|
||||||
arg@^4.1.0:
|
arg@^4.1.0:
|
||||||
version "4.1.3"
|
version "4.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
|
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
|
||||||
|
@ -2284,6 +2396,11 @@ builtin-status-codes@^3.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
|
resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
|
||||||
integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=
|
integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=
|
||||||
|
|
||||||
|
bytes@3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
|
||||||
|
integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
|
||||||
|
|
||||||
bytes@3.1.0, bytes@^3.0.0:
|
bytes@3.1.0, bytes@^3.0.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
|
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
|
||||||
|
@ -2608,6 +2725,11 @@ constants-browserify@1.0.0, constants-browserify@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
|
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
|
||||||
integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=
|
integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=
|
||||||
|
|
||||||
|
content-type@1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
|
||||||
|
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
|
||||||
|
|
||||||
convert-source-map@1.7.0:
|
convert-source-map@1.7.0:
|
||||||
version "1.7.0"
|
version "1.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
|
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
|
||||||
|
@ -2930,6 +3052,11 @@ delayed-stream@~1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
|
||||||
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
|
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
|
||||||
|
|
||||||
|
depd@1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"
|
||||||
|
integrity sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=
|
||||||
|
|
||||||
depd@~1.1.2:
|
depd@~1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
|
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
|
||||||
|
@ -4045,7 +4172,7 @@ hmac-drbg@^1.0.1:
|
||||||
minimalistic-assert "^1.0.0"
|
minimalistic-assert "^1.0.0"
|
||||||
minimalistic-crypto-utils "^1.0.1"
|
minimalistic-crypto-utils "^1.0.1"
|
||||||
|
|
||||||
hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1:
|
hoist-non-react-statics@^3.2.1, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
|
||||||
version "3.3.2"
|
version "3.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||||
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||||
|
@ -4084,6 +4211,16 @@ html-tags@^3.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140"
|
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140"
|
||||||
integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==
|
integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==
|
||||||
|
|
||||||
|
http-errors@1.6.2:
|
||||||
|
version "1.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
|
||||||
|
integrity sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=
|
||||||
|
dependencies:
|
||||||
|
depd "1.1.1"
|
||||||
|
inherits "2.0.3"
|
||||||
|
setprototypeof "1.0.3"
|
||||||
|
statuses ">= 1.3.1 < 2"
|
||||||
|
|
||||||
http-errors@1.7.3:
|
http-errors@1.7.3:
|
||||||
version "1.7.3"
|
version "1.7.3"
|
||||||
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
|
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
|
||||||
|
@ -4146,6 +4283,11 @@ ical.js@^1.4.0:
|
||||||
resolved "https://registry.yarnpkg.com/ical.js/-/ical.js-1.4.0.tgz#fc5619dc55fe03d909bf04362aa0677f4541b9d7"
|
resolved "https://registry.yarnpkg.com/ical.js/-/ical.js-1.4.0.tgz#fc5619dc55fe03d909bf04362aa0677f4541b9d7"
|
||||||
integrity sha512-ltHZuOFNNjcyEYbzDgjemS7LWIFh2vydJeznxQHUh3dnarbxqOYsWONYteBVAq1MEOHnwXFGN2eskZReHclnrA==
|
integrity sha512-ltHZuOFNNjcyEYbzDgjemS7LWIFh2vydJeznxQHUh3dnarbxqOYsWONYteBVAq1MEOHnwXFGN2eskZReHclnrA==
|
||||||
|
|
||||||
|
iconv-lite@0.4.19:
|
||||||
|
version "0.4.19"
|
||||||
|
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
|
||||||
|
integrity sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
version "0.4.24"
|
version "0.4.24"
|
||||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
|
||||||
|
@ -4274,6 +4416,15 @@ internal-slot@^1.0.3:
|
||||||
has "^1.0.3"
|
has "^1.0.3"
|
||||||
side-channel "^1.0.4"
|
side-channel "^1.0.4"
|
||||||
|
|
||||||
|
intl-messageformat@9.9.1:
|
||||||
|
version "9.9.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.9.1.tgz#255d453b0656b4f7e741f31d2b4a95bf2adfe064"
|
||||||
|
integrity sha512-cuzS/XKHn//hvKka77JKU2dseiVY2dofQjIOZv6ZFxFt4Z9sPXnZ7KQ9Ak2r+4XBCjI04MqJ1PhKs/3X22AkfA==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/fast-memoize" "1.2.0"
|
||||||
|
"@formatjs/icu-messageformat-parser" "2.0.11"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
invariant@^2.2.4:
|
invariant@^2.2.4:
|
||||||
version "2.2.4"
|
version "2.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||||
|
@ -4454,6 +4605,11 @@ is-regexp@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
|
resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
|
||||||
integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk=
|
integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk=
|
||||||
|
|
||||||
|
is-stream@1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
||||||
|
integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
|
||||||
|
|
||||||
is-stream@^2.0.0:
|
is-stream@^2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
||||||
|
@ -5469,6 +5625,16 @@ merge2@^1.3.0:
|
||||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||||
|
|
||||||
|
micro@^9.3.4:
|
||||||
|
version "9.3.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/micro/-/micro-9.3.4.tgz#745a494e53c8916f64fb6a729f8cbf2a506b35ad"
|
||||||
|
integrity sha512-smz9naZwTG7qaFnEZ2vn248YZq9XR+XoOH3auieZbkhDL4xLOxiE+KqG8qqnBeKfXA9c1uEFGCxPN1D+nT6N7w==
|
||||||
|
dependencies:
|
||||||
|
arg "4.1.0"
|
||||||
|
content-type "1.0.4"
|
||||||
|
is-stream "1.1.0"
|
||||||
|
raw-body "2.3.2"
|
||||||
|
|
||||||
micromatch@^4.0.4:
|
micromatch@^4.0.4:
|
||||||
version "4.0.4"
|
version "4.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
|
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
|
||||||
|
@ -6451,7 +6617,7 @@ qrcode@^1.4.4:
|
||||||
pngjs "^3.3.0"
|
pngjs "^3.3.0"
|
||||||
yargs "^13.2.4"
|
yargs "^13.2.4"
|
||||||
|
|
||||||
qs@^6.7.0:
|
qs@^6.6.0, qs@^6.7.0:
|
||||||
version "6.10.1"
|
version "6.10.1"
|
||||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a"
|
||||||
integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==
|
integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==
|
||||||
|
@ -6522,6 +6688,16 @@ randomfill@^1.0.3:
|
||||||
randombytes "^2.0.5"
|
randombytes "^2.0.5"
|
||||||
safe-buffer "^5.1.0"
|
safe-buffer "^5.1.0"
|
||||||
|
|
||||||
|
raw-body@2.3.2:
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89"
|
||||||
|
integrity sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=
|
||||||
|
dependencies:
|
||||||
|
bytes "3.0.0"
|
||||||
|
http-errors "1.6.2"
|
||||||
|
iconv-lite "0.4.19"
|
||||||
|
unpipe "1.0.0"
|
||||||
|
|
||||||
raw-body@2.4.1:
|
raw-body@2.4.1:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
|
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
|
||||||
|
@ -6584,6 +6760,22 @@ react-input-autosize@^3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prop-types "^15.5.8"
|
prop-types "^15.5.8"
|
||||||
|
|
||||||
|
react-intl@^5.20.7:
|
||||||
|
version "5.20.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.20.10.tgz#8b0f18a5b76e9f8f5a3ccc993deb0c4cef9dcde0"
|
||||||
|
integrity sha512-zy0ZQhpjkGsKcK1BFo2HbGM/q8GBVovzoXZGQ76DowR0yr6UzQuPLkrlIrObL2zxIYiDaxaz+hUJaoa2a1xqOQ==
|
||||||
|
dependencies:
|
||||||
|
"@formatjs/ecma402-abstract" "1.9.8"
|
||||||
|
"@formatjs/icu-messageformat-parser" "2.0.11"
|
||||||
|
"@formatjs/intl" "1.14.1"
|
||||||
|
"@formatjs/intl-displaynames" "5.2.3"
|
||||||
|
"@formatjs/intl-listformat" "6.3.3"
|
||||||
|
"@types/hoist-non-react-statics" "^3.3.1"
|
||||||
|
"@types/react" "17"
|
||||||
|
hoist-non-react-statics "^3.3.2"
|
||||||
|
intl-messageformat "9.9.1"
|
||||||
|
tslib "^2.1.0"
|
||||||
|
|
||||||
react-is@17.0.2, react-is@^17.0.1:
|
react-is@17.0.2, react-is@^17.0.1:
|
||||||
version "17.0.2"
|
version "17.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||||
|
@ -7023,6 +7215,11 @@ setimmediate@^1.0.4:
|
||||||
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
|
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
|
||||||
integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
|
integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
|
||||||
|
|
||||||
|
setprototypeof@1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04"
|
||||||
|
integrity sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=
|
||||||
|
|
||||||
setprototypeof@1.1.1:
|
setprototypeof@1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
|
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
|
||||||
|
@ -7229,7 +7426,7 @@ stacktrace-parser@0.1.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest "^0.7.1"
|
type-fest "^0.7.1"
|
||||||
|
|
||||||
"statuses@>= 1.5.0 < 2":
|
"statuses@>= 1.3.1 < 2", "statuses@>= 1.5.0 < 2":
|
||||||
version "1.5.0"
|
version "1.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
|
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
|
||||||
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
|
integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
|
||||||
|
@ -7417,6 +7614,14 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
|
||||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||||
|
|
||||||
|
stripe@*, stripe@^8.168.0:
|
||||||
|
version "8.174.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.174.0.tgz#91d2e61b0217b1ee9fde2842582e0f1cf1dddc94"
|
||||||
|
integrity sha512-UFU5TuYH7XwUmSllUIcIKhhsvvhhjw9D6ZwVdfB74wU4VOOaWBiQqszkw6chaEFpdulUmbcAH5eZltV3HwOi7g==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" ">=8.1.0"
|
||||||
|
qs "^6.6.0"
|
||||||
|
|
||||||
styled-jsx@4.0.1:
|
styled-jsx@4.0.1:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-4.0.1.tgz#ae3f716eacc0792f7050389de88add6d5245b9e9"
|
resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-4.0.1.tgz#ae3f716eacc0792f7050389de88add6d5245b9e9"
|
||||||
|
|
Loading…
Reference in a new issue