Merge branch 'main' into teams-stripe-checkout-form

This commit is contained in:
Peer Richelsen 2022-11-06 15:47:10 +00:00 committed by GitHub
commit e59dbb9ed0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 234 additions and 79 deletions

View File

@ -24,14 +24,7 @@ export function AvailableEventLocations({ locations }: { locations: Props["event
alt={`${eventLocationType.label} icon`}
/>
<Tooltip content={locationKeyToString(location)}>
<a
target="_blank"
href={locationKeyToString(location) ?? "/"}
className="truncate"
key={location.type}
rel="noreferrer">
{locationKeyToString(location)}
</a>
<p className="truncate">{locationKeyToString(location)}</p>
</Tooltip>
</div>
);

View File

@ -40,6 +40,7 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import { Icon } from "@calcom/ui/Icon";
import { Tooltip } from "@calcom/ui/Tooltip";
import AddressInput from "@calcom/ui/form/AddressInputLazy";
import { Button } from "@calcom/ui/components";
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
import { EmailInput, Form } from "@calcom/ui/form/fields";
@ -68,6 +69,8 @@ type BookingFormValues = {
notes?: string;
locationType?: EventLocationType["type"];
guests?: string[];
address?: string;
attendeeAddress?: string;
phone?: string;
hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers
customInputs?: {
@ -269,6 +272,7 @@ const BookingPage = ({
.refine((val) => isValidPhoneNumber(val))
.optional()
.nullable(),
attendeeAddress: z.string().optional().nullable(),
smsReminderNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
@ -297,10 +301,10 @@ const BookingPage = ({
const selectedLocation = getEventLocationType(selectedLocationType);
const AttendeeInput =
selectedLocation?.attendeeInputType === "text"
? "input"
: selectedLocation?.attendeeInputType === "phone"
selectedLocation?.attendeeInputType === "phone"
? PhoneInput
: selectedLocation?.attendeeInputType === "attendeeAddress"
? AddressInput
: null;
// Calculate the booking date(s)
@ -356,6 +360,7 @@ const BookingPage = ({
location: getEventLocationValue(locations, {
type: booking.locationType ? booking.locationType : selectedLocationType || "",
phone: booking.phone,
attendeeAddress: booking.attendeeAddress,
}),
metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
@ -386,6 +391,7 @@ const BookingPage = ({
location: getEventLocationValue(locations, {
type: (booking.locationType ? booking.locationType : selectedLocationType) || "",
phone: booking.phone,
attendeeAddress: booking.attendeeAddress,
}),
metadata,
customInputs: Object.keys(booking.customInputs || {}).map((inputId) => ({
@ -648,16 +654,39 @@ const BookingPage = ({
{AttendeeInput && (
<div className="mb-4">
<label
htmlFor="phone"
htmlFor={
selectedLocationType === LocationType.Phone
? "phone"
: selectedLocationType === LocationType.AttendeeInPerson
? "attendeeAddress"
: ""
}
className="block text-sm font-medium text-gray-700 dark:text-white">
{t("phone_number")}
{selectedLocationType === LocationType.Phone
? t("phone_number")
: selectedLocationType === LocationType.AttendeeInPerson
? t("Address")
: ""}
</label>
<div className="mt-1">
<AttendeeInput<BookingFormValues>
control={bookingForm.control}
name="phone"
bookingForm={bookingForm}
name={
selectedLocationType === LocationType.Phone
? "phone"
: selectedLocationType === LocationType.AttendeeInPerson
? "attendeeAddress"
: ""
}
placeholder={t(selectedLocation?.attendeeInputPlaceholder || "")}
id="phone"
id={
selectedLocationType === LocationType.Phone
? "phone"
: selectedLocationType === LocationType.AttendeeInPerson
? "attendeeAddress"
: ""
}
required
disabled={disableInput}
/>

View File

@ -293,7 +293,10 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
defaultValue={selection}
options={
booking
? locationOptions.filter((location) => location.value !== "phone")
? locationOptions.filter(
(location) =>
location.value !== "phone" && location.value !== "attendeeInPerson"
)
: locationOptions
}
isSearchable

View File

@ -232,6 +232,12 @@ export const EditLocationDialog = (props: ISetLocationDialog) => {
const { locationType: newLocation, displayLocationPublicly } = values;
let details = {};
if (newLocation === LocationType.AttendeeInPerson) {
details = {
address: values.locationAddress,
};
}
if (newLocation === LocationType.InPerson) {
details = {
address: values.locationAddress,

View File

@ -54,7 +54,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
<div className="flex flex-col space-y-8">
{/**
* Only display calendar selector if user has connected calendars AND if it's not
* a team event. Since we don't have logic to handle each attende calendar (for now).
* a team event. Since we don't have logic to handle each attendee calendar (for now).
* This will fallback to each user selected destination calendar.
*/}
{!!connectedCalendarsQuery.data?.connectedCalendars.length && !team && (
@ -292,7 +292,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
description={t("offer_seats_description")}
checked={value}
onCheckedChange={(e) => {
// Enabling seats will disable guests and requiring confimation until fully supported
// Enabling seats will disable guests and requiring confirmation until fully supported
if (e) {
formMethods.setValue("disableGuests", true);
formMethods.setValue("requiresConfirmation", false);
@ -316,9 +316,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
label={t("number_of_seats")}
type="number"
defaultValue={value || 2}
min={1}
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Number(e.target.value));
onChange(Math.abs(Number(e.target.value)));
}}
/>
<div className="mt-2">

View File

@ -1,7 +1,7 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { SessionProvider } from "next-auth/react";
import { EventCollectionProvider } from "next-collect/client";
import { appWithTranslation } from "next-i18next";
import { appWithTranslation, SSRConfig } from "next-i18next";
import { ThemeProvider } from "next-themes";
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
import { NextRouter } from "next/router";
@ -14,9 +14,9 @@ import { MetaProvider } from "@calcom/ui/v2/core/Meta";
import usePublicPage from "@lib/hooks/usePublicPage";
const I18nextAdapter = appWithTranslation<NextJsAppProps & { children: React.ReactNode }>(({ children }) => (
<>{children}</>
));
const I18nextAdapter = appWithTranslation<NextJsAppProps<SSRConfig> & { children: React.ReactNode }>(
({ children }) => <>{children}</>
);
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<NextAppProps, "Component"> & {

View File

@ -56,6 +56,7 @@ export type FormValues = {
locations: {
type: EventLocationType["type"];
address?: string;
attendeeAddress?: string;
link?: string;
hostPhoneNumber?: string;
displayLocationPublicly?: boolean;

View File

@ -369,23 +369,12 @@ export default function Success(props: SuccessProps) {
<p className="text-bookinglight">{bookingInfo.user.email}</p>
</div>
)}
{!eventType.seatsShowAttendees
? bookingInfo?.attendees
.filter((attendee) => attendee.email === email)
.map((attendee) => (
<div key={attendee.name} className="mb-3">
<p>{attendee.name}</p>
<p className="text-bookinglight">{attendee.email}</p>
</div>
))
: bookingInfo?.attendees.map((attendee, index) => (
<div
key={attendee.name}
className={index === bookingInfo.attendees.length - 1 ? "" : "mb-3"}>
<p>{attendee.name}</p>
<p className="text-bookinglight">{attendee.email}</p>
</div>
))}
{bookingInfo?.attendees.map((attendee, index) => (
<div key={attendee.name} className="mb-3 last:mb-0">
<p>{attendee.name}</p>
<p className="text-bookinglight">{attendee.email}</p>
</div>
))}
</>
</div>
</>
@ -786,6 +775,28 @@ const schema = z.object({
bookingId: strToNumber,
});
const handleSeatsEventTypeOnBooking = (
eventType: {
seatsPerTimeSlot?: boolean | null;
seatsShowAttendees: boolean | null;
[x: string | number | symbol]: unknown;
},
booking: Partial<
Prisma.BookingGetPayload<{ include: { attendees: { select: { name: true; email: true } } } }>
>,
email: string
) => {
if (eventType?.seatsPerTimeSlot !== null) {
// @TODO: right now bookings with seats doesn't save every description that its entered by every user
delete booking.description;
}
if (!eventType.seatsShowAttendees) {
const attendee = booking?.attendees?.find((a) => a.email === email);
booking["attendees"] = attendee ? [attendee] : [];
}
return;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const parsedQuery = schema.safeParse(context.query);
@ -884,6 +895,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
},
});
if (bookingInfo !== null && email) {
handleSeatsEventTypeOnBooking(eventType, bookingInfo, email);
}
let recurringBookings = null;
if (recurringEventIdQuery) {
// We need to get the dates for the bookings to be able to show them in the UI

View File

@ -123,6 +123,13 @@ operators.between.label = "Between";
delete operators.proximity;
delete operators.is_null;
delete operators.is_not_null;
/**
* Not supported with JSONLogic. Implement them and add these back -> https://github.com/jwadhams/json-logic-js/issues/81
*/
delete operators.starts_with;
delete operators.ends_with;
const config = {
conjunctions: BasicConfig.conjunctions,
operators,

View File

@ -160,6 +160,10 @@ export default class GoogleCalendarService implements Calendar {
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
return new Promise(async (resolve, reject) => {
const myGoogleAuth = await this.auth.getToken();
const eventAttendees = event.attendees.map(({ id, ...rest }) => ({
...rest,
responseStatus: "accepted",
}));
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
@ -179,10 +183,7 @@ export default class GoogleCalendarService implements Calendar {
responseStatus: "accepted",
},
// eslint-disable-next-line
...event.attendees.map(({ id, ...rest }) => ({
...rest,
responseStatus: "accepted",
})),
...eventAttendees,
],
reminders: {
useDefault: true,

View File

@ -16,8 +16,8 @@ export type DefaultEventLocationType = {
iconUrl: string;
// HACK: `variable` and `defaultValueVariable` are required due to legacy reason where different locations were stored in different places.
variable: "locationType" | "locationAddress" | "locationLink" | "locationPhoneNumber" | "phone";
defaultValueVariable: "address" | "link" | "hostPhoneNumber" | "phone";
variable: "locationType" | "locationAddress" | "address" | "locationLink" | "locationPhoneNumber" | "phone";
defaultValueVariable: "address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone";
} & (
| {
organizerInputType: "phone" | "text" | null;
@ -26,7 +26,7 @@ export type DefaultEventLocationType = {
attendeeInputPlaceholder?: null;
}
| {
attendeeInputType: "phone" | "text" | null;
attendeeInputType: "phone" | "attendeeAddress" | null;
attendeeInputPlaceholder: string;
organizerInputType?: null;
organizerInputPlaceholder?: null;
@ -40,6 +40,13 @@ export type EventLocationType = DefaultEventLocationType | EventLocationTypeFrom
export const DailyLocationType = "integrations:daily";
export enum DefaultEventLocationTypeEnum {
/**
* Booker Address
*/
AttendeeInPerson = "attendeeInPerson",
/**
* Organizer Address
*/
InPerson = "inPerson",
/**
* Booker Phone
@ -53,10 +60,22 @@ export enum DefaultEventLocationTypeEnum {
}
export const defaultLocations: DefaultEventLocationType[] = [
{
default: true,
type: DefaultEventLocationTypeEnum.AttendeeInPerson,
label: "In Person (Attendee Address)",
variable: "address",
organizerInputType: null,
messageForOrganizer: "Cal will ask your invitee to enter an address before scheduling.",
attendeeInputType: "attendeeAddress",
attendeeInputPlaceholder: `Enter Address`,
defaultValueVariable: "attendeeAddress",
iconUrl: "/map-pin.svg",
},
{
default: true,
type: DefaultEventLocationTypeEnum.InPerson,
label: "In Person",
label: "In Person (Organizer Address)",
organizerInputType: "text",
messageForOrganizer: "Provide an Address or Place",
// HACK:
@ -103,7 +122,7 @@ export const defaultLocations: DefaultEventLocationType[] = [
export type LocationObject = {
type: string;
displayLocationPublicly?: boolean;
} & Partial<Record<"address" | "link" | "hostPhoneNumber" | "phone", string>>;
} & Partial<Record<"address" | "attendeeAddress" | "link" | "hostPhoneNumber" | "phone", string>>;
// integrations:jitsi | 919999999999 | Delhi | https://manual.meeting.link | Around Video
export type BookingLocationValue = string;

View File

@ -326,6 +326,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
const handleZoomResponse = async (response: Response, credentialId: Credential["id"]) => {
let _response = response.clone();
const responseClone = response.clone();
if (_response.headers.get("content-encoding") === "gzip") {
const responseString = await response.text();
_response = JSON.parse(responseString);
@ -339,7 +340,7 @@ const handleZoomResponse = async (response: Response, credentialId: Credential["
throw Error(response.statusText);
}
return response.json();
return responseClone.json();
};
const invalidateCredential = async (credentialId: Credential["id"]) => {

View File

@ -7,8 +7,14 @@ import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApi
import { getEventLocationTypeFromApp } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import prisma from "@calcom/prisma";
import { Attendee } from "@calcom/prisma/client";
import { createdEventSchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent, NewCalendarEventType } from "@calcom/types/Calendar";
import type {
AdditionalInformation,
CalendarEvent,
NewCalendarEventType,
Person,
} from "@calcom/types/Calendar";
import { CredentialPayload, CredentialWithAppName } from "@calcom/types/Credential";
import type { Event } from "@calcom/types/Event";
import type {
@ -294,6 +300,7 @@ export default class EventManager {
}
public async updateCalendarAttendees(event: CalendarEvent, booking: PartialBooking) {
// @NOTE: This function is only used for updating attendees on a calendar event. Can we remove this?
await this.updateAllCalendarEvents(event, booking);
}
@ -430,11 +437,14 @@ export default class EventManager {
try {
// Bookings should only have one calendar reference
calendarReference = booking.references.filter((reference) => reference.type.includes("_calendar"))[0];
if (!calendarReference) throw new Error("bookingRef");
if (!calendarReference) {
throw new Error("bookingRef");
}
const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = calendarReference;
if (!bookingExternalCalendarId) throw new Error("externalCalendarId");
if (!bookingExternalCalendarId) {
throw new Error("externalCalendarId");
}
let result = [];
if (calendarReference.credentialId) {
@ -458,13 +468,15 @@ export default class EventManager {
.map(async (cred) => {
const calendarReference = booking.references.find((ref) => ref.type === cred.type);
if (!calendarReference)
return {
appName: cred.appName,
type: cred.type,
success: false,
uid: "",
originalEvent: event,
};
if (!calendarReference) {
return {
appName: cred.appName,
type: cred.type,
success: false,
uid: "",
originalEvent: event,
};
}
const { externalCalendarId: bookingExternalCalendarId, meetingId: bookingRefUid } =
calendarReference;
return await updateEvent(cred, event, bookingRefUid ?? null, bookingExternalCalendarId ?? null);
@ -474,7 +486,9 @@ export default class EventManager {
return Promise.all(result);
} catch (error) {
let message = `Tried to 'updateAllCalendarEvents' but there was no '{thing}' for '${credential?.type}', userId: '${credential?.userId}', bookingId: '${booking?.id}'`;
if (error instanceof Error) message = message.replace("{thing}", error.message);
if (error instanceof Error) {
message = message.replace("{thing}", error.message);
}
console.error(message);
return Promise.resolve([
{

View File

@ -47,7 +47,7 @@
"npm-run-all": "^4.1.5",
"postcss": "^8.4.18",
"typescript": "^4.7.4",
"vite": "^3.1.0",
"vite": "^2.9.15",
"tailwindcss": "^3.2.1"
}
}

View File

@ -47,7 +47,7 @@
"eslint": "^8.22.0",
"npm-run-all": "^4.1.5",
"typescript": "^4.7.4",
"vite": "^3.1.0"
"vite": "^2.9.15"
},
"dependencies": {
"@calcom/embed-core": "*",

View File

@ -7,6 +7,7 @@ import {
WebhookTriggerEvents,
} from "@prisma/client";
import async from "async";
import { cloneDeep } from "lodash";
import type { NextApiRequest } from "next";
import { RRule } from "rrule";
import short from "short-uuid";
@ -482,8 +483,9 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
if (reqBody.bookingUid) {
if (!eventType.seatsPerTimeSlot)
if (!eventType.seatsPerTimeSlot) {
throw new HttpError({ statusCode: 404, message: "Event type does not have seats" });
}
const booking = await prisma.booking.findUnique({
where: {
@ -505,7 +507,9 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
},
},
});
if (!booking) throw new HttpError({ statusCode: 404, message: "Booking not found" });
if (!booking) {
throw new HttpError({ statusCode: 404, message: "Booking not found" });
}
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
@ -514,11 +518,13 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot <= booking.attendees.length)
if (eventType.seatsPerTimeSlot <= booking.attendees.length) {
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
}
if (booking.attendees.some((attendee) => attendee.email === invitee[0].email))
if (booking.attendees.find((attendee) => attendee.email === invitee[0].email)) {
throw new HttpError({ statusCode: 409, message: "Already signed up for time slot" });
}
await prisma.booking.update({
where: {
@ -537,8 +543,13 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
});
const newSeat = booking.attendees.length !== 0;
await sendScheduledSeatsEmails(evt, invitee[0], newSeat, !!eventType.seatsShowAttendees);
/**
* Remember objects are passed into functions as references
* so if you modify it in a inner function it will be modified in the outer function
* deep cloning evt to avoid this
*/
const copyEvent = cloneDeep(evt);
await sendScheduledSeatsEmails(copyEvent, invitee[0], newSeat, !!eventType.seatsShowAttendees);
const credentials = await refreshCredentials(organizerUser.credentials);
const eventManager = new EventManager({ ...organizerUser, credentials });

View File

@ -1,6 +1,6 @@
import React from "react";
import { CAL_URL } from "./constants";
import { CAL_URL, LOGO } from "./constants";
// Ensures tw prop is typed.
declare module "react" {
@ -119,7 +119,7 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
<Wrapper variant="dark">
<div tw="h-full flex flex-col justify-start">
<div tw="flex items-center justify-center" style={{ fontFamily: "cal", fontWeight: 300 }}>
<img src={`${CAL_URL}/cal-logo-word-black.svg`} width="350" alt="Logo" />
<img src={`${CAL_URL}/${LOGO}`} width="350" alt="Logo" />
{avatars.length > 0 && <div tw="font-bold text-black text-[92px] mx-8 bottom-2">/</div>}
<div tw="flex flex-row">
{avatars.slice(0, 3).map((avatar) => (
@ -189,12 +189,7 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
export const App = ({ name, description, slug }: AppImageProps) => (
<Wrapper>
<img
src={`${CAL_URL}/cal-logo-word-black.svg`}
width="150"
alt="Logo"
tw="absolute right-[48px] top-[48px]"
/>
<img src={`${CAL_URL}/${LOGO}`} width="150" alt="Logo" tw="absolute right-[48px] top-[48px]" />
<VisualBlur visualSlug={slug} />

View File

@ -152,7 +152,7 @@ const loggedInViewerRouter = createProtectedRouter()
locale: user.locale,
timeFormat: user.timeFormat,
timeZone: user.timeZone,
avatar: user.avatar,
avatar: `${CAL_URL}/${user.username}/avatar.png`,
createdDate: user.createdDate,
trialEndsAt: user.trialEndsAt,
completedOnboarding: user.completedOnboarding,

View File

@ -0,0 +1,51 @@
import { UseFormReturn } from "react-hook-form";
import { Props } from "react-phone-number-input/react-hook-form";
import { EventLocationType } from "@calcom/app-store/locations";
import { Icon } from "../Icon";
type BookingFormValues = {
name: string;
email: string;
notes?: string;
locationType?: EventLocationType["type"];
guests?: string[];
address?: string;
attendeeAddress?: string;
phone?: string;
hostPhoneNumber?: string; // Maybe come up with a better way to name this to distingish between two types of phone numbers
customInputs?: {
[key: string]: string | boolean;
};
rescheduleReason?: string;
smsReminderNumber?: string;
};
export type AddressInputProps<FormValues> = Props<
{
value: string;
id: string;
placeholder: string;
required: boolean;
bookingForm: UseFormReturn<BookingFormValues>;
},
FormValues
>;
function AddressInput<FormValues>({ bookingForm, name, className, ...rest }: AddressInputProps<FormValues>) {
return (
<div className="relative ">
<Icon.FiMapPin color="#D2D2D2" className="absolute top-1/2 left-0.5 ml-3 h-6 -translate-y-1/2" />
<input
{...rest}
{...bookingForm.register("attendeeAddress")}
name={name}
color="#D2D2D2"
className={`${className} border-1 focus-within:border-brand dark:bg-darkgray-100 dark:border-darkgray-300 block h-10 w-full rounded-md border border-gray-300 py-px pl-10 text-sm outline-none ring-black focus-within:ring-1 disabled:text-gray-500 disabled:opacity-50 dark:text-white dark:placeholder-gray-500 dark:selection:bg-green-500 disabled:dark:text-gray-500`}
/>
</div>
);
}
export default AddressInput;

View File

@ -0,0 +1,8 @@
import dynamic from "next/dynamic";
/** These are like 40kb that not every user needs */
const AddressInput = dynamic(
() => import("./AddressInput")
) as unknown as typeof import("./AddressInput").default;
export default AddressInput;