Feat/Display location information publicly (#2752)

* Updating checkbox field to reflect new designs

* Include Infobadge option checkbox

* Checkbox Field + i18n

* Default checked - true

* Sync with router

* Extracting Types

* Update filtering logic

* Add UI to booking page

* Default address/link

* Update hashedlink page

* Tidy up

* Video icon

* Add nullish check

* Update to use RHF controller

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon 2022-05-25 21:34:08 +01:00 committed by GitHub
parent 058550ba3d
commit 7c3090bc23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 168 additions and 18 deletions

View File

@ -8,18 +8,22 @@ import {
CreditCardIcon,
GlobeIcon,
InformationCircleIcon,
LocationMarkerIcon,
RefreshIcon,
VideoCameraIcon,
} from "@heroicons/react/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useContracts } from "contexts/contractsContext";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import utc from "dayjs/plugin/utc";
import { TFunction } from "next-i18next";
import { useRouter } from "next/router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FormattedNumber, IntlProvider } from "react-intl";
import { Frequency as RRuleFrequency } from "rrule";
import { AppStoreLocationType, LocationObject, LocationType } from "@calcom/app-store/locations";
import {
useEmbedStyles,
useIsEmbed,
@ -57,6 +61,33 @@ dayjs.extend(customParseFormat);
type Props = AvailabilityTeamPageProps | AvailabilityPageProps;
export const locationKeyToString = (location: LocationObject, t: TFunction) => {
switch (location.type) {
case LocationType.InPerson:
return location.address || "In Person"; // If disabled address won't exist on the object
case LocationType.Link:
return location.link || "Link"; // If disabled link won't exist on the object
case LocationType.Phone:
return t("phone_call");
case LocationType.GoogleMeet:
return "Google Meet";
case LocationType.Zoom:
return "Zoom";
case LocationType.Daily:
return "Cal Video";
case LocationType.Jitsi:
return "Jitsi";
case LocationType.Huddle01:
return "Huddle Video";
case LocationType.Tandem:
return "Tandem";
case LocationType.Teams:
return "Microsoft Teams";
default:
return null;
}
};
const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage, booking }: Props) => {
const router = useRouter();
const isEmbed = useIsEmbed();
@ -225,6 +256,25 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{eventType.description}
</p>
)}
{eventType.locations.length === 1 && (
<p className="text-bookinglight mb-2 dark:text-white">
<LocationMarkerIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
{locationKeyToString(eventType.locations[0], t)}
</p>
)}
{eventType.locations.length === 1 && (
<p className="text-bookinglight mb-2 dark:text-white">
{Object.values(AppStoreLocationType).includes(
eventType.locations[0].type as unknown as AppStoreLocationType
) ? (
<VideoCameraIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
) : (
<LocationMarkerIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
)}
{locationKeyToString(eventType.locations[0], t)}
</p>
)}
<p className="text-bookinglight mb-2 dark:text-white">
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4" />
{eventType.length} {t("minutes")}
@ -297,6 +347,36 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
{eventType.description}
</p>
)}
{eventType.locations.length === 1 && (
<p className="text-bookinglight mb-2 dark:text-white">
{Object.values(AppStoreLocationType).includes(
eventType.locations[0].type as unknown as AppStoreLocationType
) ? (
<VideoCameraIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
) : (
<LocationMarkerIcon className="mr-[10px] ml-[2px] -mt-1 inline-block h-4 w-4 text-gray-400" />
)}
{locationKeyToString(eventType.locations[0], t)}
</p>
)}
{eventType.locations.length > 1 && (
<div className="text-bookinglight flex-warp mb-2 flex dark:text-white">
<div className="mr-[10px] ml-[2px] -mt-1 ">
<LocationMarkerIcon className="inline-block h-4 w-4 text-gray-400" />
</div>
<p>
{eventType.locations.map((el, i, arr) => {
return (
<span key={el.type}>
{locationKeyToString(el, t)}{" "}
{arr.length - 1 !== i && <span className="font-light"> or </span>}
</span>
);
})}
</p>
</div>
)}
<p className="text-bookinglight mb-3 dark:text-white">
<ClockIcon className="mr-[10px] -mt-1 ml-[2px] inline-block h-4 w-4 text-gray-400" />
{eventType.length} {t("minutes")}
@ -340,7 +420,6 @@ const AvailabilityPage = ({ profile, plan, eventType, workingHours, previousPage
</IntlProvider>
</p>
)}
<TimezoneDropdown />
{previousPage === `${WEBAPP_URL}/${profile.slug}` && (
<div className="flex h-full flex-col justify-end">

View File

@ -37,7 +37,7 @@ import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { ensureArray } from "@lib/ensureArray";
import useTheme from "@lib/hooks/useTheme";
import { LocationType } from "@lib/location";
import { LocationObject, LocationType } from "@lib/location";
import createBooking from "@lib/mutations/bookings/create-booking";
import createRecurringBooking from "@lib/mutations/bookings/create-recurring-booking";
import { parseDate, parseRecurringDates } from "@lib/parseDate";
@ -203,10 +203,9 @@ const BookingPage = ({
const eventTypeDetail = { isWeb3Active: false, ...eventType };
type Location = { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string };
// it would be nice if Prisma at some point in the future allowed for Json<Location>; as of now this is not the case.
const locations: Location[] = useMemo(
() => (eventType.locations as Location[]) || [],
const locations: LocationObject[] = useMemo(
() => (eventType.locations as LocationObject[]) || [],
[eventType.locations]
);

View File

@ -3,6 +3,7 @@ import { UserPlan } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { AppStoreLocationType, locationHiddenFilter, LocationObject } from "@calcom/app-store/locations";
import { getDefaultEvent, getGroupName, getUsernameList } from "@calcom/lib/defaultEvents";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { RecurringEvent } from "@calcom/types/Calendar";
@ -84,6 +85,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
locations: true,
schedulingType: true,
recurringEvent: true,
schedule: {
@ -254,12 +256,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
} as const;
}
}
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
const eventTypeObject = Object.assign({}, eventType, {
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
locations: locationHiddenFilter(locations),
});
const schedule = eventType.schedule

View File

@ -7,6 +7,7 @@ import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import { GetBookingType } from "@lib/getBooking";
import { AppStoreLocationType, locationHiddenFilter, LocationObject } from "@lib/location";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -54,6 +55,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
locations: true,
timeZone: true,
metadata: true,
slotInterval: true,
@ -132,6 +134,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
notFound: true,
};
}
const locations = hashedLink.eventType.locations
? (hashedLink.eventType.locations as LocationObject[])
: [];
const [user] = users;
const eventTypeObject = Object.assign({}, hashedLink.eventType, {
metadata: {} as JSONObject,
@ -139,6 +146,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodStartDate: hashedLink.eventType.periodStartDate?.toString() ?? null,
periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null,
slug,
locations: locationHiddenFilter(locations),
});
const schedule = {

View File

@ -50,7 +50,7 @@ import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
import { isSuccessRedirectAvailable } from "@lib/isSuccessRedirectAvailable";
import { LocationType } from "@lib/location";
import { LocationObject, LocationType } from "@lib/location";
import prisma from "@lib/prisma";
import { slugify } from "@lib/slugify";
import { trpc } from "@lib/trpc";
@ -118,7 +118,13 @@ export type FormValues = {
hidden: boolean;
hideCalendarNotes: boolean;
hashedLink: string | undefined;
locations: { type: LocationType; address?: string; link?: string; hostPhoneNumber?: string }[];
locations: {
type: LocationType;
address?: string;
link?: string;
hostPhoneNumber?: string;
displayLocationPublicly?: boolean;
}[];
customInputs: EventTypeCustomInput[];
users: string[];
schedule: number;
@ -422,6 +428,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
if (!selectedLocation) {
return null;
}
switch (selectedLocation.value) {
case LocationType.InPerson:
return (
@ -435,7 +442,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
{...locationFormMethods.register("locationAddress")}
id="address"
required
className=" block w-full rounded-sm border-gray-300 text-sm"
className="block w-full rounded-sm border-gray-300 text-sm"
defaultValue={
formMethods
.getValues("locations")
@ -443,6 +450,18 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}
/>
</div>
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={({ field: { onChange, value } }) => (
<CheckboxField
description={t("display_location_label")}
onChange={(e) => onChange(e.target.checked)}
infomationIconText={t("display_location_info_badge")}></CheckboxField>
)}
/>
</div>
</div>
);
case LocationType.Link:
@ -469,6 +488,18 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</p>
)}
</div>
<div className="mt-3">
<Controller
name="displayLocationPublicly"
control={locationFormMethods.control}
render={({ field: { onChange, value } }) => (
<CheckboxField
description={t("display_location_label")}
onChange={(e) => onChange(e.target.checked)}
infomationIconText={t("display_location_info_badge")}></CheckboxField>
)}
/>
</div>
</div>
);
case LocationType.UserPhone:
@ -584,6 +615,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const locationFormSchema = z.object({
locationType: z.string(),
locationAddress: z.string().optional(),
displayLocationPublicly: z.boolean().optional(),
locationPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
@ -596,6 +628,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
locationPhoneNumber?: string;
locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid?
locationLink?: string; // Currently this only accepts links that are HTTPS://
displayLocationPublicly?: boolean;
}>({
resolver: zodResolver(locationFormSchema),
});
@ -2131,14 +2164,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
<Form
form={locationFormMethods}
handleSubmit={async (values) => {
const newLocation = values.locationType;
const { locationType: newLocation, displayLocationPublicly } = values;
let details = {};
if (newLocation === LocationType.InPerson) {
details = { address: values.locationAddress };
details = {
address: values.locationAddress,
displayLocationPublicly,
};
}
if (newLocation === LocationType.Link) {
details = { link: values.locationLink };
details = { link: values.locationLink, displayLocationPublicly };
}
if (newLocation === LocationType.UserPhone) {
details = { hostPhoneNumber: values.locationPhoneNumber };
@ -2373,11 +2408,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!rawEventType) throw Error("Event type not found");
type Location = {
type: LocationType;
address?: string;
};
const credentials = await prisma.credential.findMany({
where: {
userId: session.user.id,
@ -2396,7 +2426,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const eventType = {
...restEventType,
recurringEvent: (restEventType.recurringEvent || {}) as RecurringEvent,
locations: locations as unknown as Location[],
locations: locations as unknown as LocationObject[],
metadata: (metadata || {}) as JSONObject,
isWeb3Active:
web3Credentials && web3Credentials.key

View File

@ -7,6 +7,7 @@ import { RecurringEvent } from "@calcom/types/Calendar";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBooking, { GetBookingType } from "@lib/getBooking";
import { AppStoreLocationType, locationHiddenFilter, LocationObject } from "@lib/location";
import prisma from "@lib/prisma";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -71,6 +72,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
beforeEventBuffer: true,
afterEventBuffer: true,
recurringEvent: true,
locations: true,
price: true,
currency: true,
timeZone: true,
@ -107,11 +109,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
eventType.schedule = null;
const locations = eventType.locations ? (eventType.locations as LocationObject[]) : [];
const eventTypeObject = Object.assign({}, eventType, {
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null,
periodEndDate: eventType.periodEndDate?.toString() ?? null,
recurringEvent: (eventType.recurringEvent || {}) as RecurringEvent,
locations: locationHiddenFilter(locations),
});
eventTypeObject.availability = [];

View File

@ -836,6 +836,8 @@
"go_to_app_store": "Go to App Store",
"calendar_error": "Something went wrong, try reconnecting your calendar with all necessary permissions",
"calendar_no_busy_slots": "There are no busy slots",
"display_location_label":"Display on booking page",
"display_location_info_badge":"Location will be visible before the booking is confirmed",
"share_feedback": "Share feedback",
"resources": "Resources",
"support_documentation": "Support documentation",

View File

@ -16,5 +16,27 @@ export enum AppStoreLocationType {
Teams = "integrations:office365_video",
}
export type LocationObject = {
type: LocationType;
address?: string;
link?: string;
displayLocationPublicly?: boolean;
hostPhoneNumber?: string;
};
export const LocationType = { ...DefaultLocationType, ...AppStoreLocationType };
export type LocationType = DefaultLocationType | AppStoreLocationType;
export const locationHiddenFilter = (locations: LocationObject[]) =>
locations.filter((el) => {
// Filter out locations that are not to be displayed publicly
const values = Object.values(AppStoreLocationType);
// Display if the location can be set to public - and also display all locations like google meet etc
if (el.displayLocationPublicly || values.includes(el["type"] as unknown as AppStoreLocationType))
return el;
else {
delete el.address;
delete el.link;
return el;
}
});

View File

@ -9,6 +9,7 @@ export const eventTypeLocations = z.array(
type: z.nativeEnum(LocationType),
address: z.string().optional(),
link: z.string().url().optional(),
displayLocationPublicly: z.boolean().optional(),
hostPhoneNumber: z.string().optional(),
})
);