From f881061d8d0cfd068906d90175551e3313edc5e0 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 17 May 2023 01:11:47 +0530 Subject: [PATCH 1/5] Fully handle booking pages with conflicting themes being opened together (#8921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Handle booking pages with conflicting themes being opened together * Add comments --------- Co-authored-by: Omar López --- apps/web/lib/app-providers.tsx | 149 ++++++++++++------ apps/web/pages/[user].tsx | 2 + apps/web/pages/[user]/[type].tsx | 4 + apps/web/pages/[user]/book.tsx | 2 + apps/web/pages/booking/[uid].tsx | 1 + apps/web/pages/d/[link]/[slug].tsx | 1 + apps/web/pages/d/[link]/book.tsx | 1 + apps/web/pages/team/[slug].tsx | 1 + apps/web/pages/team/[slug]/[type].tsx | 1 + apps/web/pages/team/[slug]/book.tsx | 1 + .../pages/routing-link/[...appPages].tsx | 2 + packages/embeds/embed-core/index.html | 15 +- packages/embeds/embed-core/playground.ts | 24 +++ 13 files changed, 157 insertions(+), 47 deletions(-) diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index f965b6a636..b8c0fa29dd 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -26,7 +26,10 @@ const I18nextAdapter = appWithTranslation & { children ); // Workaround for https://github.com/vercel/next.js/issues/8592 -export type AppProps = Omit>, "Component"> & { +export type AppProps = Omit< + NextAppProps>, + "Component" +> & { Component: NextAppProps["Component"] & { requiresLicense?: boolean; isThemeSupported?: boolean; @@ -72,58 +75,21 @@ const enum ThemeSupport { Booking = "userConfigured", } -const CalcomThemeProvider = ( - props: PropsWithChildren< - WithNonceProps & { - isBookingPage?: boolean | ((arg: { router: NextRouter }) => boolean); - isThemeSupported?: boolean; - } - > -) => { - // We now support the inverse of how we handled it in the past. Setting this to false will disable theme. - // undefined or true means we use system theme +type CalcomThemeProps = PropsWithChildren< + Pick & + Pick +>; +const CalcomThemeProvider = (props: CalcomThemeProps) => { const router = useRouter(); - const isBookingPage = (() => { - if (typeof props.isBookingPage === "function") { - return props.isBookingPage({ router: router }); - } - return props.isBookingPage; - })(); - - const themeSupport = isBookingPage - ? ThemeSupport.Booking - : // if isThemeSupported is explicitly false, we don't use theme there - props.isThemeSupported === false - ? ThemeSupport.None - : ThemeSupport.App; - - const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined; // Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently // One such example is our Embeds Demo and Testing page at http://localhost:3100 // Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey const embedNamespace = typeof window !== "undefined" ? window.getEmbedNamespace() : null; const isEmbedMode = typeof embedNamespace === "string"; - const storageKey = isEmbedMode - ? `embed-theme-${embedNamespace}` - : themeSupport === ThemeSupport.App - ? "app-theme" - : themeSupport === ThemeSupport.Booking - ? "booking-theme" - : undefined; - return ( - + {/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */} {/* color-scheme makes background:transparent not work in iframe which is required by embed. */} {typeof window !== "undefined" && !isEmbedMode && ( @@ -140,6 +106,100 @@ const CalcomThemeProvider = ( ); }; +/** + * The most important job for this fn is to generate correct storageKey for theme persistenc. + * `storageKey` is important because that key is listened for changes(using [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event) and any pages opened will change it's theme based on that(as part of next-themes implementation). + * Choosing the right storageKey avoids theme flickering caused by another page using different theme + * So, we handle all the cases here namely, + * - Both Booking Pages, /free/30min and /pro/30min but configured with different themes but being operated together. + * - Embeds using different namespace. They can be completely themed different on the same page. + * - Embeds using the same namespace but showing different cal.com links with different themes + * - Embeds using the same namespace and showing same cal.com links with different themes(Different theme is possible for same cal.com link in case of embed because of theme config available in embed) + * - App has different theme then Booking Pages. + * + * All the above cases have one thing in common, which is the origin and thus localStorage is shared and thus `storageKey` is critical to avoid theme flickering. + * + * Some things to note: + * - There is a side effect of so many factors in `storageKey` that many localStorage keys will be created if a user goes through all these scenarios(e.g like booking a lot of different users) + * - Some might recommend disabling localStorage persistence but that doesn't give good UX as then we would default to light theme always for a few seconds before switching to dark theme(if that's the user's preference). + * - We can't disable [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event handling as well because changing theme in one tab won't change the theme without refresh in other tabs. That's again a bad UX + * - Theme flickering becomes infinitely ongoing in case of embeds because of the browser's delay in processing `storage` event within iframes. Consider two embeds simulatenously opened with pages A and B. Note the timeline and keep in mind that it happened + * because 'setItem(A)' and 'Receives storageEvent(A)' allowed executing setItem(B) in b/w because of the delay. + * - t1 -> setItem(A) & Fires storageEvent(A) - On Page A) - Current State(A) + * - t2 -> setItem(B) & Fires storageEvent(B) - On Page B) - Current State(B) + * - t3 -> Receives storageEvent(A) & thus setItem(A) & thus fires storageEvent(A) (On Page B) - Current State(A) + * - t4 -> Receives storageEvent(B) & thus setItem(B) & thus fires storageEvent(B) (On Page A) - Current State(B) + * - ... and so on ... + */ +function getThemeProviderProps({ + props, + isEmbedMode, + embedNamespace, + router, +}: { + props: Omit; + isEmbedMode: boolean; + embedNamespace: string | null; + router: NextRouter; +}) { + const isBookingPage = (() => { + if (typeof props.isBookingPage === "function") { + return props.isBookingPage({ router: router }); + } + return props.isBookingPage; + })(); + + const themeSupport = isBookingPage + ? ThemeSupport.Booking + : // if isThemeSupported is explicitly false, we don't use theme there + props.isThemeSupported === false + ? ThemeSupport.None + : ThemeSupport.App; + + const isBookingPageThemSupportRequired = themeSupport === ThemeSupport.Booking; + const themeBasis = props.themeBasis; + + if ((isBookingPageThemSupportRequired || isEmbedMode) && !themeBasis) { + console.warn( + "`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker." + ); + } + + const appearanceIdSuffix = themeBasis ? ":" + themeBasis : ""; + const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined; + let embedExplicitlySetThemeSuffix = ""; + + if (typeof window !== "undefined") { + const embedTheme = window.getEmbedTheme(); + if (embedTheme) { + embedExplicitlySetThemeSuffix = ":" + embedTheme; + } + } + + const storageKey = isEmbedMode + ? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker + // Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise + `embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}` + : themeSupport === ThemeSupport.App + ? "app-theme" + : isBookingPageThemSupportRequired + ? `booking-theme${appearanceIdSuffix}` + : undefined; + + return { + storageKey, + forcedTheme, + themeSupport, + nonce: props.nonce, + enableColorScheme: false, + enableSystem: themeSupport !== ThemeSupport.None, + // next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes + // This is how login to dashboard soft navigation changes theme from light to dark + key: storageKey, + attribute: "class", + }; +} + function FeatureFlagsProvider({ children }: { children: React.ReactNode }) { const flags = useFlags(); return {children}; @@ -157,6 +217,7 @@ const AppProviders = (props: AppPropsWithChildren) => { {/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */} diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 8d0b41a7d6..caa96f6c62 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -356,6 +356,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => users, safeBio, profile, + // Dynamic group has no theme preference right now. It uses system theme. + themeBasis: isDynamicGroup ? null : user.username, user: { emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), }, diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 7468e89061..882ac3b440 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -177,6 +177,8 @@ async function getUserPageProps(context: GetStaticPropsContext) { slug: `${user.username}/${eventType.slug}`, image: `${WEBAPP_URL}/${user.username}/avatar.png`, }, + // Dynamic group has no theme preference right now. It uses system theme. + themeBasis: user.username, away: user?.away, isDynamic: false, trpcState: ssg.dehydrate(), @@ -307,6 +309,8 @@ async function getDynamicGroupPageProps(context: GetStaticPropsContext) { props: { eventType: eventTypeObject, profile, + // Dynamic group has no theme preference right now. It uses system theme. + themeBasis: null, isDynamic: true, away: false, trpcState: ssg.dehydrate(), diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index 741f35882d..a7b1f8091a 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -293,6 +293,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { props: { away: user.away, profile, + // Dynamic group has no theme preference right now. It uses system theme. + themeBasis: isDynamicGroupBooking ? null : user.username, eventType: eventTypeObject, booking, currentSlotBooking: currentSlotBooking, diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index 786b759e81..229babba28 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -1092,6 +1092,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { + themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username, hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding, profile, eventType, diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 3017918cdb..c990de4531 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -160,6 +160,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { props: { away: user.away, + themeBasis: user.username, isDynamicGroup: false, profile, date, diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx index eb01b2627d..d0cf51559d 100644 --- a/apps/web/pages/d/[link]/book.tsx +++ b/apps/web/pages/d/[link]/book.tsx @@ -122,6 +122,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { profile, + themeBasis: user.username, eventType: eventTypeObject, booking: null, currentSlotBooking: null, diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 08bc04ca58..80f03d25b5 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -207,6 +207,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return { props: { team: { ...team, safeBio, members }, + themeBasis: team.slug, trpcState: ssr.dehydrate(), markdownStrippedBio, }, diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 1eeb235b24..d06e7776b7 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -196,6 +196,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => brandColor: team.brandColor, darkBrandColor: team.darkBrandColor, }, + themeBasis: team.slug, date: dateParam, eventType: eventTypeObject, workingHours, diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index 6f85ead949..53ed06d0ca 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -153,6 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { image: eventTypeObject.team?.logo || null, eventName: null, }, + themeBasis: eventTypeObject.team?.slug, eventType: eventTypeObject, recurringEventCount, booking, diff --git a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx index 5661a2e203..2cd80e0bb9 100644 --- a/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/routing-link/[...appPages].tsx @@ -192,6 +192,7 @@ export const getServerSideProps = async function getServerSideProps( include: { user: { select: { + username: true, theme: true, brandColor: true, darkBrandColor: true, @@ -209,6 +210,7 @@ export const getServerSideProps = async function getServerSideProps( return { props: { isEmbed, + themeBasis: form.user.username, profile: { theme: form.user.theme, brandColor: form.user.brandColor, diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index cd7e1396ff..67559d77a5 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -9,7 +9,7 @@ const randomInt = Math.floor(Math.random() * 16777216); // Convert the integer to a hex string with 6 digits and add leading zeros if necessary - const hexString = randomInt.toString(16).padStart(6, '0'); + const hexString = randomInt.toString(16).padStart(6, "0"); // Return the hex string with a '#' prefix return `#${hexString}`; @@ -161,7 +161,7 @@

[Dark Theme][Guests(janedoe@example.com and test@example.com)]

- + You would see last Booking page action in my place
@@ -187,7 +187,9 @@ - + @@ -234,6 +236,13 @@

Hide EventType Details Test

+
+

You would be able to test out conflicting themes for the same namespace here.

+
+
+ Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace +
+ diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index 5b030f7ef9..4295396420 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -245,6 +245,28 @@ if (only === "all" || only === "hideEventTypeDetails") { ); } +if (only === "conflicting-theme") { + Cal("init", "conflictingTheme", { + debug: true, + origin: "http://localhost:3000", + }); + + Cal.ns.conflictingTheme("inline", { + elementOrSelector: "#cal-booking-place-conflicting-theme .dark", + calLink: "pro/30min", + config: { + theme: "dark", + }, + }); + Cal.ns.conflictingTheme("inline", { + elementOrSelector: "#cal-booking-place-conflicting-theme .light", + calLink: "pro/30min", + config: { + theme: "light", + }, + }); +} + Cal("init", "popupDarkTheme", { debug: true, origin: "http://localhost:3000", @@ -268,10 +290,12 @@ Cal("init", "popupAutoTheme", { debug: true, origin: "http://localhost:3000", }); + Cal("init", "popupTeamLinkLightTheme", { debug: true, origin: "http://localhost:3000", }); + Cal("init", "popupTeamLinkDarkTheme", { debug: true, origin: "http://localhost:3000", From 8ec178debdb9c095382d21bc0c743a72c77cdbd8 Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Tue, 16 May 2023 22:34:15 +0200 Subject: [PATCH 2/5] CAL-1591: Remember form values in new booker form when user navigates back and forth between timeslots and form (#8920) --- .../BookEventForm/BookEventForm.tsx | 21 +++++++++++++++++-- packages/features/bookings/Booker/store.ts | 11 ++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index bd0917e4e0..8c5f00a71d 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -70,11 +70,15 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { const timeslot = useBookerStore((state) => state.selectedTimeslot); const recurringEventCount = useBookerStore((state) => state.recurringEventCount); const username = useBookerStore((state) => state.username); + const formValues = useBookerStore((state) => state.formValues); + const setFormValues = useBookerStore((state) => state.setFormValues); const isRescheduling = !!rescheduleUid && !!rescheduleBooking; const event = useEvent(); const eventType = event.data; const defaultValues = useMemo(() => { + if (Object.keys(formValues).length) return formValues; + if (!eventType?.bookingFields) { return {}; } @@ -128,7 +132,7 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { email: defaultUserValues.email, }; return defaults; - }, [eventType?.bookingFields, isRescheduling, rescheduleBooking, rescheduleUid]); + }, [eventType?.bookingFields, formValues, isRescheduling, rescheduleBooking, rescheduleUid]); const bookingFormSchema = z .object({ @@ -226,6 +230,8 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { ); const bookEvent = (values: BookingFormValues) => { + // Clears form values stored in store, so old values won't stick around. + setFormValues({}); bookingForm.clearErrors(); // It shouldn't be possible that this method is fired without having event data, @@ -280,7 +286,18 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => { return (
-
+ { + // Form data is saved in store. This way when user navigates back to + // still change the timeslot, and comes back to the form, all their values + // still exist. This gets cleared when the form is submitted. + const values = bookingForm.getValues(); + setFormValues(values); + }} + form={bookingForm} + handleSubmit={bookEvent} + noValidate> -1)} fields={eventType.bookingFields} diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts index ba84ced3ca..44143327db 100644 --- a/packages/features/bookings/Booker/store.ts +++ b/packages/features/bookings/Booker/store.ts @@ -79,6 +79,13 @@ type BookerStore = { * Method called by booker component to set initial data. */ initialize: (data: StoreInitializeType) => void; + /** + * Stored form state, used when user navigates back and + * forth between timeslots and form. Get's cleared on submit + * to prevent sticky data. + */ + formValues: Record; + setFormValues: (values: Record) => void; }; const validLayouts: BookerLayout[] = ["large_calendar", "large_timeslots", "small_calendar"]; @@ -179,6 +186,10 @@ export const useBookerStore = create((set, get) => ({ set({ selectedTimeslot }); updateQueryParam("slot", selectedTimeslot ?? ""); }, + formValues: {}, + setFormValues: (formValues: Record) => { + set({ formValues }); + }, })); export const useInitializeBookerStore = ({ From ff74822c48546c8015023b261fa7774972b556a5 Mon Sep 17 00:00:00 2001 From: Nafees Nazik <84864519+G3root@users.noreply.github.com> Date: Wed, 17 May 2023 13:47:09 +0530 Subject: [PATCH 3/5] Fix: locale and time format in attendee email (#8941) * feat: add locale prop * feat: add locale prop * feat: add locale prop * fix: time format --- packages/emails/src/components/WhenInfo.tsx | 14 +++++++++----- .../src/templates/AttendeeScheduledEmail.tsx | 8 +++++++- .../emails/src/templates/BaseScheduledEmail.tsx | 9 +++++++-- .../src/templates/BrokenIntegrationEmail.tsx | 10 +++++++++- .../emails/src/templates/NoShowFeeChargedEmail.tsx | 4 ++++ .../src/templates/OrganizerScheduledEmail.tsx | 5 +++++ 6 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/emails/src/components/WhenInfo.tsx b/packages/emails/src/components/WhenInfo.tsx index 6effa922fb..10ba32d9de 100644 --- a/packages/emails/src/components/WhenInfo.tsx +++ b/packages/emails/src/components/WhenInfo.tsx @@ -5,7 +5,7 @@ import dayjs from "@calcom/dayjs"; // TODO: Use browser locale, implement Intl in Dayjs maybe? import "@calcom/dayjs/locales"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; -import { TimeFormat } from "@calcom/lib/timeFormat"; +import type { TimeFormat } from "@calcom/lib/timeFormat"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import type { RecurringEvent } from "@calcom/types/Calendar"; @@ -31,10 +31,14 @@ export function getRecurringWhen({ return ""; } -export function WhenInfo(props: { calEvent: CalendarEvent; timeZone: string; t: TFunction }) { - const { timeZone, t, calEvent: { recurringEvent } = {} } = props; - const timeFormat = props.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR; - const locale = props.calEvent.organizer.language.locale; +export function WhenInfo(props: { + calEvent: CalendarEvent; + timeZone: string; + t: TFunction; + locale: string; + timeFormat: TimeFormat; +}) { + const { timeZone, t, calEvent: { recurringEvent } = {}, locale, timeFormat } = props; function getRecipientStart(format: string) { return dayjs(props.calEvent.startTime).tz(timeZone).locale(locale).format(format); diff --git a/packages/emails/src/templates/AttendeeScheduledEmail.tsx b/packages/emails/src/templates/AttendeeScheduledEmail.tsx index cbb1826499..b1497cd3f9 100644 --- a/packages/emails/src/templates/AttendeeScheduledEmail.tsx +++ b/packages/emails/src/templates/AttendeeScheduledEmail.tsx @@ -9,6 +9,12 @@ export const AttendeeScheduledEmail = ( } & Partial> ) => { return ( - + ); }; diff --git a/packages/emails/src/templates/BaseScheduledEmail.tsx b/packages/emails/src/templates/BaseScheduledEmail.tsx index 94c55560d9..e54222418f 100644 --- a/packages/emails/src/templates/BaseScheduledEmail.tsx +++ b/packages/emails/src/templates/BaseScheduledEmail.tsx @@ -1,6 +1,7 @@ import type { TFunction } from "next-i18next"; import dayjs from "@calcom/dayjs"; +import { TimeFormat } from "@calcom/lib/timeFormat"; import type { CalendarEvent, Person } from "@calcom/types/Calendar"; import { @@ -21,9 +22,13 @@ export const BaseScheduledEmail = ( timeZone: string; includeAppsStatus?: boolean; t: TFunction; + locale: string; + timeFormat: TimeFormat | undefined; } & Partial> ) => { - const { t, timeZone } = props; + const { t, timeZone, locale, timeFormat: timeFormat_ } = props; + + const timeFormat = timeFormat_ ?? TimeFormat.TWELVE_HOUR; function getRecipientStart(format: string) { return dayjs(props.calEvent.startTime).tz(timeZone).format(format); @@ -73,7 +78,7 @@ export const BaseScheduledEmail = ( )} - + diff --git a/packages/emails/src/templates/BrokenIntegrationEmail.tsx b/packages/emails/src/templates/BrokenIntegrationEmail.tsx index 01d0ec4808..99f7fe72ce 100644 --- a/packages/emails/src/templates/BrokenIntegrationEmail.tsx +++ b/packages/emails/src/templates/BrokenIntegrationEmail.tsx @@ -1,4 +1,4 @@ -import { TFunction } from "next-i18next"; +import type { TFunction } from "next-i18next"; import { Trans } from "react-i18next"; import { AppStoreLocationType } from "@calcom/app-store/locations"; @@ -56,6 +56,8 @@ export const BrokenIntegrationEmail = ( ) => { const { calEvent, type } = props; const t = calEvent.organizer.language.translate; + const locale = calEvent.organizer.language.locale; + const timeFormat = calEvent.organizer?.timeFormat; if (type === "video") { let location = calEvent.location ? getEnumKeyByEnumValue(AppStoreLocationType, calEvent.location) : " "; @@ -70,6 +72,8 @@ export const BrokenIntegrationEmail = ( } @@ -94,6 +98,8 @@ export const BrokenIntegrationEmail = ( } @@ -107,6 +113,8 @@ export const BrokenIntegrationEmail = ( { const { calEvent } = props; const t = props.attendee.language.translate; + const locale = props.attendee.language.locale; + const timeFormat = props.attendee?.timeFormat; if (!calEvent.paymentInfo?.amount) throw new Error("No payment info"); return ( {t("no_show_fee_charged_subtitle", { diff --git a/packages/emails/src/templates/OrganizerScheduledEmail.tsx b/packages/emails/src/templates/OrganizerScheduledEmail.tsx index fc3eab435f..d2ec9dcc22 100644 --- a/packages/emails/src/templates/OrganizerScheduledEmail.tsx +++ b/packages/emails/src/templates/OrganizerScheduledEmail.tsx @@ -29,13 +29,18 @@ export const OrganizerScheduledEmail = ( } const t = props.teamMember?.language.translate || props.calEvent.organizer.language.translate; + const locale = props.teamMember?.language.locale || props.calEvent.organizer.language.locale; + const timeFormat = props.teamMember?.timeFormat || props.calEvent.organizer?.timeFormat; + return ( {props.attendeeCancelled From 81655f998826caef5009abba729910acf38e989b Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 17 May 2023 08:47:19 +0000 Subject: [PATCH 4/5] New Crowdin translations by Github Action --- apps/web/public/static/locales/ta/common.json | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/web/public/static/locales/ta/common.json b/apps/web/public/static/locales/ta/common.json index eff7d3d3f0..307429fdf9 100644 --- a/apps/web/public/static/locales/ta/common.json +++ b/apps/web/public/static/locales/ta/common.json @@ -1,4 +1,5 @@ { + "identity_provider": "அடையாள வழங்குபவர்", "trial_days_left": "உங்கள் மேம்படுத்தப்பட்ட பயன்பாட்டு சோதனையில் $t(day, {\"count\": {{days}} }) மீதமுள்ளது", "day_one": "{{count}} நாள்", "day_other": "{{count}} நாட்கள்", @@ -7,6 +8,7 @@ "upgrade_now": "மேம்படுத்தவும்", "accept_invitation": "அழைப்பை ஏற்கவும்", "calcom_explained": "{{appName}} அனைவருக்கும் திட்டமிடல் உள்கட்டமைப்பை வழங்குகிறது.", + "calcom_explained_new_user": "உங்கள் {{appName}} கணக்கை அமைக்கவும்! உங்கள் திட்டமிடல் சிக்கல்கள் அனைத்தையும் தீர்க்க சில படிகள் மட்டுமே உள்ளன.", "have_any_questions": "கேள்விகள் உள்ளதா? நாங்கள் உதவ தயாராக இருக்கிறோம்.", "reset_password_subject": "{{appName}}: கடவுச்சொல் மீட்டமைக்கும் வழிமுறைகள்", "event_declined_subject": "{{title}} {{date}} அன்று நிராகரிக்கப்பட்டது", @@ -28,8 +30,58 @@ "your_event_has_been_scheduled": "உங்கள் நிகழ்வு திட்டமிடப்பட்டது", "your_event_has_been_scheduled_recurring": "உங்கள் தொடர் நிகழ்வு திட்டமிடப்பட்டுள்ளது", "accept_our_license": ".env மாறியை மாற்றுவதன் மூலம் எங்கள் உரிமத்தை ஏற்கவும்\n<1>NEXT_PUBLIC_LICENSE_CONSENT to '{{agree}}'.", + "error_message": "பிழை செய்தி: '{{errorMessage}}'", + "refund_failed_subject": "பணத்தைத் திரும்பப் பெற முடியவில்லை: {{name}} - {{date}} - {{eventType}}", "refund_failed": "{{date}} அன்று {{userName}} உடன் நடந்த {{eventType}} நிகழ்விற்கான பணத்தைத் திரும்பி அனுப்ப இயலவில்லை.", "check_with_provider_and_user": "உங்கள் கட்டண வழங்குநர் மற்றும் {{user}} உடன் இனைந்து இதை எவ்வாறு கையாள்வது என்பதைச் சரிபார்க்கவும்.", + "a_refund_failed": "பணத்தைத் திரும்பப் பெற முடியவில்லை", + "awaiting_payment_subject": "பணம் செலுத்த காத்திருக்கிறது: {{date}} அன்று {{title}}", + "meeting_awaiting_payment": "உங்கள் மீட்டிங் கட்டணம் செலுத்த காத்திருக்கிறது", + "help": "உதவி", + "price": "விலை", + "paid": "பணம் செலுத்தப்பட்டது", + "refunded": "பணம் திருப்பிக் கொடுக்கப்பட்டது", + "payment": "பணம் செலுத்துதல்", + "missing_card_fields": "கார்டு புலங்கள் இல்லை", + "pay_now": "இப்போது பணம் செலுத்த", + "codebase_has_to_stay_opensource": "கோட்பேஸ் மாற்றப்பட்டாலும் இல்லாவிட்டாலும் திறந்த மூலமாக இருக்க வேண்டும்", + "cannot_repackage_codebase": "நீங்கள் கோட்பேஸை மீண்டும் பேக்கேஜ் செய்யவோ விற்கவோ முடியாது", + "acquire_license": "மின்னஞ்சல்மூலம் இந்த விதிமுறைகளை அகற்ற வணிக உரிமத்தைப் பெறவும்", + "terms_summary": "விதிமுறைகளின் சுருக்கம்", + "open_env": ".env ஐத் திறந்து எங்கள் உரிமத்தை ஏற்கவும்", + "env_changed": "நான் என் .env ஐ மாற்றிவிட்டேன்", + "accept_license": "உரிமத்தை ஏற்கவும்", + "still_waiting_for_approval": "ஒரு நிகழ்வு இன்னும் ஒப்புதலுக்காகக் காத்திருக்கிறது", + "event_is_still_waiting": "நிகழ்வு கோரிக்கை இன்னும் காத்திருக்கிறது: {{attendeeName}} - {{date}} - {{eventType}}", + "no_more_results": "மேலும் முடிவுகள் இல்லை", + "no_results": "முடிவுகள் இல்லை", + "load_more_results": "மேலும் முடிவுகளை ஏற்றவும்", + "integration_meeting_id": "{{integrationName}} சந்திப்பு ஐடி: {{meetingId}}", + "confirmed_event_type_subject": "உறுதிப்படுத்தப்பட்டது: {{date}} அன்று {{name}} உடன் {{eventType}}", + "new_event_request": "புதிய நிகழ்வு கோரிக்கை: {{attendeeName}} - {{date}} - {{eventType}}", + "confirm_or_reject_request": "கோரிக்கையை உறுதிப்படுத்தவும் அல்லது நிராகரிக்கவும்", + "check_bookings_page_to_confirm_or_reject": "முன்பதிவை உறுதிப்படுத்த அல்லது நிராகரிக்க உங்கள் முன்பதிவு பக்கத்தைப் பார்க்கவும்.", + "event_awaiting_approval": "உங்கள் ஒப்புதலுக்காக ஒரு நிகழ்வு காத்திருக்கிறது", + "event_awaiting_approval_recurring": "தொடர்ச்சியான நிகழ்வு உங்கள் ஒப்புதலுக்காகக் காத்திருக்கிறது", + "someone_requested_an_event": "உங்கள் காலெண்டரில் ஒரு நிகழ்வைத் திட்டமிடுமாறு ஒருவர் கோரியுள்ளார்.", + "someone_requested_password_reset": "உங்கள் கடவுச்சொல்லை மாற்ற யாரோ ஒரு இணைப்பைக் கோரியுள்ளனர்.", + "password_reset_instructions": "நீங்கள் இதைக் கோரவில்லை எனில், இந்த மின்னஞ்சலைப் பாதுகாப்பாகப் புறக்கணிக்கலாம், மேலும் உங்கள் கடவுச்சொல் மாற்றப்படாது.", + "event_awaiting_approval_subject": "ஒப்புதலுக்காகக் காத்திருக்கிறது: {{title}} {{date}} இல்", + "event_still_awaiting_approval": "உங்கள் ஒப்புதலுக்காக ஒரு நிகழ்வு இன்னும் காத்திருக்கிறது", + "booking_submitted_subject": "முன்பதிவு சமர்ப்பிக்கப்பட்டது: {{title}} {{date}} அன்று", + "download_your_recording": "உங்கள் பதிவைப் பதிவிறக்கவும்", + "your_meeting_has_been_booked": "உங்கள் சந்திப்பு பதிவு செய்யப்பட்டுள்ளது", + "event_type_has_been_rescheduled_on_time_date": "உங்கள் {{title}}, {{date}} க்கு மாற்றியமைக்கப்பட்டுள்ளது.", + "event_has_been_rescheduled": "புதுப்பிக்கப்பட்டது - உங்கள் நிகழ்வு மீண்டும் திட்டமிடப்பட்டது", + "request_reschedule_title_attendee": "உங்கள் முன்பதிவை மாற்றியமைக்க கோரிக்கை", + "request_reschedule_subtitle": "{{organizer}} முன்பதிவை ரத்துசெய்துவிட்டு, மற்றொரு நேரத்தைத் தேர்ந்தெடுக்கும்படி கோரியுள்ளார்.", + "request_reschedule_title_organizer": "நீங்கள் {{attendee}} ஐ மறுஅட்டவணைக்குக் கோரியுள்ளீர்கள்", + "request_reschedule_subtitle_organizer": "நீங்கள் முன்பதிவை ரத்து செய்துவிட்டீர்கள், {{attendee}} உங்களுடன் புதிய முன்பதிவு நேரத்தைத் தேர்ந்தெடுக்க வேண்டும்.", + "rescheduled_event_type_subject": "மறுஅட்டவணைக்கான கோரிக்கை அனுப்பப்பட்டது: {{eventType}} உடன் {{name}} உடன் {{date}}", + "requested_to_reschedule_subject_attendee": "மறுஅட்டவணை செயல் தேவை: {{eventType}} க்கு {{name}} உடன் புதிய நேரத்தை முன்பதிவு செய்யவும்", + "reschedule_reason": "மறு அட்டவணைக்கான காரணம்", + "hi_user_name": "வணக்கம் {{name}}", + "turn_on": "இயக்கவும்", "email_no_user_invite_heading": "நீங்கள் {{appName}} குழுவில் சேர அழைக்கப்பட்டுள்ளீர்கள்", "email_no_user_invite_subheading": "{{invitedBy}} உங்களை {{appName}} குழுவில் சேர அழைத்துள்ளார். {{appName}} என்பது ஈமெயில் இல்லாமலேயே உங்களையும் உங்கள் குழுவையும் கூட்டங்களைத் திட்டமிடுவதற்கு உதவும் திட்டமிடல் பயன்பாடு.", "email_no_user_invite_steps_intro": "சில சிறிய முயர்ச்சித்தல் மூலம் நாங்கள் உங்களை அழைத்துச் செல்வோம், எந்த நேரத்திலும் உங்கள் குழுவுடன் மன அழுத்தமில்லாத திட்டமிடலை அனுபவிப்பீர்கள்.", @@ -114,6 +166,7 @@ "select_all": "அனைத்தையும் தேர்வுசெய்", "default_conferencing_bulk_title": "தற்போதுள்ள நிகழ்வு வகைகளை மொத்தமாகப் புதுப்பிக்கவும்", "default_conferencing_bulk_description": "தேர்ந்தெடுக்கப்பட்ட நிகழ்வு வகைகளுக்கான இருப்பிடங்களைப் புதுப்பிக்கவும்", + "locked_by_admin": "குழு நிர்வாகியால் பூட்டப்பட்டது", "looking_for_more_analytics": "மேலும் பகுப்பாய்வுகளைத் தேடுகிறீர்களா?", "add_filter": "வடிகட்டிச் சேர்க்கவும்", "select_user": "பயனரைத் தேர்ந்தெடுக்கவும்", From b8b6c48d7d4ea6ce37540c3011509ae350a06501 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 17 May 2023 14:17:48 +0530 Subject: [PATCH 5/5] Improvements: Prefill Routing Forms and connect prefilling with Booking Form (#8780) * Support prefilling routing form and prefilling Booking form through routing form * Use Option Value as is instead of lowercasing * Fix prefill validation issue * Add prefill tests * Fix Routing Form tests * Small fix --- apps/web/public/static/locales/en/common.json | 1 + .../routing-forms/components/FormActions.tsx | 5 +- .../components/FormInputFields.tsx | 3 +- .../routing-forms/components/SingleForm.tsx | 2 +- .../react-awesome-query-builder/widgets.tsx | 8 +- .../pages/form-edit/[...appPages].tsx | 2 +- .../pages/route-builder/[...appPages].tsx | 2 +- .../pages/routing-link/[...appPages].tsx | 95 +++++++- .../playwright/tests/basic.e2e.ts | 206 +++++++++++++----- .../features/form-builder/FormBuilder.tsx | 2 +- playwright.config.ts | 4 +- 11 files changed, 258 insertions(+), 72 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 529d56c259..de7b68f9f0 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1815,6 +1815,7 @@ "open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.", "need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.", "book_my_cal": "Book my Cal", + "form_updated_successfully":"Form updated successfully.", "email_not_cal_member_cta": "Join your team", "disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees", "disable_attendees_confirmation_emails_description": "At least one workflow is active on this event type that sends an email to the attendees when the event is booked.", diff --git a/packages/app-store/routing-forms/components/FormActions.tsx b/packages/app-store/routing-forms/components/FormActions.tsx index 256139a305..483e31014f 100644 --- a/packages/app-store/routing-forms/components/FormActions.tsx +++ b/packages/app-store/routing-forms/components/FormActions.tsx @@ -276,6 +276,9 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil } return { previousValue }; }, + onSuccess: () => { + showToast(t("form_updated_successfully"), "success"); + }, onSettled: (routingForm) => { utils.viewer.appRoutingForms.forms.invalidate(); if (routingForm) { @@ -463,7 +466,7 @@ export const FormAction = forwardRef(function FormAction + {children} ); diff --git a/packages/app-store/routing-forms/components/FormInputFields.tsx b/packages/app-store/routing-forms/components/FormInputFields.tsx index ea37718d72..12d00188b7 100644 --- a/packages/app-store/routing-forms/components/FormInputFields.tsx +++ b/packages/app-store/routing-forms/components/FormInputFields.tsx @@ -1,6 +1,7 @@ import type { App_RoutingForms_Form } from "@prisma/client"; import type { Dispatch, SetStateAction } from "react"; +import getFieldIdentifier from "../lib/getFieldIdentifier"; import { getQueryBuilderConfig } from "../lib/getQueryBuilderConfig"; import isRouterLinkedField from "../lib/isRouterLinkedField"; import type { SerializableForm, Response } from "../types/types"; @@ -52,7 +53,7 @@ export default function FormInputFields(props: Props) { /* @ts-ignore */ required={!!field.required} listValues={options} - data-testid="form-field" + data-testid={`form-field-${getFieldIdentifier(field)}`} setValue={(value) => { setResponse((response) => { response = response || {}; diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index dd11ae07c4..8c1fba94e7 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -251,7 +251,7 @@ function SingleForm({ form, appUrl, Page }: SingleFormComponentProps) { const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({ onSuccess() { - showToast("Form updated successfully.", "success"); + showToast(t("form_updated_successfully"), "success"); }, onError(e) { if (e.message) { diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx b/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx index 8fec40249a..edfd451df4 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.tsx @@ -157,7 +157,7 @@ const MultiSelectWidget = ({ }; }); - const defaultValue = selectItems.filter((item) => value?.includes(item.value)); + const optionsFromList = selectItems.filter((item) => value?.includes(item.value)); return ( diff --git a/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx b/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx index 6df0cdbfb9..8a5834983b 100644 --- a/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx @@ -134,7 +134,7 @@ function Field({