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:
parent
058550ba3d
commit
7c3090bc23
|
@ -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">
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue
Block a user