Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
828ef8580e | ||
|
|
a348c3a20a | ||
|
|
3c68c7f7cb | ||
|
|
820b2da16d | ||
|
|
6bdb2b94eb | ||
|
|
3c3db0b3a6 | ||
|
|
4748641c22 | ||
|
|
804787f2a3 | ||
|
|
b7c3b31e0c | ||
|
|
ab6781cd72 | ||
|
|
b070cfb227 | ||
|
|
5acb3a09e5 | ||
|
|
69c9f690cb | ||
|
|
d2a12ea834 | ||
|
|
16f24b4831 | ||
|
|
a3eeeab259 | ||
|
|
f69c254fbc | ||
|
|
f0f6bd4846 | ||
|
|
8816a18854 | ||
|
|
4e529e5c6c |
@@ -2,7 +2,7 @@ import { CAL_URL } from "@calcom/lib/constants";
|
||||
|
||||
export default function getCalLink(eventType: {
|
||||
team?: { slug: string | null } | null;
|
||||
owner: { username: string | null } | null;
|
||||
owner?: { username: string | null } | null;
|
||||
users?: { username: string | null }[];
|
||||
slug: string;
|
||||
}) {
|
||||
|
||||
@@ -28,8 +28,8 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
|
||||
{emptyStore ? (
|
||||
<EmptyScreen
|
||||
Icon={AlertCircle}
|
||||
headline={t("no_apps")}
|
||||
description={isAdmin ? "You can enable apps in the settings" : ""}
|
||||
headline={isAdmin ? t("no_apps") : t("no_apps_configured")}
|
||||
description={isAdmin ? t("enable_in_settings") : t("please_contact_admin")}
|
||||
buttonText={isAdmin ? t("apps_settings") : ""}
|
||||
buttonOnClick={() => router.push("/settings/admin/apps/calendar")}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ const generateHashedLink = (id: number) => {
|
||||
|
||||
export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps, "eventType" | "team">) => {
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
const { data: user } = trpc.viewer.me.useQuery();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -49,6 +50,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
|
||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
||||
const selectedThemeIsDark =
|
||||
user?.theme === "dark" ||
|
||||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
|
||||
|
||||
eventType.bookingFields.forEach(({ name }) => {
|
||||
bookingFields[name] = name + " input";
|
||||
@@ -162,7 +166,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</div>
|
||||
<hr className="border-subtle [&:has(+div:empty)]:hidden" />
|
||||
<div>
|
||||
<BookerLayoutSelector fallbackToUserSettings />
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} />
|
||||
</div>
|
||||
<hr className="border-subtle" />
|
||||
<FormBuilder
|
||||
|
||||
+20
-23
@@ -1,10 +1,10 @@
|
||||
require("dotenv").config({ path: "../../.env" });
|
||||
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||
const os = require("os");
|
||||
const glob = require("glob");
|
||||
const englishTranslation = require("./public/static/locales/en/common.json");
|
||||
const { withAxiom } = require("next-axiom");
|
||||
const { i18n } = require("./next-i18next.config");
|
||||
const { pages } = require("./pages");
|
||||
|
||||
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
|
||||
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
|
||||
@@ -82,16 +82,19 @@ if (process.env.ANALYZE === "true") {
|
||||
|
||||
plugins.push(withAxiom);
|
||||
|
||||
/** Needed to rewrite public booking page, gets all static pages but [user] */
|
||||
const pages = glob
|
||||
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
|
||||
.map((filename) =>
|
||||
filename
|
||||
.substr(6)
|
||||
.replace(/(\.tsx|\.js|\.ts)/, "")
|
||||
.replace(/\/.*/, "")
|
||||
)
|
||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
|
||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
||||
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
||||
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
|
||||
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
|
||||
// book$ ensures that only /book is excluded from rewrite(which is at the end always) and not /booked
|
||||
|
||||
// Important Note: When modifying these RegExps update apps/web/test/lib/next-config.test.ts as well
|
||||
const userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
|
||||
const teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
|
||||
const privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
|
||||
const embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
|
||||
const embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
@@ -183,12 +186,6 @@ const nextConfig = {
|
||||
return config;
|
||||
},
|
||||
async rewrites() {
|
||||
// .* matches / as well(Note: *(i.e wildcard) doesn't match / but .*(i.e. RegExp) does)
|
||||
// It would match /free/30min but not /bookings/upcoming because 'bookings' is an item in pages
|
||||
// It would also not match /free/30min/embed because we are ensuring just two slashes
|
||||
// ?!book ensures it doesn't match /free/book page which doesn't have a corresponding new-booker page.
|
||||
// [^/]+ makes the RegExp match the full path, it seems like a partial match doesn't work.
|
||||
const userTypeRouteRegExp = `/:user((?!${pages.join("|")})[^/]*)/:type((?!book)[^/]+)`;
|
||||
let rewrites = [
|
||||
{
|
||||
source: "/org/:slug",
|
||||
@@ -233,12 +230,12 @@ const nextConfig = {
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: "/team/:slug/:type",
|
||||
source: teamTypeRouteRegExp,
|
||||
destination: "/new-booker/team/:slug/:type",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: "/d/:link/:slug",
|
||||
source: privateLinkRouteRegExp,
|
||||
destination: "/new-booker/d/:link/:slug",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
@@ -246,12 +243,12 @@ const nextConfig = {
|
||||
// Keep cookie based booker enabled to test new-booker embed in production
|
||||
...[
|
||||
{
|
||||
source: `/:user((?!${pages.join("|")}).*)/:type/embed`,
|
||||
source: embedUserTypeRouteRegExp,
|
||||
destination: "/new-booker/:user/:type/embed",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
{
|
||||
source: "/team/:slug/:type/embed",
|
||||
source: embedTeamTypeRouteRegExp,
|
||||
destination: "/new-booker/team/:slug/:type/embed",
|
||||
has: [{ type: "cookie", key: "new-booker-enabled" }],
|
||||
},
|
||||
@@ -293,11 +290,11 @@ const nextConfig = {
|
||||
destination: "/new-booker/:user/:type",
|
||||
},
|
||||
{
|
||||
source: "/team/:slug/:type",
|
||||
source: teamTypeRouteRegExp,
|
||||
destination: "/new-booker/team/:slug/:type",
|
||||
},
|
||||
{
|
||||
source: "/d/:link/:slug",
|
||||
source: privateLinkRouteRegExp,
|
||||
destination: "/new-booker/d/:link/:slug",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.0.3",
|
||||
"version": "3.0.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const glob = require("glob");
|
||||
|
||||
/** Needed to rewrite public booking page, gets all static pages but [user] */
|
||||
const pages = glob
|
||||
.sync("pages/**/[^_]*.{tsx,js,ts}", { cwd: __dirname })
|
||||
.map((filename) =>
|
||||
filename
|
||||
.substr(6)
|
||||
.replace(/(\.tsx|\.js|\.ts)/, "")
|
||||
.replace(/\/.*/, "")
|
||||
)
|
||||
.filter((v, i, self) => self.indexOf(v) === i && !v.startsWith("[user]"));
|
||||
|
||||
exports.pages = pages;
|
||||
@@ -13,7 +13,8 @@ import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import { OrganizationEventTypeFilter } from "@calcom/features/eventtypes/components/OrganizationEventTypeFilter";
|
||||
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -47,7 +48,6 @@ import {
|
||||
HeadSeo,
|
||||
Skeleton,
|
||||
Label,
|
||||
VerticalDivider,
|
||||
Alert,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
@@ -420,7 +420,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
truncateAfter={4}
|
||||
items={type.children
|
||||
.flatMap((ch) => ch.users)
|
||||
.map((user: User) => ({
|
||||
.map((user: Pick<User, "name" | "username">) => ({
|
||||
alt: user.name || "",
|
||||
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
|
||||
title: user.name || "",
|
||||
@@ -800,8 +800,7 @@ const CTA = () => {
|
||||
const Actions = () => {
|
||||
return (
|
||||
<div className="hidden items-center md:flex">
|
||||
<OrganizationEventTypeFilter />
|
||||
<VerticalDivider />
|
||||
<TeamsFilter popoverTriggerClassNames="mb-0" showVerticalDivider={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -834,9 +833,6 @@ const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any);
|
||||
|
||||
const EventTypesPage = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -862,6 +858,10 @@ const EventTypesPage = () => {
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const filters = getTeamsFiltersFromQuery(router.query);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer as any, { filters });
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -879,47 +879,55 @@ const EventTypesPage = () => {
|
||||
CTA={<CTA />}>
|
||||
<WithQuery
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => (
|
||||
<>
|
||||
{data.eventTypeGroups.length > 1 ? (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||
) : (
|
||||
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
|
||||
<div className="flex flex-col" key={group.profile.slug}>
|
||||
<EventTypeListHeading
|
||||
profile={group.profile}
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
teamId={group.teamId}
|
||||
orgSlug={orgBranding?.slug}
|
||||
/>
|
||||
success={({ data }) => {
|
||||
const isFilteredByOnlyOneItem =
|
||||
(filters?.teamIds?.length === 1 || filters?.userIds?.length === 1) &&
|
||||
data.eventTypeGroups.length === 1;
|
||||
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : data.eventTypeGroups.length === 1 ? (
|
||||
<EventTypeList
|
||||
types={data.eventTypeGroups[0].eventTypes}
|
||||
group={data.eventTypeGroups[0]}
|
||||
groupIndex={0}
|
||||
readOnly={data.eventTypeGroups[0].metadata.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<CreateFirstEventTypeView />
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
{data.eventTypeGroups.length > 1 || isFilteredByOnlyOneItem ? (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<MobileTeamsTab eventTypeGroups={data.eventTypeGroups} />
|
||||
) : (
|
||||
data.eventTypeGroups.map((group: EventTypeGroup, index: number) => (
|
||||
<div className="flex flex-col" key={group.profile.slug}>
|
||||
<EventTypeListHeading
|
||||
profile={group.profile}
|
||||
membershipCount={group.metadata.membershipCount}
|
||||
teamId={group.teamId}
|
||||
orgSlug={orgBranding?.slug}
|
||||
/>
|
||||
|
||||
<EmbedDialog />
|
||||
{router.query.dialog === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
)}
|
||||
<EventTypeList
|
||||
types={group.eventTypes}
|
||||
group={group}
|
||||
groupIndex={index}
|
||||
readOnly={group.metadata.readOnly}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
data.eventTypeGroups.length === 1 && (
|
||||
<EventTypeList
|
||||
types={data.eventTypeGroups[0].eventTypes}
|
||||
group={data.eventTypeGroups[0]}
|
||||
groupIndex={0}
|
||||
readOnly={data.eventTypeGroups[0].metadata.readOnly}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{data.eventTypeGroups.length === 0 && <CreateFirstEventTypeView />}
|
||||
|
||||
<EmbedDialog />
|
||||
{router.query.dialog === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Shell>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden }: PageProps) {
|
||||
export default function Type({ slug, user, booking, away, isBrandingHidden, isTeamEvent }: PageProps) {
|
||||
return (
|
||||
<main className="flex h-full min-h-[100dvh] items-center justify-center">
|
||||
<BookerSeo
|
||||
@@ -29,6 +29,7 @@ export default function Type({ slug, user, booking, away, isBrandingHidden }: Pa
|
||||
rescheduleBooking={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent={isTeamEvent}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -57,6 +58,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,9 +102,11 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
booking = await getBookingByUidOrRescheduleUid(`${rescheduleUid}`);
|
||||
}
|
||||
|
||||
const isTeamEvent = !!hashedLink.eventType?.team?.id;
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug });
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent });
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
@@ -114,6 +122,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
// Sending the team event from the server, because this template file
|
||||
// is reused for both team and user events.
|
||||
isTeamEvent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function Type({ slug, user, booking, away, isBrandingHidden }: Pa
|
||||
rescheduleBooking={booking}
|
||||
isAway={away}
|
||||
hideBranding={isBrandingHidden}
|
||||
isTeamEvent
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
@@ -71,7 +72,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({ username: teamSlug, eventSlug: meetingSlug });
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
|
||||
@@ -70,6 +70,13 @@ const AppearanceView = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTheme = formMethods.watch("theme");
|
||||
const selectedThemeIsDark =
|
||||
selectedTheme === "dark" ||
|
||||
(selectedTheme === "" &&
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark"));
|
||||
|
||||
const {
|
||||
formState: { isSubmitting, isDirty },
|
||||
reset,
|
||||
@@ -144,6 +151,7 @@ const AppearanceView = () => {
|
||||
|
||||
<hr className="border-subtle my-8 border [&:has(+hr)]:hidden" />
|
||||
<BookerLayoutSelector
|
||||
isDark={selectedThemeIsDark}
|
||||
name="metadata.defaultBookerLayouts"
|
||||
title={t("bookerlayout_user_settings_title")}
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 18 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 24 KiB |
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "يجب إدخال عنوان URL للفريق",
|
||||
"team_url_taken": "عنوان URL هذا مأخوذ بالفعل",
|
||||
"team_publish": "نشر فريق",
|
||||
"number_sms_notifications": "رقم الهاتف (إشعارات الرسائل النصية)",
|
||||
"attendee_email_variable": "اسم الحاضر",
|
||||
"attendee_email_info": "البريد الإلكتروني للشخص الحجز",
|
||||
"kbar_search_placeholder": "اكتب أمرًا أو بحثًا...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Musíte zadat adresu URL týmu",
|
||||
"team_url_taken": "Tato adresa URL je už obsazená",
|
||||
"team_publish": "Zveřejnit tým",
|
||||
"number_sms_notifications": "Číslo telefonu (SMS oznámení)",
|
||||
"attendee_email_variable": "E-mail účastníka",
|
||||
"attendee_email_info": "E-mail osoby provádějící rezervaci",
|
||||
"kbar_search_placeholder": "Zadejte příkaz nebo vyhledejte...",
|
||||
|
||||
@@ -1344,7 +1344,6 @@
|
||||
"team_url_required": "Der skal indtastes et team URL",
|
||||
"team_url_taken": "Denne URL er allerede i brug",
|
||||
"team_publish": "Publicer team",
|
||||
"number_sms_notifications": "Telefonnummer (SMS notifikationer)",
|
||||
"attendee_email_variable": "Deltagers e-mail",
|
||||
"attendee_email_info": "Personen der booker's e-mail",
|
||||
"kbar_search_placeholder": "Indtast en kommando eller søgning...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Team-URL muss eingegeben werden",
|
||||
"team_url_taken": "Diese URL ist bereits vergeben",
|
||||
"team_publish": "Team veröffentlichen",
|
||||
"number_sms_notifications": "Telefonnummer (SMS-Benachrichtigungen)",
|
||||
"attendee_email_variable": "Teilnehmer E-Mail",
|
||||
"attendee_email_info": "Die E-Mail-Adresse der buchenden Person",
|
||||
"kbar_search_placeholder": "Geben Sie einen Befehl ein oder suchen Sie ...",
|
||||
|
||||
@@ -1466,7 +1466,7 @@
|
||||
"team_url_required": "Must enter a team URL",
|
||||
"team_url_taken": "This URL is already taken",
|
||||
"team_publish": "Publish team",
|
||||
"number_sms_notifications": "Phone number (SMS notifications)",
|
||||
"number_sms_notifications": "Phone number (SMS notifications)",
|
||||
"attendee_email_variable": "Attendee email",
|
||||
"attendee_email_info": "The person booking's email",
|
||||
"kbar_search_placeholder": "Type a command or search...",
|
||||
@@ -1523,6 +1523,9 @@
|
||||
"no_available_apps": "There are no available apps",
|
||||
"no_available_apps_description": "Please ensure there are apps in your deployment under 'packages/app-store'",
|
||||
"no_apps": "There are no apps enabled in this instance of Cal",
|
||||
"no_apps_configured": "No app has been configured yet",
|
||||
"enable_in_settings": "You can enable apps in the settings",
|
||||
"please_contact_admin": "Please contact your admin",
|
||||
"apps_settings": "Apps settings",
|
||||
"fill_this_field": "Please fill in this field",
|
||||
"options": "Options",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Debe introducir una URL del equipo",
|
||||
"team_url_taken": "Esta URL ya está en uso",
|
||||
"team_publish": "Publicar equipo",
|
||||
"number_sms_notifications": "Número de teléfono (notificaciones por SMS)",
|
||||
"attendee_email_variable": "Correo electrónico del asistente",
|
||||
"attendee_email_info": "El correo electrónico de la persona que reserva",
|
||||
"kbar_search_placeholder": "Escriba un comando o búsqueda...",
|
||||
|
||||
@@ -1523,6 +1523,9 @@
|
||||
"no_available_apps": "Il n'y a aucune application disponible",
|
||||
"no_available_apps_description": "Veuillez vous assurer qu'il y a des applications dans votre déploiement sous \"packages/app-store\"",
|
||||
"no_apps": "Aucune application n'est activée dans cette instance de Cal",
|
||||
"no_apps_configured": "Aucune application n'a encore été configurée",
|
||||
"enable_in_settings": "Vous pouvez activer les applications dans les paramètres",
|
||||
"please_contact_admin": "Veuillez contacter votre administrateur",
|
||||
"apps_settings": "Paramètres des applications",
|
||||
"fill_this_field": "Veuillez remplir ce champ",
|
||||
"options": "Options",
|
||||
|
||||
@@ -1421,7 +1421,6 @@
|
||||
"team_url_required": "יש להזין כתובת URL של הצוות",
|
||||
"team_url_taken": "כתובת ה-URL הזו כבר תפוסה",
|
||||
"team_publish": "פרסום צוות",
|
||||
"number_sms_notifications": "מספר טלפון (התראות SMS)",
|
||||
"attendee_email_variable": "כתובת הדוא\"ל של המשתתף",
|
||||
"attendee_email_info": "כתובת הדוא\"ל של האדם שביצע את ההזמנה",
|
||||
"kbar_search_placeholder": "הקלד/י פקודה או חפש/י...",
|
||||
|
||||
@@ -1438,7 +1438,6 @@
|
||||
"team_url_required": "Immettere un URL per il team",
|
||||
"team_url_taken": "URL già occupato",
|
||||
"team_publish": "Pubblica team",
|
||||
"number_sms_notifications": "Numero di telefono (notifiche SMS)",
|
||||
"attendee_email_variable": "E-mail partecipante",
|
||||
"attendee_email_info": "E-mail della persona che prenota",
|
||||
"kbar_search_placeholder": "Digita un comando o esegui una ricerca...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "チームの URL の入力は必須です",
|
||||
"team_url_taken": "この URL は既に使用されています",
|
||||
"team_publish": "チームを公開",
|
||||
"number_sms_notifications": "電話番号(SMS 通知)",
|
||||
"attendee_email_variable": "出席者のメールアドレス",
|
||||
"attendee_email_info": "予約者のメールアドレス",
|
||||
"kbar_search_placeholder": "コマンドを入力するか、検索してください...",
|
||||
|
||||
@@ -1409,7 +1409,6 @@
|
||||
"team_url_required": "팀 URL을 입력하세요",
|
||||
"team_url_taken": "이미 사용 중인 URL입니다",
|
||||
"team_publish": "팀 게시",
|
||||
"number_sms_notifications": "전화번호 (SMS notifications)",
|
||||
"attendee_email_variable": "참석자 이메일",
|
||||
"attendee_email_info": "예약자 이메일",
|
||||
"kbar_search_placeholder": "명령어를 입력하거나 검색하세요...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Moet een team-URL invoeren",
|
||||
"team_url_taken": "Deze URL is al in gebruik",
|
||||
"team_publish": "Team publiceren",
|
||||
"number_sms_notifications": "Telefoonnummer (sms-meldingen)",
|
||||
"attendee_email_variable": "E-mailadres deelnemer",
|
||||
"attendee_email_info": "Het e-mailadres van de persoon die boekt",
|
||||
"kbar_search_placeholder": "Typ een opdracht of zoek...",
|
||||
|
||||
@@ -1318,7 +1318,6 @@
|
||||
"team_url_required": "Må angi en team URL",
|
||||
"team_url_taken": "Denne URL'en er allerede tatt",
|
||||
"team_publish": "Publiser team",
|
||||
"number_sms_notifications": "Telefonnummer (SMS varsler)",
|
||||
"attendee_email_variable": "Deltakerens e-post",
|
||||
"attendee_email_info": "Den som booker sin e-post",
|
||||
"kbar_search_placeholder": "Skriv inn en kommando eller søk...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Musisz wprowadzić adres URL zespołu",
|
||||
"team_url_taken": "Ten adres URL jest już zajęty",
|
||||
"team_publish": "Opublikuj zespół",
|
||||
"number_sms_notifications": "Numer telefonu (powiadomienia SMS)",
|
||||
"attendee_email_variable": "Adres e-mail uczestnika",
|
||||
"attendee_email_info": "Adres e-mail osoby rezerwującej",
|
||||
"kbar_search_placeholder": "Wpisz polecenie lub wyszukaj...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "É preciso inserir uma URL de equipe",
|
||||
"team_url_taken": "Este URL já está em uso",
|
||||
"team_publish": "Publicar equipe",
|
||||
"number_sms_notifications": "Número de telefone (notificações de SMS)",
|
||||
"attendee_email_variable": "E-mail do participante",
|
||||
"attendee_email_info": "O e-mail da pessoa que fez a reserva",
|
||||
"kbar_search_placeholder": "Digite um comando ou procure...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Deve especificar um endereço para a equipa",
|
||||
"team_url_taken": "Este endereço já está a ser utilizado",
|
||||
"team_publish": "Publicar equipa",
|
||||
"number_sms_notifications": "Número de telefone (notificações SMS)",
|
||||
"attendee_email_variable": "E-mail do participante",
|
||||
"attendee_email_info": "O e-mail do responsável pela reserva",
|
||||
"kbar_search_placeholder": "Digite um comando ou pesquise...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Trebuie să introduceți un URL de echipă",
|
||||
"team_url_taken": "Acest URL este folosit deja",
|
||||
"team_publish": "Publicați echipa",
|
||||
"number_sms_notifications": "Număr de telefon (notificări SMS)",
|
||||
"attendee_email_variable": "E-mail participant",
|
||||
"attendee_email_info": "E-mailul persoanei care efectuează rezervarea",
|
||||
"kbar_search_placeholder": "Scrie o comandă sau caută...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Необходимо ввести URL-адрес команды",
|
||||
"team_url_taken": "Этот URL-адрес уже занят",
|
||||
"team_publish": "Опубликовать команду",
|
||||
"number_sms_notifications": "Номер телефона (SMS-уведомления)",
|
||||
"attendee_email_variable": "Электронная почта участника",
|
||||
"attendee_email_info": "Электронная почта участника, на которого оформляется бронирование",
|
||||
"kbar_search_placeholder": "Введите команду или начните поиск...",
|
||||
|
||||
@@ -1435,7 +1435,6 @@
|
||||
"team_url_required": "Morate da unesete URL tima",
|
||||
"team_url_taken": "Ovaj URL je zauzet",
|
||||
"team_publish": "Objavite tim",
|
||||
"number_sms_notifications": "Broj telefona (obaveštenja putem SMS-a)",
|
||||
"attendee_email_variable": "Imejl polaznika",
|
||||
"attendee_email_info": "Imejl osobe koja rezerviše",
|
||||
"kbar_search_placeholder": "Unesite komandu ili pretražite...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Team-URL måste anges",
|
||||
"team_url_taken": "Denna URL används redan",
|
||||
"team_publish": "Publicera team",
|
||||
"number_sms_notifications": "Telefonnummer (SMS-notiser)",
|
||||
"attendee_email_variable": "Deltagarens e-postadress",
|
||||
"attendee_email_info": "Personens e-postadress för bokning",
|
||||
"kbar_search_placeholder": "Ange ett kommando eller sök ...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Ekip URL'si girilmelidir",
|
||||
"team_url_taken": "Bu URL zaten kullanılıyor",
|
||||
"team_publish": "Ekibi yayınla",
|
||||
"number_sms_notifications": "Telefon numarası (SMS bildirimleri)",
|
||||
"attendee_email_variable": "Katılımcı e-postası",
|
||||
"attendee_email_info": "Rezervasyon yaptıran kişinin e-postası",
|
||||
"kbar_search_placeholder": "Komut girin veya arayın...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Потрібно ввести URL-адресу команди",
|
||||
"team_url_taken": "Цю URL-адресу вже зайнято",
|
||||
"team_publish": "Опублікувати команду",
|
||||
"number_sms_notifications": "Номер телефону (SMS-сповіщення)",
|
||||
"attendee_email_variable": "Адреса ел. пошти учасника",
|
||||
"attendee_email_info": "Адреса ел. пошти особи, яка бронює",
|
||||
"kbar_search_placeholder": "Введіть команду або пошуковий запит…",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "Cần phải nhập một URL của nhóm",
|
||||
"team_url_taken": "URL này đã có người lấy rồi",
|
||||
"team_publish": "Đăng nhóm",
|
||||
"number_sms_notifications": "Số điện thoại (thông báo SMS)",
|
||||
"attendee_email_variable": "Email người tham dự",
|
||||
"attendee_email_info": "Email người tham gia lịch hẹn",
|
||||
"kbar_search_placeholder": "Nhập một lệnh hoặc tìm kiếm...",
|
||||
|
||||
@@ -1421,7 +1421,6 @@
|
||||
"team_url_required": "必须输入团队链接",
|
||||
"team_url_taken": "此链接已被占用",
|
||||
"team_publish": "发布团队",
|
||||
"number_sms_notifications": "电话号码(短信通知)",
|
||||
"attendee_email_variable": "参与者电子邮件",
|
||||
"attendee_email_info": "预约人的电子邮件",
|
||||
"kbar_search_placeholder": "输入命令或搜索...",
|
||||
|
||||
@@ -1408,7 +1408,6 @@
|
||||
"team_url_required": "團隊網址為必填",
|
||||
"team_url_taken": "已有人使用此網址",
|
||||
"team_publish": "發佈團隊",
|
||||
"number_sms_notifications": "電話號碼 (簡訊通知)",
|
||||
"attendee_email_variable": "與會者電子郵件",
|
||||
"attendee_email_info": "預約人電子郵件",
|
||||
"kbar_search_placeholder": "輸入指令或搜尋…",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
|
||||
import { it, expect, describe, beforeAll, afterAll } from "vitest";
|
||||
let userTypeRouteRegExp: RegExp;
|
||||
let teamTypeRouteRegExp:RegExp;
|
||||
let privateLinkRouteRegExp:RegExp
|
||||
let embedUserTypeRouteRegExp:RegExp
|
||||
let embedTeamTypeRouteRegExp:RegExp
|
||||
|
||||
|
||||
const getRegExpFromNextJsRewriteRegExp = (nextJsRegExp:string) => {
|
||||
// const parts = nextJsRegExp.split(':');
|
||||
|
||||
// const validNamedGroupRegExp = parts.map((part, index)=>{
|
||||
// if (index === 0) {
|
||||
// return part;
|
||||
// }
|
||||
// if (part.match(/^[a-zA-Z0-9]+$/)) {
|
||||
// return `(?<${part}>[^/]+)`
|
||||
// }
|
||||
// part = part.replace(new RegExp('([^(]+)(.*)'), '(?<$1>$2)');
|
||||
// return part
|
||||
// }).join('');
|
||||
|
||||
// TODO: If we can easily convert the exported rewrite regexes from next.config.js to a valid named capturing group regex, it would be best
|
||||
// Next.js does an exact match as per my testing.
|
||||
return new RegExp(`^${nextJsRegExp}$`)
|
||||
}
|
||||
|
||||
describe('next.config.js - RegExp', ()=>{
|
||||
beforeAll(async()=>{
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = 1
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pages = require("../../pages").pages
|
||||
|
||||
// How to convert a Next.js rewrite RegExp/wildcard to a valid JS named capturing Group RegExp?
|
||||
// - /:user/ -> (?<user>[^/]+)
|
||||
// - /:user(?!404)[^/]+/ -> (?<user>((?!404)[^/]+))
|
||||
|
||||
// userTypeRouteRegExp = `/:user((?!${pages.join("/|")})[^/]*)/:type((?!book$)[^/]+)`;
|
||||
userTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("/|")})[^/]*))/(?<type>((?!book$)[^/]+))`);
|
||||
|
||||
// teamTypeRouteRegExp = "/team/:slug/:type((?!book$)[^/]+)";
|
||||
teamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>((?!book$)[^/]+))");
|
||||
|
||||
// privateLinkRouteRegExp = "/d/:link/:slug((?!book$)[^/]+)";
|
||||
privateLinkRouteRegExp = getRegExpFromNextJsRewriteRegExp("/d/(?<link>[^/]+)/(?<slug>((?!book$)[^/]+))");
|
||||
|
||||
// embedUserTypeRouteRegExp = `/:user((?!${pages.join("|")}).*)/:type/embed`;
|
||||
embedUserTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp(`/(?<user>((?!${pages.join("|")}).*))/(?<type>[^/]+)/embed`);
|
||||
|
||||
// embedTeamTypeRouteRegExp = "/team/:slug/:type/embed";
|
||||
embedTeamTypeRouteRegExp = getRegExpFromNextJsRewriteRegExp("/team/(?<slug>[^/]+)/(?<type>[^/]+)/embed");
|
||||
});
|
||||
|
||||
afterAll(()=>{
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_URL = process.env.CALENDSO_ENCRYPTION_KEY = undefined
|
||||
})
|
||||
|
||||
it("Booking Urls", async () => {
|
||||
expect(userTypeRouteRegExp.exec('/free/30')?.groups).toContain({
|
||||
user: 'free',
|
||||
type: '30'
|
||||
})
|
||||
|
||||
// Edgecase of username starting with team also works
|
||||
expect(userTypeRouteRegExp.exec('/teampro/30')?.groups).toContain({
|
||||
user: 'teampro',
|
||||
type: '30'
|
||||
})
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/teampro+pro/30')?.groups).toContain({
|
||||
user: 'teampro+pro',
|
||||
type: '30'
|
||||
})
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/teampro+pro/book')).toEqual(null)
|
||||
|
||||
// Because /book doesn't have a corresponding new-booker route.
|
||||
expect(userTypeRouteRegExp.exec('/free/book')).toEqual(null)
|
||||
|
||||
// Because /booked is a normal event name
|
||||
expect(userTypeRouteRegExp.exec('/free/booked')?.groups).toEqual({
|
||||
user: 'free',
|
||||
type: 'booked'
|
||||
})
|
||||
|
||||
|
||||
expect(embedUserTypeRouteRegExp.exec('/free/30/embed')?.groups).toContain({
|
||||
user: 'free',
|
||||
type:'30'
|
||||
})
|
||||
|
||||
expect(teamTypeRouteRegExp.exec('/team/seeded/30')?.groups).toContain({
|
||||
slug: 'seeded',
|
||||
type: '30'
|
||||
})
|
||||
|
||||
// Because /book doesn't have a corresponding new-booker route.
|
||||
expect(teamTypeRouteRegExp.exec('/team/seeded/book')).toEqual(null)
|
||||
|
||||
expect(teamTypeRouteRegExp.exec('/team/seeded/30/embed')).toEqual(null)
|
||||
|
||||
expect(embedTeamTypeRouteRegExp.exec('/team/seeded/30/embed')?.groups).toContain({
|
||||
slug: 'seeded',
|
||||
type:'30'
|
||||
})
|
||||
|
||||
expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({
|
||||
link: '3v4s321CXRJZx5TFxkpPvd',
|
||||
slug: '30min'
|
||||
})
|
||||
|
||||
expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/30min')?.groups).toContain({
|
||||
link: '3v4s321CXRJZx5TFxkpPvd',
|
||||
slug: '30min'
|
||||
})
|
||||
|
||||
// Because /book doesn't have a corresponding new-booker route.
|
||||
expect(privateLinkRouteRegExp.exec('/d/3v4s321CXRJZx5TFxkpPvd/book')).toEqual(null)
|
||||
});
|
||||
|
||||
it('Non booking Urls', ()=>{
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/404')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/404')).toEqual(null)
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/404/30')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/404/30')).toEqual(null)
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/api')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/api')).toEqual(null)
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/api/30')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/api/30')).toEqual(null)
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/workflows/30')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/workflows/30')).toEqual(null)
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/event-types/30')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/event-types/30')).toEqual(null)
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/teams/1')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/teams/1')).toEqual(null)
|
||||
|
||||
expect(userTypeRouteRegExp.exec('/teams')).toEqual(null)
|
||||
expect(teamTypeRouteRegExp.exec('/teams')).toEqual(null)
|
||||
|
||||
// Note that even though it matches /embed/embed.js, but it's served from /public and the regexes are in afterEach, it won't hit the flow.
|
||||
// expect(userTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null)
|
||||
// expect(teamTypeRouteRegExp.exec('/embed/embed.js')).toEqual(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useMemo } from "react";
|
||||
import StickyBox from "react-sticky-box";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useEmbedUiConfig } from "@calcom/embed-core/embed-iframe";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
|
||||
@@ -35,6 +36,7 @@ const BookerComponent = ({
|
||||
month,
|
||||
rescheduleBooking,
|
||||
hideBranding = false,
|
||||
isTeamEvent,
|
||||
}: BookerProps) => {
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
const isTablet = useMediaQuery("(max-width: 1024px)");
|
||||
@@ -80,6 +82,7 @@ const BookerComponent = ({
|
||||
rescheduleUid,
|
||||
rescheduleBooking,
|
||||
layout: defaultLayout,
|
||||
isTeamEvent,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,6 +106,9 @@ const BookerComponent = ({
|
||||
}
|
||||
}, [layout]);
|
||||
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
|
||||
|
||||
if (event.isSuccess && !event.data) {
|
||||
return <NotFound />;
|
||||
}
|
||||
@@ -114,7 +120,7 @@ const BookerComponent = ({
|
||||
ref={animationScope}
|
||||
className={classNames(
|
||||
// Sets booker size css variables for the size of all the columns.
|
||||
...getBookerSizeClassNames(layout, bookerState),
|
||||
...getBookerSizeClassNames(layout, bookerState, hideEventTypeDetails),
|
||||
"bg-default dark:bg-muted grid max-w-full items-start dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row",
|
||||
layout === BookerLayouts.MONTH_VIEW && "border-subtle rounded-md border",
|
||||
!isEmbed && "sm:transition-[width] sm:duration-300",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { m } from "framer-motion";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { EventDetails, EventMembers, EventMetaSkeleton, EventTitle } from "@calcom/features/bookings";
|
||||
import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/Details";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
@@ -25,6 +26,13 @@ export const EventMeta = () => {
|
||||
const rescheduleBooking = useBookerStore((state) => state.rescheduleBooking);
|
||||
const { i18n, t } = useLocale();
|
||||
const { data: event, isLoading } = useEvent();
|
||||
const embedUiConfig = useEmbedUiConfig();
|
||||
const isEmbed = useIsEmbed();
|
||||
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
|
||||
|
||||
if (hideEventTypeDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-10 p-6">
|
||||
|
||||
@@ -112,25 +112,38 @@ export const resizeAnimationConfig: ResizeAnimationConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export const getBookerSizeClassNames = (layout: BookerLayout, bookerState: BookerState) => {
|
||||
export const getBookerSizeClassNames = (
|
||||
layout: BookerLayout,
|
||||
bookerState: BookerState,
|
||||
hideEventTypeDetails = false
|
||||
) => {
|
||||
const getBookerMetaClass = (className: string) => {
|
||||
if (hideEventTypeDetails) {
|
||||
return "";
|
||||
}
|
||||
return className;
|
||||
};
|
||||
|
||||
return [
|
||||
// Size settings are abstracted on their own lines purely for readbility.
|
||||
// Size settings are abstracted on their own lines purely for readability.
|
||||
// General sizes, used always
|
||||
"[--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px]",
|
||||
// Small calendar defaults
|
||||
layout === BookerLayouts.MONTH_VIEW && "[--booker-meta-width:240px]",
|
||||
layout === BookerLayouts.MONTH_VIEW && getBookerMetaClass("[--booker-meta-width:240px]"),
|
||||
// Meta column get's wider in booking view to fit the full date on a single row in case
|
||||
// of a multi occurance event. Also makes form less wide, which also looks better.
|
||||
layout === BookerLayouts.MONTH_VIEW &&
|
||||
bookerState === "booking" &&
|
||||
"[--booker-main-width:420px] lg:[--booker-meta-width:340px]",
|
||||
`[--booker-main-width:420px] ${getBookerMetaClass("lg:[--booker-meta-width:340px]")}`,
|
||||
// Smaller meta when not in booking view.
|
||||
layout === BookerLayouts.MONTH_VIEW &&
|
||||
bookerState !== "booking" &&
|
||||
"[--booker-main-width:480px] lg:[--booker-meta-width:280px]",
|
||||
`[--booker-main-width:480px] ${getBookerMetaClass("lg:[--booker-meta-width:280px]")}`,
|
||||
// Fullscreen view settings.
|
||||
layout !== BookerLayouts.MONTH_VIEW &&
|
||||
"[--booker-main-width:480px] [--booker-meta-width:340px] lg:[--booker-meta-width:424px]",
|
||||
`[--booker-main-width:480px] [--booker-meta-width:340px] ${getBookerMetaClass(
|
||||
"lg:[--booker-meta-width:424px]"
|
||||
)}`,
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type StoreInitializeType = {
|
||||
rescheduleUid: string | null;
|
||||
rescheduleBooking: GetBookingType | null | undefined;
|
||||
layout: BookerLayout;
|
||||
isTeamEvent?: boolean;
|
||||
};
|
||||
|
||||
export type BookerStore = {
|
||||
@@ -89,6 +90,12 @@ export type BookerStore = {
|
||||
*/
|
||||
formValues: Record<string, any>;
|
||||
setFormValues: (values: Record<string, any>) => void;
|
||||
/**
|
||||
* Force event being a team event, so we only query for team events instead
|
||||
* of also include 'user' events and return the first event that matches with
|
||||
* both the slug and the event slug.
|
||||
*/
|
||||
isTeamEvent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,6 +144,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||
updateQueryParam("month", month ?? "");
|
||||
get().setSelectedDate(null);
|
||||
},
|
||||
isTeamEvent: false,
|
||||
initialize: ({
|
||||
username,
|
||||
eventSlug,
|
||||
@@ -145,6 +153,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||
rescheduleUid = null,
|
||||
rescheduleBooking = null,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
}: StoreInitializeType) => {
|
||||
const selectedDateInStore = get().selectedDate;
|
||||
|
||||
@@ -165,6 +174,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||
rescheduleUid,
|
||||
rescheduleBooking,
|
||||
layout: layout || BookerLayouts.MONTH_VIEW,
|
||||
isTeamEvent: isTeamEvent || false,
|
||||
// Preselect today's date in week / column view, since they use this to show the week title.
|
||||
selectedDate:
|
||||
selectedDateInStore ||
|
||||
@@ -207,9 +217,29 @@ export const useInitializeBookerStore = ({
|
||||
rescheduleUid = null,
|
||||
rescheduleBooking = null,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
}: StoreInitializeType) => {
|
||||
const initializeStore = useBookerStore((state) => state.initialize);
|
||||
useEffect(() => {
|
||||
initializeStore({ username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking, layout });
|
||||
}, [initializeStore, username, eventSlug, month, eventId, rescheduleUid, rescheduleBooking, layout]);
|
||||
initializeStore({
|
||||
username,
|
||||
eventSlug,
|
||||
month,
|
||||
eventId,
|
||||
rescheduleUid,
|
||||
rescheduleBooking,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
});
|
||||
}, [
|
||||
initializeStore,
|
||||
username,
|
||||
eventSlug,
|
||||
month,
|
||||
eventId,
|
||||
rescheduleUid,
|
||||
rescheduleBooking,
|
||||
layout,
|
||||
isTeamEvent,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface BookerProps {
|
||||
* within the atom (i.e. without a server side component).
|
||||
*/
|
||||
rescheduleBooking?: GetBookingType;
|
||||
/**
|
||||
* If this boolean is passed, we will only check team events with this slug and event slug.
|
||||
* If it's not passed, we will first query a generic user event, and only if that doesn't exist
|
||||
* fetch the team event. In case there's both a team + user with the same slug AND same event slug,
|
||||
* that will always result in the user event being returned.
|
||||
*/
|
||||
isTeamEvent?: boolean;
|
||||
}
|
||||
|
||||
export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
|
||||
|
||||
@@ -16,9 +16,10 @@ import { useBookerStore } from "../store";
|
||||
*/
|
||||
export const useEvent = () => {
|
||||
const [username, eventSlug] = useBookerStore((state) => [state.username, state.eventSlug], shallow);
|
||||
const isTeamEvent = useBookerStore((state) => state.isTeamEvent);
|
||||
|
||||
return trpc.viewer.public.event.useQuery(
|
||||
{ username: username ?? "", eventSlug: eventSlug ?? "" },
|
||||
{ username: username ?? "", eventSlug: eventSlug ?? "", isTeamEvent },
|
||||
{ refetchOnWindowFocus: false, enabled: Boolean(username) && Boolean(eventSlug) }
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Session } from "next-auth";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import {
|
||||
parseTeamId,
|
||||
checkSelfImpersonation,
|
||||
checkUserIdentifier,
|
||||
checkPermission,
|
||||
} from "./ImpersonationProvider";
|
||||
|
||||
const session: Session = {
|
||||
expires: "2021-08-31T15:00:00.000Z",
|
||||
hasValidLicense: true,
|
||||
user: {
|
||||
id: 123,
|
||||
username: "test",
|
||||
role: UserPermissionRole.USER,
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
describe("parseTeamId", () => {
|
||||
it("should return undefined if no teamId is provided", () => {
|
||||
expect(parseTeamId(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return the parsed teamId if a teamId is provided", () => {
|
||||
expect(parseTeamId({ username: "test", teamId: "123" })).toBe(123);
|
||||
});
|
||||
|
||||
it("should throw an error if the provided teamId is not a positive number", () => {
|
||||
expect(() => parseTeamId({ username: "test", teamId: "-123" })).toThrow();
|
||||
});
|
||||
it("should throw an error if the provided teamId is not a number", () => {
|
||||
expect(() => parseTeamId({ username: "test", teamId: "notanumber" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSelfImpersonation", () => {
|
||||
it("should throw an error if the provided username is the same as the session user's username", () => {
|
||||
expect(() => checkSelfImpersonation(session, { username: "test" })).toThrow();
|
||||
});
|
||||
|
||||
it("should throw an error if the provided username is the same as the session user's email", () => {
|
||||
expect(() => checkSelfImpersonation(session, { username: "test@example.com" })).toThrow();
|
||||
});
|
||||
|
||||
it("should not throw an error if the provided username is different from the session user's username and email", () => {
|
||||
expect(() => checkSelfImpersonation(session, { username: "other" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUserIdentifier", () => {
|
||||
it("should throw an error if no username is provided", () => {
|
||||
expect(() => checkUserIdentifier(undefined)).toThrow();
|
||||
});
|
||||
|
||||
it("should not throw an error if a username is provided", () => {
|
||||
expect(() => checkUserIdentifier({ username: "test" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPermission", () => {
|
||||
it("should throw an error if the user is not an admin and team impersonation is disabled", () => {
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
|
||||
expect(() => checkPermission(session)).toThrow();
|
||||
});
|
||||
|
||||
it("should not throw an error if the user is an admin and team impersonation is disabled", () => {
|
||||
const modifiedSession = { ...session, user: { ...session.user, role: UserPermissionRole.ADMIN } };
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
|
||||
expect(() => checkPermission(modifiedSession)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not throw an error if the user is not an admin but team impersonation is enabled", () => {
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "true";
|
||||
expect(() => checkPermission(session)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { User } from "@prisma/client";
|
||||
import type { Session } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -44,6 +45,28 @@ const auditAndReturnNextUser = async (
|
||||
return obj;
|
||||
};
|
||||
|
||||
type Credentials = Record<"username" | "teamId", string> | undefined;
|
||||
|
||||
export function parseTeamId(creds: Partial<Credentials>) {
|
||||
return creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined;
|
||||
}
|
||||
|
||||
export function checkSelfImpersonation(session: Session | null, creds: Partial<Credentials>) {
|
||||
if (session?.user.username === creds?.username || session?.user.email === creds?.username) {
|
||||
throw new Error("You cannot impersonate yourself.");
|
||||
}
|
||||
}
|
||||
|
||||
export function checkUserIdentifier(creds: Partial<Credentials>) {
|
||||
if (!creds?.username) throw new Error("User identifier must be present");
|
||||
}
|
||||
|
||||
export function checkPermission(session: Session | null) {
|
||||
if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
}
|
||||
}
|
||||
|
||||
const ImpersonationProvider = CredentialsProvider({
|
||||
id: "impersonation-auth",
|
||||
name: "Impersonation",
|
||||
@@ -56,18 +79,10 @@ const ImpersonationProvider = CredentialsProvider({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore need to figure out how to correctly type this
|
||||
const session = await getSession({ req });
|
||||
// If teamId is present -> parse the teamId and throw error itn ot number. If not present teamId is set to undefined
|
||||
const teamId = creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined;
|
||||
|
||||
if (session?.user.username === creds?.username || session?.user.email === creds?.username) {
|
||||
throw new Error("You cannot impersonate yourself.");
|
||||
}
|
||||
|
||||
if (!creds?.username) throw new Error("User identifier must be present");
|
||||
// If you are an ADMIN we return way before team impersonation logic is executed, so NEXT_PUBLIC_TEAM_IMPERSONATION certainly true
|
||||
if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
}
|
||||
const teamId = parseTeamId(creds);
|
||||
checkSelfImpersonation(session, creds);
|
||||
checkUserIdentifier(creds);
|
||||
checkPermission(session);
|
||||
|
||||
// Get user who is being impersonated
|
||||
const impersonatedUser = await prisma.user.findFirst({
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { ReactNode, InputHTMLAttributes } from "react";
|
||||
import { useState, forwardRef, Fragment } from "react";
|
||||
|
||||
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { AnimatedPopover, Avatar } from "@calcom/ui";
|
||||
import { Layers, User } from "@calcom/ui/components/icon";
|
||||
|
||||
export type IEventTypesFilters = RouterOutputs["viewer"]["eventTypes"]["listWithTeam"];
|
||||
export type IEventTypeFilter = IEventTypesFilters[0];
|
||||
|
||||
export const OrganizationEventTypeFilter = () => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
|
||||
const [dropdownTitle, setDropdownTitle] = useState<string>(t("all_apps"));
|
||||
|
||||
const { data: teams, status } = trpc.viewer.teams.list.useQuery();
|
||||
const isNotEmpty = !!teams?.length;
|
||||
|
||||
return status === "success" ? (
|
||||
<AnimatedPopover text={dropdownTitle} popoverTriggerClassNames="!mb-0">
|
||||
<CheckboxFieldContainer>
|
||||
<CheckboxField
|
||||
id="all-eventtypes-checkbox"
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
checked={dropdownTitle === t("all_apps")}
|
||||
onChange={(e) => {
|
||||
removeAllQueryParams();
|
||||
setDropdownTitle(t("all_apps"));
|
||||
// TODO: What to do when all event types is unchecked
|
||||
}}
|
||||
label={t("all_apps")}
|
||||
/>
|
||||
</CheckboxFieldContainer>
|
||||
<CheckboxFieldContainer>
|
||||
<CheckboxField
|
||||
id="all-eventtypes-checkbox"
|
||||
icon={<User className="h-4 w-4" />}
|
||||
checked={query.userIds?.includes(session.data?.user.id || 0)}
|
||||
onChange={(e) => {
|
||||
setDropdownTitle(t("yours"));
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("userIds", session.data?.user.id || 0);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
|
||||
}
|
||||
}}
|
||||
label={t("yours")}
|
||||
/>
|
||||
</CheckboxFieldContainer>
|
||||
|
||||
{isNotEmpty && (
|
||||
<Fragment>
|
||||
<div className="text-subtle px-4 py-2.5 text-xs font-medium uppercase leading-none">TEAMS</div>
|
||||
{teams?.map((team) => (
|
||||
<CheckboxFieldContainer key={team.id}>
|
||||
<CheckboxField
|
||||
id={team.name}
|
||||
label={team.name}
|
||||
icon={
|
||||
<Avatar
|
||||
alt={team?.name}
|
||||
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
checked={query.teamIds?.includes(team.id)}
|
||||
onChange={(e) => {
|
||||
setDropdownTitle(team.name);
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("teamIds", team.id);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("teamIds", team.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CheckboxFieldContainer>
|
||||
))}
|
||||
</Fragment>
|
||||
)}
|
||||
</AnimatedPopover>
|
||||
) : null;
|
||||
};
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const CheckboxField = forwardRef<HTMLInputElement, Props>(({ label, icon, ...rest }, ref) => {
|
||||
return (
|
||||
<label className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="text-default flex h-6 w-6 items-center justify-center ltr:mr-2 rtl:ml-2">{icon}</div>
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
const CheckboxFieldContainer = ({ children }: { children: ReactNode }) => {
|
||||
return <div className="flex items-center px-3 py-2">{children}</div>;
|
||||
};
|
||||
|
||||
CheckboxField.displayName = "CheckboxField";
|
||||
@@ -7,7 +7,7 @@ import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
||||
import { getDefaultEvent, getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import type { BookerLayoutSettings } from "@calcom/prisma/zod-utils";
|
||||
@@ -71,13 +71,20 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
name: true,
|
||||
theme: true,
|
||||
metadata: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
},
|
||||
},
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
export const getPublicEvent = async (username: string, eventSlug: string, prisma: PrismaClient) => {
|
||||
const usernameList = username.split("+");
|
||||
export const getPublicEvent = async (
|
||||
username: string,
|
||||
eventSlug: string,
|
||||
isTeamEvent: boolean | undefined,
|
||||
prisma: PrismaClient
|
||||
) => {
|
||||
const usernameList = getUsernameList(username);
|
||||
|
||||
// In case of dynamic group event, we fetch user's data and use the default event.
|
||||
if (usernameList.length > 1) {
|
||||
@@ -141,24 +148,26 @@ export const getPublicEvent = async (username: string, eventSlug: string, prisma
|
||||
};
|
||||
}
|
||||
|
||||
const usersOrTeamQuery = isTeamEvent
|
||||
? {
|
||||
team: {
|
||||
slug: username,
|
||||
},
|
||||
}
|
||||
: {
|
||||
users: {
|
||||
some: {
|
||||
username,
|
||||
},
|
||||
},
|
||||
team: null,
|
||||
};
|
||||
|
||||
// In case it's not a group event, it's either a single user or a team, and we query that data.
|
||||
const event = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
slug: eventSlug,
|
||||
OR: [
|
||||
{
|
||||
users: {
|
||||
some: {
|
||||
username,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
slug: username,
|
||||
},
|
||||
},
|
||||
],
|
||||
...usersOrTeamQuery,
|
||||
},
|
||||
select: publicEventSelect,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { AnimatedPopover, Avatar, Divider } from "@calcom/ui";
|
||||
import { AnimatedPopover, Avatar, Divider, VerticalDivider } from "@calcom/ui";
|
||||
import { Layers, User } from "@calcom/ui/components/icon";
|
||||
|
||||
import { filterQuerySchema } from "../lib/getTeamsFiltersFromQuery";
|
||||
@@ -20,7 +20,13 @@ function useFilterQuery() {
|
||||
return useTypedQuery(filterQuerySchema.passthrough());
|
||||
}
|
||||
|
||||
export const TeamsFilter = () => {
|
||||
export const TeamsFilter = ({
|
||||
popoverTriggerClassNames,
|
||||
showVerticalDivider = false,
|
||||
}: {
|
||||
popoverTriggerClassNames?: string;
|
||||
showVerticalDivider?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const { data: query, pushItemToKey, removeItemByKeyAndValue, removeAllQueryParams } = useFilterQuery();
|
||||
@@ -40,7 +46,7 @@ export const TeamsFilter = () => {
|
||||
if (selectedTeamsNames) {
|
||||
checkedOptions.push(...selectedTeamsNames);
|
||||
}
|
||||
return checkedOptions.join(",");
|
||||
return `${t("team")}: ${checkedOptions.join(",")}`;
|
||||
}
|
||||
if (query.userIds) {
|
||||
return t("yours");
|
||||
@@ -51,56 +57,59 @@ export const TeamsFilter = () => {
|
||||
if (!teams || !teams.length) return null;
|
||||
|
||||
return (
|
||||
<AnimatedPopover text={getCheckedOptionsNames()}>
|
||||
<FilterCheckboxFieldsContainer>
|
||||
<FilterCheckboxField
|
||||
id="all"
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
|
||||
onChange={(e) => {
|
||||
removeAllQueryParams();
|
||||
}}
|
||||
label={t("all_apps")}
|
||||
/>
|
||||
|
||||
<FilterCheckboxField
|
||||
id="yours"
|
||||
icon={<User className="h-4 w-4" />}
|
||||
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("userIds", session.data?.user.id || 0);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
|
||||
}
|
||||
}}
|
||||
label={t("yours")}
|
||||
/>
|
||||
<Divider />
|
||||
{teams?.map((team) => (
|
||||
<div className="flex items-center">
|
||||
<AnimatedPopover text={getCheckedOptionsNames()} popoverTriggerClassNames={popoverTriggerClassNames}>
|
||||
<FilterCheckboxFieldsContainer>
|
||||
<FilterCheckboxField
|
||||
key={team.id}
|
||||
id={team.name}
|
||||
label={team.name}
|
||||
checked={!!query.teamIds?.includes(team.id)}
|
||||
id="all"
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
checked={!query.teamIds && !query.userIds?.includes(session.data?.user.id || 0)}
|
||||
onChange={(e) => {
|
||||
removeAllQueryParams();
|
||||
}}
|
||||
label={t("all_apps")}
|
||||
/>
|
||||
|
||||
<FilterCheckboxField
|
||||
id="yours"
|
||||
icon={<User className="h-4 w-4" />}
|
||||
checked={!!query.userIds?.includes(session.data?.user.id || 0)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("teamIds", team.id);
|
||||
pushItemToKey("userIds", session.data?.user.id || 0);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("teamIds", team.id);
|
||||
removeItemByKeyAndValue("userIds", session.data?.user.id || 0);
|
||||
}
|
||||
}}
|
||||
icon={
|
||||
<Avatar
|
||||
alt={team?.name}
|
||||
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
label={t("yours")}
|
||||
/>
|
||||
))}
|
||||
</FilterCheckboxFieldsContainer>
|
||||
</AnimatedPopover>
|
||||
<Divider />
|
||||
{teams?.map((team) => (
|
||||
<FilterCheckboxField
|
||||
key={team.id}
|
||||
id={team.name}
|
||||
label={team.name}
|
||||
checked={!!query.teamIds?.includes(team.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
pushItemToKey("teamIds", team.id);
|
||||
} else if (!e.target.checked) {
|
||||
removeItemByKeyAndValue("teamIds", team.id);
|
||||
}
|
||||
}}
|
||||
icon={
|
||||
<Avatar
|
||||
alt={team?.name}
|
||||
imageSrc={getPlaceholderAvatar(team.logo, team?.name as string)}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</FilterCheckboxFieldsContainer>
|
||||
</AnimatedPopover>
|
||||
{showVerticalDivider && <VerticalDivider />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
type UseScheduleWithCacheArgs = {
|
||||
@@ -22,13 +23,12 @@ export const useSchedule = ({
|
||||
}: UseScheduleWithCacheArgs) => {
|
||||
const monthDayjs = month ? dayjs(month) : dayjs();
|
||||
const nextMonthDayjs = monthDayjs.add(1, "month");
|
||||
|
||||
// Why the non-null assertions? All of these arguments are checked in the enabled condition,
|
||||
// and the query will not run if they are null. However, the check in `enabled` does
|
||||
// no satisfy typscript.
|
||||
return trpc.viewer.public.slots.getSchedule.useQuery(
|
||||
{
|
||||
usernameList: username && username.indexOf("+") > -1 ? username.split("+") : [username!],
|
||||
usernameList: getUsernameList(username ?? ""),
|
||||
eventTypeSlug: eventSlug!,
|
||||
// @TODO: Old code fetched 2 days ago if we were fetching the current month.
|
||||
// Do we want / need to keep that behavior?
|
||||
|
||||
@@ -23,6 +23,13 @@ type BookerLayoutSelectorProps = {
|
||||
* settings.
|
||||
*/
|
||||
fallbackToUserSettings?: boolean;
|
||||
/**
|
||||
* isDark boolean should be passed in when the user selected 'dark mode' in the theme settings in profile/appearance.
|
||||
* So it's not based on the user's system settings, but on the user's preference for the booker.
|
||||
* This boolean is then used to show a dark version of the layout image. It's only easthetic, no functionality is attached
|
||||
* to this boolean.
|
||||
*/
|
||||
isDark?: boolean;
|
||||
};
|
||||
|
||||
const defaultFieldName = "metadata.bookerLayouts";
|
||||
@@ -32,6 +39,7 @@ export const BookerLayoutSelector = ({
|
||||
description,
|
||||
name,
|
||||
fallbackToUserSettings,
|
||||
isDark,
|
||||
}: BookerLayoutSelectorProps) => {
|
||||
const { control, getValues } = useFormContext();
|
||||
const { t } = useLocale();
|
||||
@@ -58,6 +66,7 @@ export const BookerLayoutSelector = ({
|
||||
showUserSettings={shouldShowUserSettings}
|
||||
settings={value}
|
||||
onChange={onChange}
|
||||
isDark={isDark}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -69,11 +78,12 @@ type BookerLayoutFieldsProps = {
|
||||
settings: BookerLayoutSettings;
|
||||
onChange: (settings: BookerLayoutSettings) => void;
|
||||
showUserSettings: boolean;
|
||||
isDark?: boolean;
|
||||
};
|
||||
|
||||
type BookerLayoutState = { [key in BookerLayouts]: boolean };
|
||||
|
||||
const BookerLayoutFields = ({ settings, onChange, showUserSettings }: BookerLayoutFieldsProps) => {
|
||||
const BookerLayoutFields = ({ settings, onChange, showUserSettings, isDark }: BookerLayoutFieldsProps) => {
|
||||
const { t } = useLocale();
|
||||
const { isLoading: isUserLoading, data: user } = useMeQuery();
|
||||
const [isOverridingSettings, setIsOverridingSettings] = useState(false);
|
||||
@@ -143,7 +153,7 @@ const BookerLayoutFields = ({ settings, onChange, showUserSettings }: BookerLayo
|
||||
<label>
|
||||
<img
|
||||
className="mb-3 w-full max-w-none cursor-pointer"
|
||||
src={`/bookerlayout_${layout}.svg`}
|
||||
src={`/bookerlayout_${layout}${isDark ? "_dark" : ""}.svg`}
|
||||
alt="Layout preview"
|
||||
/>
|
||||
<Checkbox
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*",
|
||||
"@prisma/client": "^4.13.0",
|
||||
"@prisma/client": "^4.16.0",
|
||||
"@prisma/generator-helper": "^4.13.0",
|
||||
"prisma": "^4.13.0",
|
||||
"prisma": "^4.16.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"zod": "^3.20.2",
|
||||
"zod-prisma": "^0.5.4"
|
||||
|
||||
@@ -10,6 +10,6 @@ interface EventHandlerOptions {
|
||||
}
|
||||
|
||||
export const eventHandler = async ({ ctx, input }: EventHandlerOptions) => {
|
||||
const event = await getPublicEvent(input.username, input.eventSlug, ctx.prisma);
|
||||
const event = await getPublicEvent(input.username, input.eventSlug, input.isTeamEvent, ctx.prisma);
|
||||
return event;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import z from "zod";
|
||||
export const ZEventInputSchema = z.object({
|
||||
username: z.string(),
|
||||
eventSlug: z.string(),
|
||||
isTeamEvent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TEventInputSchema = z.infer<typeof ZEventInputSchema>;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ZCreateInputSchema } from "./create.schema";
|
||||
import { ZDeleteInputSchema } from "./delete.schema";
|
||||
import { ZDuplicateInputSchema } from "./duplicate.schema";
|
||||
import { ZGetInputSchema } from "./get.schema";
|
||||
import { ZEventTypeInputSchema } from "./getByViewer.schema";
|
||||
import { ZUpdateInputSchema } from "./update.schema";
|
||||
import { eventOwnerProcedure } from "./util";
|
||||
|
||||
@@ -28,7 +29,7 @@ const UNSTABLE_HANDLER_CACHE: BookingsRouterHandlerCache = {};
|
||||
|
||||
export const eventTypesRouter = router({
|
||||
// REVIEW: What should we name this procedure?
|
||||
getByViewer: authedProcedure.query(async ({ ctx }) => {
|
||||
getByViewer: authedProcedure.input(ZEventTypeInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
|
||||
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
|
||||
(mod) => mod.getByViewerHandler
|
||||
@@ -44,6 +45,7 @@ export const eventTypesRouter = router({
|
||||
|
||||
const result = await UNSTABLE_HANDLER_CACHE.getByViewer({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
|
||||
timer();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type PrismaClient, Prisma } from "@prisma/client";
|
||||
import { orderBy } from "lodash";
|
||||
|
||||
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { baseEventTypeSelect, baseUserSelect } from "@calcom/prisma";
|
||||
@@ -11,53 +12,58 @@ import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
import type { TEventTypeInputSchema } from "./getByViewer.schema";
|
||||
|
||||
type GetByViewerOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TEventTypeInputSchema;
|
||||
};
|
||||
|
||||
export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
// Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering
|
||||
position: true,
|
||||
hashedLink: true,
|
||||
locations: true,
|
||||
destinationCalendar: true,
|
||||
userId: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
// logo: true, // Skipping to avoid 4mb limit
|
||||
bio: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
metadata: true,
|
||||
users: {
|
||||
select: baseUserSelect,
|
||||
},
|
||||
children: {
|
||||
include: {
|
||||
users: {
|
||||
select: baseUserSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
parentId: true,
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: baseUserSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
seatsPerTimeSlot: true,
|
||||
...baseEventTypeSelect,
|
||||
});
|
||||
|
||||
export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) => {
|
||||
const { prisma } = ctx;
|
||||
const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
// Position is required by lodash to sort on it. Don't remove it, TS won't complain but it would silently break reordering
|
||||
position: true,
|
||||
hashedLink: true,
|
||||
locations: true,
|
||||
destinationCalendar: true,
|
||||
userId: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
// logo: true, // Skipping to avoid 4mb limit
|
||||
bio: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
metadata: true,
|
||||
users: {
|
||||
select: baseUserSelect,
|
||||
},
|
||||
children: {
|
||||
include: {
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
parentId: true,
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: baseUserSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
seatsPerTimeSlot: true,
|
||||
...baseEventTypeSelect,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
@@ -107,6 +113,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
eventTypes: {
|
||||
where: {
|
||||
team: null,
|
||||
userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters),
|
||||
},
|
||||
select: eventTypeSelect,
|
||||
orderBy: [
|
||||
@@ -137,7 +144,8 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
const typesRaw = (
|
||||
await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
userId: getPrismaWhereUserIdFromFilter(ctx.user.id, input?.filters),
|
||||
team: null,
|
||||
},
|
||||
select: eventTypeSelect,
|
||||
orderBy: [
|
||||
@@ -167,11 +175,13 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
};
|
||||
|
||||
let eventTypeGroups: EventTypeGroup[] = [];
|
||||
|
||||
const eventTypesHashMap = userEventTypes.concat(typesRaw).reduce((hashMap, newItem) => {
|
||||
const oldItem = hashMap[newItem.id];
|
||||
hashMap[newItem.id] = { ...oldItem, ...newItem };
|
||||
return hashMap;
|
||||
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
|
||||
|
||||
const mergedEventTypes = Object.values(eventTypesHashMap)
|
||||
.map((eventType) => eventType)
|
||||
.filter((evType) => evType.schedulingType !== SchedulingType.MANAGED);
|
||||
@@ -204,6 +214,13 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
return mshipToNumber(mship1) > mshipToNumber(mship2);
|
||||
};
|
||||
|
||||
const filterTeamsEventTypesBasedOnInput = (eventType: ReturnType<typeof mapEventType>) => {
|
||||
if (!input?.filters || !hasFilter(input?.filters)) {
|
||||
return true;
|
||||
}
|
||||
return input?.filters?.teamIds?.includes(eventType?.team?.id || 0) ?? false;
|
||||
};
|
||||
|
||||
eventTypeGroups = ([] as EventTypeGroup[]).concat(
|
||||
eventTypeGroups,
|
||||
user.teams
|
||||
@@ -242,6 +259,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
},
|
||||
eventTypes: membership.team.eventTypes
|
||||
.map(mapEventType)
|
||||
.filter(filterTeamsEventTypesBasedOnInput)
|
||||
.filter((evType) => evType.userId === null || evType.userId === ctx.user.id)
|
||||
.filter((evType) =>
|
||||
membership.role === MembershipRole.MEMBER
|
||||
@@ -251,6 +269,7 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
// don't display event teams without event types,
|
||||
eventTypeGroups: eventTypeGroups.filter((groupBy) => !!groupBy.eventTypes?.length),
|
||||
@@ -264,3 +283,15 @@ export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export function getPrismaWhereUserIdFromFilter(
|
||||
userId: number,
|
||||
filters: NonNullable<TEventTypeInputSchema>["filters"]
|
||||
) {
|
||||
if (!filters || !hasFilter(filters)) {
|
||||
return userId;
|
||||
} else if (filters.userIds?.[0] === userId) {
|
||||
return userId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
export {};
|
||||
import { z } from "zod";
|
||||
|
||||
export const filterQuerySchemaStrict = z.object({
|
||||
teamIds: z.number().array().optional(),
|
||||
// A user can only filter by only his userId
|
||||
userIds: z.number().array().max(1).optional(),
|
||||
});
|
||||
|
||||
export const ZEventTypeInputSchema = z
|
||||
.object({
|
||||
filters: filterQuerySchemaStrict.optional(),
|
||||
})
|
||||
.nullish();
|
||||
|
||||
export type TEventTypeInputSchema = z.infer<typeof ZEventTypeInputSchema>;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { subdomainSuffix } from "@calcom/ee/organizations/lib/orgDomains";
|
||||
import { cancelTeamSubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { IS_PRODUCTION, IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||
import { closeComDeleteTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -16,6 +18,41 @@ type DeleteOptions = {
|
||||
input: TDeleteInputSchema;
|
||||
};
|
||||
|
||||
const deleteVercelDomain = async ({
|
||||
slug,
|
||||
isOrganization,
|
||||
}: {
|
||||
slug?: string | null;
|
||||
isOrganization?: boolean | null;
|
||||
}) => {
|
||||
if (!isOrganization || !slug) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fullDomain = `${slug}.${subdomainSuffix()}`;
|
||||
const response = await fetch(
|
||||
`https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${fullDomain}?teamId=${process.env.TEAM_ID_VERCEL}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN_VERCEL}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Domain is already owned by another team but you can request delegation to access it
|
||||
if (data.error?.code === "forbidden")
|
||||
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_team" });
|
||||
|
||||
// Domain is already being used by a different project
|
||||
if (data.error?.code === "domain_taken")
|
||||
throw new TRPCError({ code: "CONFLICT", message: "domain_taken_project" });
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
@@ -34,6 +71,14 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
const deletedTeamMetadata = teamMetadataSchema.parse(deletedTeam.metadata);
|
||||
|
||||
if (IS_PRODUCTION)
|
||||
deleteVercelDomain({
|
||||
slug: deletedTeam.slug,
|
||||
isOrganization: deletedTeamMetadata?.isOrganization,
|
||||
});
|
||||
|
||||
// Sync Services: Close.cm
|
||||
closeComDeleteTeam(deletedTeam);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// @TODO: turn this into a more generic component that has the same Props API as MUI https://mui.com/material-ui/react-card/
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
@@ -8,10 +10,96 @@ import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { Button } from "../button";
|
||||
|
||||
export type BaseCardProps = {
|
||||
const cvaCardTypeByVariant = cva("", {
|
||||
// Variants won't have any style by default. Style will only be applied if the variants are combined.
|
||||
// So, style is defined in compoundVariants.
|
||||
variants: {
|
||||
variant: {
|
||||
basic: "",
|
||||
ProfileCard: "",
|
||||
SidebarCard: "",
|
||||
},
|
||||
structure: {
|
||||
image: "",
|
||||
card: "",
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Style for Basic Variants types
|
||||
{
|
||||
variant: "basic",
|
||||
structure: "image",
|
||||
className: "w-10 h-auto",
|
||||
},
|
||||
{
|
||||
variant: "basic",
|
||||
structure: "card",
|
||||
className: "p-5",
|
||||
},
|
||||
{
|
||||
variant: "basic",
|
||||
structure: "title",
|
||||
className: "text-base mt-4",
|
||||
},
|
||||
{
|
||||
variant: "basic",
|
||||
structure: "description",
|
||||
className: "text-sm leading-[18px] text-subtle font-normal",
|
||||
},
|
||||
|
||||
// Style for ProfileCard Variant Types
|
||||
{
|
||||
variant: "ProfileCard",
|
||||
structure: "image",
|
||||
className: "w-9 h-auto rounded-full mb-4s",
|
||||
},
|
||||
{
|
||||
variant: "ProfileCard",
|
||||
structure: "card",
|
||||
className: "w-80 p-4 hover:bg-subtle",
|
||||
},
|
||||
{
|
||||
variant: "ProfileCard",
|
||||
structure: "title",
|
||||
className: "text-base",
|
||||
},
|
||||
{
|
||||
variant: "ProfileCard",
|
||||
structure: "description",
|
||||
className: "text-sm leading-[18px] text-subtle font-normal",
|
||||
},
|
||||
|
||||
// Style for SidebarCard Variant Types
|
||||
{
|
||||
variant: "SidebarCard",
|
||||
structure: "image",
|
||||
className: "w-9 h-auto rounded-full mb-4s",
|
||||
},
|
||||
{
|
||||
variant: "SidebarCard",
|
||||
structure: "card",
|
||||
className: "w-full p-3 border border-subtle",
|
||||
},
|
||||
{
|
||||
variant: "SidebarCard",
|
||||
structure: "title",
|
||||
className: "text-sm font-cal",
|
||||
},
|
||||
{
|
||||
variant: "SidebarCard",
|
||||
structure: "description",
|
||||
className: "text-xs text-default line-clamp-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
type CVACardType = Required<Pick<VariantProps<typeof cvaCardTypeByVariant>, "variant">>;
|
||||
|
||||
export interface BaseCardProps extends CVACardType {
|
||||
image?: string;
|
||||
icon?: ReactNode;
|
||||
variant: keyof typeof cardTypeByVariant;
|
||||
imageProps?: JSX.IntrinsicElements["img"];
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
@@ -27,30 +115,7 @@ export type BaseCardProps = {
|
||||
};
|
||||
mediaLink?: string;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
|
||||
// @TODO: use CVA
|
||||
|
||||
const cardTypeByVariant = {
|
||||
basic: {
|
||||
image: "w-10 h-auto",
|
||||
card: "p-5",
|
||||
title: "text-base mt-4",
|
||||
description: "text-sm leading-[18px] text-subtle font-normal",
|
||||
},
|
||||
ProfileCard: {
|
||||
image: "w-9 h-auto rounded-full mb-4s",
|
||||
card: "w-80 p-4 hover:bg-subtle",
|
||||
title: "text-base",
|
||||
description: "text-sm leading-[18px] text-subtle font-normal",
|
||||
},
|
||||
SidebarCard: {
|
||||
image: "w-9 h-auto rounded-full mb-4s",
|
||||
card: "w-full p-3 border border-subtle",
|
||||
title: "text-sm font-cal",
|
||||
description: "text-xs text-default line-clamp-2",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function Card({
|
||||
image,
|
||||
@@ -70,7 +135,7 @@ export function Card({
|
||||
<div
|
||||
className={classNames(
|
||||
containerProps?.className,
|
||||
cardTypeByVariant[variant].card,
|
||||
cvaCardTypeByVariant({ variant, structure: "card" }),
|
||||
"bg-default border-subtle text-default flex flex-col justify-between rounded-md border"
|
||||
)}
|
||||
{...containerProps}>
|
||||
@@ -81,14 +146,17 @@ export function Card({
|
||||
src={image}
|
||||
// Stops eslint complaining - not smart enough to realise it comes from ...imageProps
|
||||
alt={imageProps?.alt}
|
||||
className={classNames(imageProps?.className, cardTypeByVariant[variant].image)}
|
||||
className={classNames(
|
||||
imageProps?.className,
|
||||
cvaCardTypeByVariant({ variant, structure: "image" })
|
||||
)}
|
||||
{...imageProps}
|
||||
/>
|
||||
)}
|
||||
<h5
|
||||
title={title}
|
||||
className={classNames(
|
||||
cardTypeByVariant[variant].title,
|
||||
cvaCardTypeByVariant({ variant, structure: "title" }),
|
||||
"line-clamp-1 text-emphasis font-bold leading-5"
|
||||
)}>
|
||||
{title}
|
||||
@@ -96,7 +164,7 @@ export function Card({
|
||||
{description && (
|
||||
<p
|
||||
title={description.toString()}
|
||||
className={classNames(cardTypeByVariant[variant].description, "pt-1")}>
|
||||
className={classNames(cvaCardTypeByVariant({ variant, structure: "description" }), "pt-1")}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
|
||||
|
||||
import {
|
||||
Examples,
|
||||
Example,
|
||||
Note,
|
||||
Title,
|
||||
VariantsTable,
|
||||
VariantColumn,
|
||||
RowTitles,
|
||||
CustomArgsTable,
|
||||
} from "@calcom/storybook/components";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "./sheet";
|
||||
|
||||
import {Button} from "../button";
|
||||
|
||||
|
||||
<Meta title="UI/Sheet" />
|
||||
|
||||
<Title title="Sheet" suffix="Brief" subtitle="Version 2.0 — Last Update: 21 June 2023" />
|
||||
|
||||
## Definition
|
||||
|
||||
A sheet is a container that can be used to display content on differnt sides of the current page.
|
||||
|
||||
## Structure
|
||||
|
||||
<Examples title="Spacing">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button>Open default sheet</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent position="right" size={"default"}>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit profile</SheetTitle>
|
||||
<SheetDescription>
|
||||
Make changes to your profile here. Click save when you're done.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Examples>
|
||||
@@ -0,0 +1,207 @@
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const portalVariants = cva("fixed inset-0 z-50 flex", {
|
||||
variants: {
|
||||
position: {
|
||||
top: "items-start",
|
||||
bottom: "items-end",
|
||||
left: "justify-start",
|
||||
right: "justify-end",
|
||||
},
|
||||
},
|
||||
defaultVariants: { position: "right" },
|
||||
});
|
||||
|
||||
interface SheetPortalProps extends SheetPrimitive.DialogPortalProps, VariantProps<typeof portalVariants> {}
|
||||
|
||||
const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => (
|
||||
<SheetPrimitive.Portal className={classNames(className)} {...props}>
|
||||
<div className={portalVariants({ position })}>{children}</div>
|
||||
</SheetPrimitive.Portal>
|
||||
);
|
||||
SheetPortal.displayName = SheetPrimitive.Portal.displayName;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={classNames(
|
||||
"bg-inverted data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 opacity-60 backdrop-blur-sm transition-all duration-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 scale-100 gap-4 bg-default m-4 rounded-xl p-6 opacity-100 shadow-lg border",
|
||||
{
|
||||
variants: {
|
||||
position: {
|
||||
top: "animate-in slide-in-from-top w-full duration-300",
|
||||
bottom: "animate-in slide-in-from-bottom w-full duration-300",
|
||||
left: "animate-in slide-in-from-left h-full duration-300",
|
||||
right: "animate-in slide-in-from-right h-full duration-300",
|
||||
},
|
||||
size: {
|
||||
content: "",
|
||||
default: "",
|
||||
sm: "",
|
||||
lg: "",
|
||||
xl: "",
|
||||
full: "",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
position: ["top", "bottom"],
|
||||
size: "content",
|
||||
class: "max-h-screen",
|
||||
},
|
||||
{
|
||||
position: ["top", "bottom"],
|
||||
size: "default",
|
||||
class: "h-1/3",
|
||||
},
|
||||
{
|
||||
position: ["top", "bottom"],
|
||||
size: "sm",
|
||||
class: "h-1/4",
|
||||
},
|
||||
{
|
||||
position: ["top", "bottom"],
|
||||
size: "lg",
|
||||
class: "h-1/2",
|
||||
},
|
||||
{
|
||||
position: ["top", "bottom"],
|
||||
size: "xl",
|
||||
class: "h-5/6",
|
||||
},
|
||||
{
|
||||
position: ["top", "bottom"],
|
||||
size: "full",
|
||||
class: "h-screen",
|
||||
},
|
||||
{
|
||||
position: ["right", "left"],
|
||||
size: "content",
|
||||
class: "max-w-screen",
|
||||
},
|
||||
{
|
||||
position: ["right", "left"],
|
||||
size: "default",
|
||||
class: "w-1/3 h-[calc(100vh-2rem)]",
|
||||
},
|
||||
{
|
||||
position: ["right", "left"],
|
||||
size: "sm",
|
||||
class: "w-1/4 h-[calc(100vh-2rem)]",
|
||||
},
|
||||
{
|
||||
position: ["right", "left"],
|
||||
size: "lg",
|
||||
class: "w-1/2 h-[calc(100vh-2rem)]",
|
||||
},
|
||||
{
|
||||
position: ["right", "left"],
|
||||
size: "xl",
|
||||
class: "w-5/6 h-[calc(100vh-2rem)]",
|
||||
},
|
||||
{
|
||||
position: ["right", "left"],
|
||||
size: "full",
|
||||
class: "w-screen h-[calc(100vh-2rem)]",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
position: "right",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, DialogContentProps>(
|
||||
({ position, size, className, children, ...props }, ref) => (
|
||||
<SheetPortal position={position}>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={classNames(sheetVariants({ position, size }), className)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="focus:ring-emphasis data-[state=open]:bg-deafult absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={classNames("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={classNames("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={classNames("text-foreground text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={classNames("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -4062,7 +4062,7 @@ __metadata:
|
||||
"@calcom/ui": "*"
|
||||
"@headlessui/react": ^1.5.0
|
||||
"@heroicons/react": ^1.0.6
|
||||
"@prisma/client": ^4.13.0
|
||||
"@prisma/client": ^4.16.0
|
||||
"@tailwindcss/forms": ^0.5.2
|
||||
"@types/node": 16.9.1
|
||||
"@types/react": 18.0.26
|
||||
@@ -4074,7 +4074,7 @@ __metadata:
|
||||
next-auth: ^4.20.1
|
||||
next-i18next: ^11.3.0
|
||||
postcss: ^8.4.18
|
||||
prisma: ^4.13.0
|
||||
prisma: ^4.16.0
|
||||
prisma-field-encryption: ^1.4.0
|
||||
react: ^18.2.0
|
||||
react-chartjs-2: ^4.0.1
|
||||
@@ -4525,10 +4525,10 @@ __metadata:
|
||||
resolution: "@calcom/prisma@workspace:packages/prisma"
|
||||
dependencies:
|
||||
"@calcom/lib": "*"
|
||||
"@prisma/client": ^4.13.0
|
||||
"@prisma/client": ^4.16.0
|
||||
"@prisma/generator-helper": ^4.13.0
|
||||
npm-run-all: ^4.1.5
|
||||
prisma: ^4.13.0
|
||||
prisma: ^4.16.0
|
||||
ts-node: ^10.9.1
|
||||
zod: ^3.20.2
|
||||
zod-prisma: ^0.5.4
|
||||
@@ -7835,17 +7835,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@prisma/client@npm:^4.13.0":
|
||||
version: 4.13.0
|
||||
resolution: "@prisma/client@npm:4.13.0"
|
||||
"@prisma/client@npm:^4.16.0":
|
||||
version: 4.16.0
|
||||
resolution: "@prisma/client@npm:4.16.0"
|
||||
dependencies:
|
||||
"@prisma/engines-version": 4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a
|
||||
"@prisma/engines-version": 4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c
|
||||
peerDependencies:
|
||||
prisma: "*"
|
||||
peerDependenciesMeta:
|
||||
prisma:
|
||||
optional: true
|
||||
checksum: 74e2ede6308d6f70dc6244c34d13cf5f6c8da4f1a1ffe42a26192675ecac02733cb5482518d2a37fa057f160873fe8d42f5e3a08fc45d52b1eb386522bdbec0b
|
||||
checksum: 67fbf8e0bd672d597433b61b65a39c58cbb2eb82e7bcac33f31215d8ca5ba48df790a20aa990c47d6dde53e0d84d1a45d07d872f772d3dd8271613abe879b5d0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -7882,17 +7882,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@prisma/engines-version@npm:4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a":
|
||||
version: 4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a
|
||||
resolution: "@prisma/engines-version@npm:4.13.0-50.1e7af066ee9cb95cf3a403c78d9aab3e6b04f37a"
|
||||
checksum: cc583fe956b65fd4548082917f14ccf1c3c54d6b26623583f5e63298a8b4b5a492ba90597123af1fc272115621cb7340e2d708d5b5e252f59139b35f0df00abe
|
||||
"@prisma/engines-version@npm:4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c":
|
||||
version: 4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c
|
||||
resolution: "@prisma/engines-version@npm:4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c"
|
||||
checksum: 23183cab33f95e0711cd6cee7642f4beeeb77b8b0aa54b419f55097bfb96863a11a28f6509ab27fef0ea794522e188044ce850993a25c341333f8305711a255f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@prisma/engines@npm:4.13.0":
|
||||
version: 4.13.0
|
||||
resolution: "@prisma/engines@npm:4.13.0"
|
||||
checksum: 6c2315dc1e1a8a07f02a074ee6898a66a96a3bbbb4341224cb1dc966da3959c2ee7557131a2ce37fd559c53d210b904b72c4559474e017438e6753d480c3cfe1
|
||||
"@prisma/engines@npm:4.16.0":
|
||||
version: 4.16.0
|
||||
resolution: "@prisma/engines@npm:4.16.0"
|
||||
checksum: 60ef1c3720720cf5a9d23f52c668669736716afd1862c4ac61e8ebcd3ca896e3453f80dfab77f3b68b001c5d499a895f28e54cc5025eae61878b40c1ec5d99c3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29665,15 +29665,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prisma@npm:^4.13.0":
|
||||
version: 4.13.0
|
||||
resolution: "prisma@npm:4.13.0"
|
||||
"prisma@npm:^4.16.0":
|
||||
version: 4.16.0
|
||||
resolution: "prisma@npm:4.16.0"
|
||||
dependencies:
|
||||
"@prisma/engines": 4.13.0
|
||||
"@prisma/engines": 4.16.0
|
||||
bin:
|
||||
prisma: build/index.js
|
||||
prisma2: build/index.js
|
||||
checksum: b735dd19842da1dfe5b3411551665db5f2a419ee5e67136dfc2c8cae3c7fe0f885b472b0f9decb3adb4af83260c51a605ecca8f2194805804e020c382096a75a
|
||||
checksum: 831a4d14a48474fa144ae0648ed5e3766756b991f1997ecca813a044ef9bd7f2a5794c603adc9d23d867d8f4a0f51cedadaebfd8a841e6923ef9b804ed8bcbbb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user