diff --git a/.gitignore b/.gitignore index 5780dbbcca..626795ad64 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ tsconfig.tsbuildinfo # Prisma-Zod packages/prisma/zod/*.ts + +# Builds +dist diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..508657d224 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "apps/api"] + path = apps/api + url = git@github.com:calcom/api.git +[submodule "apps/website"] + path = apps/website + url = git@github.com:calcom/website.git diff --git a/.husky/post-receive b/.husky/post-receive new file mode 100755 index 0000000000..9d95befd62 --- /dev/null +++ b/.husky/post-receive @@ -0,0 +1,6 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +echo "Updating submodules recursively" +pwd +git submodule update --init --recursive diff --git a/.vercelignore b/.vercelignore deleted file mode 100644 index 26f5c15f86..0000000000 --- a/.vercelignore +++ /dev/null @@ -1 +0,0 @@ -.github diff --git a/README.md b/README.md index 8cc9a77247..fb74e2bdd0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,14 @@ Here is what you need to be able to run Cal. yarn dx ``` +#### Development tip + +> Add `NEXT_PUBLIC_DEBUG=1` anywhere in your `apps/web/.env` to get logging information for all the queries and mutations driven by **trpc**. + +```sh +echo 'NEXT_PUBLIC_DEBUG=1' >> apps/web/.env +``` + #### Manual setup 1. Configure environment variables in the .env file. Replace ``, ``, ``, `` with their applicable values diff --git a/apps/api b/apps/api new file mode 160000 index 0000000000..378cbf8f3a --- /dev/null +++ b/apps/api @@ -0,0 +1 @@ +Subproject commit 378cbf8f3a67ea7877296f1da02edb2b6e3efbce diff --git a/apps/web/components/Shell.tsx b/apps/web/components/Shell.tsx index 6ec76ee7be..ca25268e15 100644 --- a/apps/web/components/Shell.tsx +++ b/apps/web/components/Shell.tsx @@ -22,7 +22,7 @@ import TrialBanner from "@ee/components/TrialBanner"; import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic"; import classNames from "@lib/classNames"; -import { BASE_URL } from "@lib/config/constants"; +import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants"; import { shouldShowOnboarding } from "@lib/getting-started"; import { useLocale } from "@lib/hooks/useLocale"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry"; @@ -66,7 +66,7 @@ function useRedirectToLoginIfUnauthenticated() { router.replace({ pathname: "/auth/login", query: { - callbackUrl: `${BASE_URL}/${location.pathname}${location.search}`, + callbackUrl: `${NEXT_PUBLIC_BASE_URL}/${location.pathname}${location.search}`, }, }); } @@ -431,7 +431,7 @@ function UserDropdown({ small }: { small?: boolean }) { ["bookings"][number]; function BookingListItem(booking: BookingItem) { + // Get user so we can determine 12/24 hour format preferences + const query = useMeQuery(); + const user = query.data; const { t, i18n } = useLocale(); const utils = trpc.useContext(); const [rejectionReason, setRejectionReason] = useState(""); @@ -120,7 +124,8 @@ function BookingListItem(booking: BookingItem) {
{startTime}
- {dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")} + {dayjs(booking.startTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "} + {dayjs(booking.endTime).format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm")}
diff --git a/apps/web/components/booking/DatePicker.tsx b/apps/web/components/booking/DatePicker.tsx index 2470053708..b558fa3e48 100644 --- a/apps/web/components/booking/DatePicker.tsx +++ b/apps/web/components/booking/DatePicker.tsx @@ -4,11 +4,13 @@ import dayjs, { Dayjs } from "dayjs"; import dayjsBusinessTime from "dayjs-business-time"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import { useEffect, useMemo, useState } from "react"; +import { memoize } from "lodash"; +import { useEffect, useMemo, useRef, useState } from "react"; import classNames from "@lib/classNames"; import { timeZone } from "@lib/clock"; import { weekdayNames } from "@lib/core/i18n/weekday"; +import { doWorkAsync } from "@lib/doWorkAsync"; import { useLocale } from "@lib/hooks/useLocale"; import getSlots from "@lib/slots"; import { WorkingHours } from "@lib/types/schedule"; @@ -87,7 +89,13 @@ function DatePicker({ const [month, setMonth] = useState(""); const [year, setYear] = useState(""); const [isFirstMonth, setIsFirstMonth] = useState(false); - + const [daysFromState, setDays] = useState< + | { + disabled: Boolean; + date: number; + }[] + | null + >(null); useEffect(() => { if (!browsingDate || (date && browsingDate.utcOffset() !== date?.utcOffset())) { setBrowsingDate(date || dayjs().tz(timeZone())); @@ -99,13 +107,56 @@ function DatePicker({ setMonth(browsingDate.toDate().toLocaleString(i18n.language, { month: "long" })); setYear(browsingDate.format("YYYY")); setIsFirstMonth(browsingDate.startOf("month").isBefore(dayjs())); + setDays(null); } }, [browsingDate, i18n.language]); - const days = useMemo(() => { + const isDisabled = ( + day: number, + { + browsingDate, + periodType, + periodStartDate, + periodEndDate, + periodCountCalendarDays, + periodDays, + eventLength, + minimumBookingNotice, + workingHours, + } + ) => { + const date = browsingDate.startOf("day").date(day); + return ( + isOutOfBounds(date, { + periodType, + periodStartDate, + periodEndDate, + periodCountCalendarDays, + periodDays, + }) || + !getSlots({ + inviteeDate: date, + frequency: eventLength, + minimumBookingNotice, + workingHours, + }).length + ); + }; + + const isDisabledRef = useRef( + memoize(isDisabled, (day, { browsingDate }) => { + // Make a composite cache key + return day + "_" + browsingDate.toString(); + }) + ); + + const days = (() => { if (!browsingDate) { return []; } + if (daysFromState) { + return daysFromState; + } // Create placeholder elements for empty days in first week let weekdayOfFirst = browsingDate.date(1).day(); if (weekStart === "Monday") { @@ -115,33 +166,49 @@ function DatePicker({ const days = Array(weekdayOfFirst).fill(null); - const isDisabled = (day: number) => { - const date = browsingDate.startOf("day").date(day); - return ( - isOutOfBounds(date, { - periodType, - periodStartDate, - periodEndDate, - periodCountCalendarDays, - periodDays, - }) || - !getSlots({ - inviteeDate: date, - frequency: eventLength, - minimumBookingNotice, - workingHours, - }).length - ); - }; + const isDisabledMemoized = isDisabledRef.current; const daysInMonth = browsingDate.daysInMonth(); + const daysInitialOffset = days.length; + + // Build UI with All dates disabled for (let i = 1; i <= daysInMonth; i++) { - days.push({ disabled: isDisabled(i), date: i }); + days.push({ + disabled: true, + date: i, + }); } + // Update dates with their availability + doWorkAsync({ + batch: 1, + name: "DatePicker", + length: daysInMonth, + callback: (i: number, isLast) => { + let day = i + 1; + days[daysInitialOffset + i] = { + disabled: isDisabledMemoized(day, { + browsingDate, + periodType, + periodStartDate, + periodEndDate, + periodCountCalendarDays, + periodDays, + eventLength, + minimumBookingNotice, + workingHours, + }), + date: day, + }; + }, + batchDone: () => { + setDays([...days]); + }, + }); + return days; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [browsingDate]); + })(); if (!browsingDate) { return ; diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 507215d96b..a336592049 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -123,7 +123,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
{ (selectedDate ? "sm:w-1/3" : "sm:w-1/2") }> {
-
+
{confirmBtn || } diff --git a/apps/web/components/integrations/ConnectIntegrations.tsx b/apps/web/components/integrations/ConnectIntegrations.tsx index 68df42c9ba..3c07f987ca 100644 --- a/apps/web/components/integrations/ConnectIntegrations.tsx +++ b/apps/web/components/integrations/ConnectIntegrations.tsx @@ -2,7 +2,7 @@ import type { IntegrationOAuthCallbackState } from "pages/api/integrations/types import { useState } from "react"; import { useMutation } from "react-query"; -import { BASE_URL } from "@lib/config/constants"; +import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants"; import { ButtonBaseProps } from "@components/ui/Button"; @@ -19,7 +19,7 @@ export default function ConnectIntegration(props: { const mutation = useMutation(async () => { const state: IntegrationOAuthCallbackState = { - returnTo: BASE_URL + location.pathname + location.search, + returnTo: NEXT_PUBLIC_BASE_URL + location.pathname + location.search, }; const stateStr = encodeURIComponent(JSON.stringify(state)); const searchParams = `?state=${stateStr}`; diff --git a/apps/web/components/pages/eventtypes/CustomInputTypeForm.tsx b/apps/web/components/pages/eventtypes/CustomInputTypeForm.tsx index b366d05477..d71917e6c1 100644 --- a/apps/web/components/pages/eventtypes/CustomInputTypeForm.tsx +++ b/apps/web/components/pages/eventtypes/CustomInputTypeForm.tsx @@ -120,11 +120,11 @@ const CustomInputTypeForm: FC = (props) => { value={selectedCustomInput?.id || -1} {...register("id", { valueAsNumber: true })} /> -
- +
+
); diff --git a/apps/web/components/team/TeamCreateModal.tsx b/apps/web/components/team/TeamCreateModal.tsx index 03bf8873bc..1fadb06918 100644 --- a/apps/web/components/team/TeamCreateModal.tsx +++ b/apps/web/components/team/TeamCreateModal.tsx @@ -76,7 +76,7 @@ export default function TeamCreate(props: Props) { />
{errorMessage && } -
+
diff --git a/apps/web/components/ui/AvatarGroup.tsx b/apps/web/components/ui/AvatarGroup.tsx index baabd2fd53..bc1db4b233 100644 --- a/apps/web/components/ui/AvatarGroup.tsx +++ b/apps/web/components/ui/AvatarGroup.tsx @@ -22,8 +22,14 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) { {props.items.slice(0, props.truncateAfter).map((item, idx) => { if (item.image != null) { return ( -
  • - +
  • +
  • ); } diff --git a/apps/web/components/ui/form/Schedule.tsx b/apps/web/components/ui/form/Schedule.tsx index 033704d4e9..69628d66d8 100644 --- a/apps/web/components/ui/form/Schedule.tsx +++ b/apps/web/components/ui/form/Schedule.tsx @@ -10,6 +10,7 @@ import { weekdayNames } from "@lib/core/i18n/weekday"; import { useLocale } from "@lib/hooks/useLocale"; import { TimeRange } from "@lib/types/schedule"; +import { useMeQuery } from "@components/Shell"; import Button from "@components/ui/Button"; import Select from "@components/ui/form/Select"; @@ -46,6 +47,10 @@ type TimeRangeFieldProps = { }; const TimeRangeField = ({ name }: TimeRangeFieldProps) => { + // Get user so we can determine 12/24 hour format preferences + const query = useMeQuery(); + const user = query.data; + // Lazy-loaded options, otherwise adding a field has a noticable redraw delay. const [options, setOptions] = useState([]); const [selected, setSelected] = useState(); @@ -57,7 +62,9 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => { const getOption = (time: ConfigType) => ({ value: dayjs(time).toDate().valueOf(), - label: dayjs(time).utc().format("HH:mm"), + label: dayjs(time) + .utc() + .format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm"), // .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }), }); @@ -82,7 +89,7 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => { handleSelected(value); return ( setOptions(timeOptions({ selected }))} onBlur={() => setOptions([])} diff --git a/apps/web/components/ui/modal/SetTimesModal.tsx b/apps/web/components/ui/modal/SetTimesModal.tsx index 07e82c02cd..b314d8542b 100644 --- a/apps/web/components/ui/modal/SetTimesModal.tsx +++ b/apps/web/components/ui/modal/SetTimesModal.tsx @@ -59,8 +59,8 @@ export default function SetTimesModal(props: SetTimesModalProps) { let endMinute = parseInt(endMinsRef.current.value); //convert to dayjs object - const startTime = dayjs(`${startHour}-${startMinute}`, "hh:mm"); - const endTime = dayjs(`${endHour}-${endMinute}`, "hh:mm"); + const startTime = dayjs(`${startHour}:${startMinute}`, "hh:mm"); + const endTime = dayjs(`${endHour}:${endMinute}`, "hh:mm"); //compute minimin and maximum allowed const maximumStartTime = endTime.subtract(step, "minute"); diff --git a/apps/web/ee/lib/stripe/customer.ts b/apps/web/ee/lib/stripe/customer.ts index 9f60e1da87..ee0097b0de 100644 --- a/apps/web/ee/lib/stripe/customer.ts +++ b/apps/web/ee/lib/stripe/customer.ts @@ -46,7 +46,23 @@ export async function getStripeCustomerId(user: UserType): Promise { + offsetStart = offsetStart || 0; + const stepLength = batch; + const lastIndex = length - 1; + const offsetEndExclusive = offsetStart + stepLength; + + batchDone = batchDone || (() => {}); + done = done || (() => {}); + + if (!__pending && data[name]) { + cancelAnimationFrame(data[name]); + } + + if (offsetStart >= length) { + done(); + return; + } + + for (let i = offsetStart; i < offsetEndExclusive && i < length; i++) { + callback(i, offsetEndExclusive > lastIndex); + } + + batchDone(); + + data[name] = requestAnimationFrame(() => { + doWorkAsync({ + length, + callback, + batchDone, + name, + batch, + done, + offsetStart: offsetEndExclusive, + __pending: true, + }); + }); +}; diff --git a/apps/web/lib/slots.ts b/apps/web/lib/slots.ts index 2ec824bcbf..8f2c132fb2 100644 --- a/apps/web/lib/slots.ts +++ b/apps/web/lib/slots.ts @@ -16,17 +16,29 @@ export type GetSlots = { workingHours: WorkingHours[]; minimumBookingNotice: number; }; +export type WorkingHoursTimeFrame = { startTime: number; endTime: number }; -const getMinuteOffset = (date: Dayjs, frequency: number) => { - // Diffs the current time with the given date and iff same day; (handled by 1440) - return difference; otherwise 0 - const minuteOffset = Math.min(date.diff(dayjs().utc(), "minute"), 1440) % 1440; - // round down to nearest step - return Math.ceil(minuteOffset / frequency) * frequency; +const splitAvailableTime = ( + startTimeMinutes: number, + endTimeMinutes: number, + frequency: number +): Array => { + let initialTime = startTimeMinutes; + const finalizationTime = endTimeMinutes; + const result = [] as Array; + while (initialTime < finalizationTime) { + const periodTime = initialTime + frequency; + result.push({ startTime: initialTime, endTime: periodTime }); + initialTime += frequency; + } + return result; }; const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours }: GetSlots) => { // current date in invitee tz const startDate = dayjs().add(minimumBookingNotice, "minute"); + const startOfDay = dayjs.utc().startOf("day"); + const startOfInviteeDay = inviteeDate.startOf("day"); // checks if the start date is in the past if (inviteeDate.isBefore(startDate, "day")) { return []; @@ -36,33 +48,27 @@ const getSlots = ({ inviteeDate, frequency, minimumBookingNotice, workingHours } { utcOffset: -inviteeDate.utcOffset() }, workingHours.map((schedule) => ({ days: schedule.days, - startTime: dayjs.utc().startOf("day").add(schedule.startTime, "minute"), - endTime: dayjs.utc().startOf("day").add(schedule.endTime, "minute"), + startTime: startOfDay.add(schedule.startTime, "minute"), + endTime: startOfDay.add(schedule.endTime, "minute"), })) ).filter((hours) => hours.days.includes(inviteeDate.day())); const slots: Dayjs[] = []; - for (let minutes = getMinuteOffset(inviteeDate, frequency); minutes < 1440; minutes += frequency) { - const slot = dayjs(inviteeDate).startOf("day").add(minutes, "minute"); - // check if slot happened already - if (slot.isBefore(startDate)) { - continue; - } - // add slots to available slots if it is found to be between the start and end time of the checked working hours. - if ( - localWorkingHours.some((hours) => - slot.isBetween( - inviteeDate.startOf("day").add(hours.startTime, "minute"), - inviteeDate.startOf("day").add(hours.endTime, "minute"), - null, - "[)" - ) - ) - ) { + + const slotsTimeFrameAvailable = [] as Array; + + // Here we split working hour in chunks for every frequency available that can fit in whole working hour + localWorkingHours.forEach((item, index) => { + slotsTimeFrameAvailable.push(...splitAvailableTime(item.startTime, item.endTime, frequency)); + }); + + slotsTimeFrameAvailable.forEach((item) => { + const slot = startOfInviteeDay.add(item.startTime, "minute"); + // Validating slot its not on the past + if (!slot.isBefore(startDate)) { slots.push(slot); } - } - + }); return slots; }; diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 7d67ff4085..1c7a4ad479 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -45,8 +45,7 @@ export default withTRPC({ // adds pretty logs to your console in development and logs errors in production loggerLink({ enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), + !!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error), }), httpBatchLink({ url: `/api/trpc`, diff --git a/apps/web/pages/auth/signup.tsx b/apps/web/pages/auth/signup.tsx index f72775a89f..28ff2e6412 100644 --- a/apps/web/pages/auth/signup.tsx +++ b/apps/web/pages/auth/signup.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import { asStringOrNull } from "@lib/asStringOrNull"; -import { BASE_URL } from "@lib/config/constants"; +import { NEXT_PUBLIC_BASE_URL } from "@lib/config/constants"; import { useLocale } from "@lib/hooks/useLocale"; import prisma from "@lib/prisma"; import { isSAMLLoginEnabled } from "@lib/saml"; @@ -60,7 +60,7 @@ export default function Signup({ email }: Props) { .then( async () => await signIn("Cal.com", { - callbackUrl: (`${BASE_URL}/${router.query.callbackUrl}` || "") as string, + callbackUrl: (`${NEXT_PUBLIC_BASE_URL}/${router.query.callbackUrl}` || "") as string, }) ) .catch((err) => { @@ -130,7 +130,7 @@ export default function Signup({ email }: Props) { className="w-5/12 justify-center" onClick={() => signIn("Cal.com", { - callbackUrl: (`${BASE_URL}/${router.query.callbackUrl}` || "") as string, + callbackUrl: (`${NEXT_PUBLIC_BASE_URL}/${router.query.callbackUrl}` || "") as string, }) }> {t("login_instead")} @@ -184,7 +184,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { return { redirect: { permanent: false, - destination: "/auth/login?callbackUrl=" + `${BASE_URL}/${ctx.query.callbackUrl}`, + destination: "/auth/login?callbackUrl=" + `${NEXT_PUBLIC_BASE_URL}/${ctx.query.callbackUrl}`, }, }; } diff --git a/apps/web/pages/availability/troubleshoot.tsx b/apps/web/pages/availability/troubleshoot.tsx index 85c308856b..eeb76a1bd9 100644 --- a/apps/web/pages/availability/troubleshoot.tsx +++ b/apps/web/pages/availability/troubleshoot.tsx @@ -22,9 +22,9 @@ const AvailabilityView = ({ user }: { user: User }) => { function convertMinsToHrsMins(mins: number) { let h = Math.floor(mins / 60); let m = mins % 60; - h = h < 10 ? 0 + h : h; - m = m < 10 ? 0 + m : m; - return `${h}:${m}`; + let hs = h < 10 ? "0" + h : h; + let ms = m < 10 ? "0" + m : m; + return `${hs}:${ms}`; } useEffect(() => { diff --git a/apps/web/pages/cancel/[uid].tsx b/apps/web/pages/cancel/[uid].tsx index 3615bb1994..cdd8aec822 100644 --- a/apps/web/pages/cancel/[uid].tsx +++ b/apps/web/pages/cancel/[uid].tsx @@ -100,8 +100,10 @@ export default function Type(props: inferSSRProps) { className="mb-5 sm:mb-6" />
    + -
    )} diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index 37d190ae6c..0b83952dbd 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -159,7 +159,7 @@ const EventTypeList = ({ readOnly, types, profile }: EventTypeListProps): JSX.El
    {type.users?.length > 1 && ( ({ diff --git a/apps/web/pages/settings/profile.tsx b/apps/web/pages/settings/profile.tsx index daebfb1caa..1a9f4b1bfc 100644 --- a/apps/web/pages/settings/profile.tsx +++ b/apps/web/pages/settings/profile.tsx @@ -146,6 +146,11 @@ function SettingsView(props: ComponentProps & { localeProp: str { value: "light", label: t("light") }, { value: "dark", label: t("dark") }, ]; + + const timeFormatOptions = [ + { value: 12, label: t("12_hour") }, + { value: 24, label: t("24_hour") }, + ]; const usernameRef = useRef(null!); const nameRef = useRef(null!); const emailRef = useRef(null!); @@ -153,6 +158,10 @@ function SettingsView(props: ComponentProps & { localeProp: str const avatarRef = useRef(null!); const hideBrandingRef = useRef(null!); const [selectedTheme, setSelectedTheme] = useState(); + const [selectedTimeFormat, setSelectedTimeFormat] = useState({ + value: props.user.timeFormat || 12, + label: timeFormatOptions.find((option) => option.value === props.user.timeFormat)?.label || 12, + }); const [selectedTimeZone, setSelectedTimeZone] = useState(props.user.timeZone); const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: props.user.weekStart, @@ -189,6 +198,7 @@ function SettingsView(props: ComponentProps & { localeProp: str const enteredWeekStartDay = selectedWeekStartDay.value; const enteredHideBranding = hideBrandingRef.current.checked; const enteredLanguage = selectedLanguage.value; + const enteredTimeFormat = selectedTimeFormat.value; // TODO: Add validation @@ -204,6 +214,7 @@ function SettingsView(props: ComponentProps & { localeProp: str theme: asStringOrNull(selectedTheme?.value), brandColor: enteredBrandColor, locale: enteredLanguage, + timeFormat: enteredTimeFormat, }); } @@ -347,6 +358,21 @@ function SettingsView(props: ComponentProps & { localeProp: str />
    +
    + +
    +