merge with remote main

This commit is contained in:
Alan 2023-09-07 16:48:17 -07:00
commit 1c2211e2bf
42 changed files with 467 additions and 211 deletions

View File

@ -13,7 +13,6 @@ import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import cx from "@calcom/lib/classNames";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
@ -302,13 +301,7 @@ export const EventSetupTab = (
<div className="flex items-center">
<img
src={eventLocationType.iconUrl}
className={cx(
"h-4 w-4",
// invert all the icons except app icons
eventLocationType.iconUrl &&
eventLocationType.iconUrl.includes("-dark") &&
"dark:invert"
)}
className="h-4 w-4 dark:invert-[.65]"
alt={`${eventLocationType.label} logo`}
/>
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${

View File

@ -3,12 +3,13 @@ import type { FormEvent } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
import turndown from "@calcom/lib/turndownService";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
import { ArrowRight } from "@calcom/ui/components/icon";
type FormData = {
@ -98,7 +99,14 @@ const UserProfile = () => {
return (
<form onSubmit={onSubmit}>
<div className="flex flex-row items-center justify-start rtl:justify-end">
{user && <Avatar alt={user.username || "user avatar"} size="lg" imageSrc={imageSrc} />}
{user && (
<OrganizationAvatar
alt={user.username || "user avatar"}
size="lg"
imageSrc={imageSrc}
organizationSlug={user.organization?.slug}
/>
)}
<input
ref={avatarRef}
type="hidden"

View File

@ -22,14 +22,7 @@ export type GroupOptionType = GroupBase<LocationOption>;
const OptionWithIcon = ({ icon, label }: { icon?: string; label: string }) => {
return (
<div className="flex items-center gap-3">
{icon && (
<img
src={icon}
alt="cover"
// invert all the icons except app icons
className={classNames(icon.includes("-dark") && "dark:invert", "h-3.5 w-3.5")}
/>
)}
{icon && <img src={icon} alt="cover" className="h-3.5 w-3.5 dark:invert-[.65]" />}
<span className={classNames("text-sm font-medium")}>{label}</span>
</div>
);

View File

@ -254,6 +254,10 @@ const nextConfig = {
source: "/org/:slug",
destination: "/team/:slug",
},
{
source: "/org/:orgSlug/avatar.png",
destination: "/api/user/avatar?orgSlug=:orgSlug",
},
{
source: "/team/:teamname/avatar.png",
destination: "/api/user/avatar?teamname=:teamname",

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.2.8",
"version": "3.2.9",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",

View File

@ -11,6 +11,7 @@ import {
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
@ -25,7 +26,7 @@ import prisma from "@calcom/prisma";
import type { EventType, User } from "@calcom/prisma/client";
import { baseEventTypeSelect } from "@calcom/prisma/selects";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Avatar, HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
import type { EmbedProps } from "@lib/withEmbedSsr";
@ -96,7 +97,12 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
"max-w-3xl px-4 py-24"
)}>
<div className="mb-8 text-center">
<Avatar imageSrc={profile.image} size="xl" alt={profile.name} />
<OrganizationAvatar
imageSrc={profile.image}
size="xl"
alt={profile.name}
organizationSlug={profile.organizationSlug}
/>
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
{profile.name}
{user.verified && (
@ -218,6 +224,7 @@ export type UserPageProps = {
theme: string | null;
brandColor: string;
darkBrandColor: string;
organizationSlug: string | null;
allowSEOIndexing: boolean;
};
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
@ -321,6 +328,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
organizationSlug: user.organization?.slug ?? null,
allowSEOIndexing: user.allowSEOIndexing ?? true,
};

View File

@ -10,6 +10,7 @@ const querySchema = z
.object({
username: z.string(),
teamname: z.string(),
orgSlug: z.string(),
/**
* Allow fetching avatar of a particular organization
* Avatars being public, we need not worry about others accessing it.
@ -19,7 +20,7 @@ const querySchema = z
.partial();
async function getIdentityData(req: NextApiRequest) {
const { username, teamname, orgId } = querySchema.parse(req.query);
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
const org = isValidOrgDomain ? currentOrgDomain : null;
@ -59,7 +60,23 @@ async function getIdentityData(req: NextApiRequest) {
org,
name: teamname,
email: null,
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
avatar: getPlaceholderAvatar(team?.logo, teamname),
};
}
if (orgSlug) {
const org = await prisma.team.findFirst({
where: getSlugOrRequestedSlug(orgSlug),
select: {
slug: true,
logo: true,
name: true,
},
});
return {
org: org?.slug,
name: org?.name,
email: null,
avatar: getPlaceholderAvatar(org?.logo, org?.name),
};
}
}

View File

@ -781,6 +781,12 @@ const CreateFirstEventTypeView = () => {
Icon={LinkIcon}
headline={t("new_event_type_heading")}
description={t("new_event_type_description")}
className="mb-16"
buttonRaw={
<Button href="?dialog=new" variant="button">
{t("create")}
</Button>
}
/>
);
};

View File

@ -6,6 +6,7 @@ import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -14,10 +15,10 @@ import turndown from "@calcom/lib/turndownService";
import { IdentityProvider } from "@calcom/prisma/enums";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import {
Alert,
Avatar,
Button,
Dialog,
DialogClose,
@ -223,6 +224,7 @@ const ProfileView = () => {
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
isLoading={updateProfileMutation.isLoading}
userOrganization={user.organization}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProvider) {
setTempFormValues(values);
@ -364,11 +366,13 @@ const ProfileForm = ({
onSubmit,
extraField,
isLoading = false,
userOrganization,
}: {
defaultValues: FormValues;
onSubmit: (values: FormValues) => void;
extraField?: React.ReactNode;
isLoading: boolean;
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
}) => {
const { t } = useLocale();
const [firstRender, setFirstRender] = useState(true);
@ -406,7 +410,12 @@ const ProfileForm = ({
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} size="lg" />
<OrganizationAvatar
alt={formMethods.getValues("username")}
imageSrc={value}
size="lg"
organizationSlug={userOrganization.slug}
/>
<div className="ms-4">
<ImageUploader
target="avatar"

View File

@ -142,7 +142,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug }: Signup
<TextField
addOnLeading={
orgSlug
? getOrgFullDomain(orgSlug, { protocol: true })
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
}
{...register("username")}

View File

@ -1,60 +1,21 @@
import { expect } from "@playwright/test";
import type { Prisma } from "@prisma/client";
import { uuid } from "short-uuid";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import {
bookTimeSlot,
createNewSeatedEventType,
selectFirstAvailableTimeSlotNextMonth,
createUserWithSeatedEventAndAttendees,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
const slug = "seats";
const user = await users.create({
eventTypes: [
{
title: "Seated event",
slug,
seatsPerTimeSlot: 10,
requiresConfirmation: true,
length: 30,
disableGuests: true, // should always be true for seated events
},
],
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
return { user, eventType };
}
async function createUserWithSeatedEventAndAttendees(
fixtures: Pick<Fixtures, "users" | "bookings">,
attendees: Prisma.AttendeeCreateManyBookingInput[]
) {
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
// startTime with 1 day from now and endTime half hour after
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
attendees: {
createMany: {
data: attendees,
},
},
});
return { user, eventType, booking };
}
test.describe("Booking with Seats", () => {
test("User can create a seated event (2 seats as example)", async ({ users, page }) => {
const user = await users.create({ name: "Seated event" });

View File

@ -6,6 +6,10 @@ import { createServer } from "http";
import { noop } from "lodash";
import type { API, Messages } from "mailhog";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import type { Fixtures } from "./fixtures";
import { test } from "./fixtures";
export function todo(title: string) {
@ -192,6 +196,7 @@ export async function installAppleCalendar(page: Page) {
await page.waitForURL("/apps/apple-calendar");
await page.click('[data-testid="install-app-button"]');
}
export async function getEmailsReceivedByUser({
emails,
userEmail,
@ -228,3 +233,44 @@ export async function expectEmailsToHaveSubject({
expect(organizerFirstEmail.subject).toBe(emailSubject);
expect(bookerFirstEmail.subject).toBe(emailSubject);
}
// this method is not used anywhere else
// but I'm keeping it here in case we need in the future
async function createUserWithSeatedEvent(users: Fixtures["users"]) {
const slug = "seats";
const user = await users.create({
eventTypes: [
{
title: "Seated event",
slug,
seatsPerTimeSlot: 10,
requiresConfirmation: true,
length: 30,
disableGuests: true, // should always be true for seated events
},
],
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
return { user, eventType };
}
export async function createUserWithSeatedEventAndAttendees(
fixtures: Pick<Fixtures, "users" | "bookings">,
attendees: Prisma.AttendeeCreateManyBookingInput[]
) {
const { user, eventType } = await createUserWithSeatedEvent(fixtures.users);
const booking = await fixtures.bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
// startTime with 1 day from now and endTime half hour after
startTime: new Date(Date.now() + 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() + 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
attendees: {
createMany: {
data: attendees,
},
},
});
return { user, eventType, booking };
}

View File

@ -1,5 +1,9 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
import { test } from "./lib/fixtures";
import {
@ -8,6 +12,7 @@ import {
selectFirstAvailableTimeSlotNextMonth,
waitFor,
gotoRoutingLink,
createUserWithSeatedEventAndAttendees,
} from "./lib/testUtils";
// remove dynamic properties that differs depending on where you run the tests
@ -15,6 +20,29 @@ const dynamic = "[redacted/dynamic]";
test.afterEach(({ users }) => users.deleteAll());
async function createWebhookReceiver(page: Page) {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return webhookReceiver;
}
test.describe("BOOKING_CREATED", async () => {
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
@ -388,6 +416,147 @@ test.describe("BOOKING_REQUESTED", async () => {
});
});
test.describe("BOOKING_RESCHEDULED", async () => {
test("can reschedule a booking and get a booking rescheduled event", async ({ page, users, bookings }) => {
const user = await users.create();
const [eventType] = user.eventTypes;
await user.apiLogin();
const webhookReceiver = await createWebhookReceiver(page);
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
});
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking?.uid } })!;
expect(newBooking).not.toBeNull();
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
expect(request.body).toMatchObject({
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
uid: newBooking?.uid,
},
});
});
test("when rescheduling to a booking that already exists, should send a booking rescheduled event with the existant booking uid", async ({
page,
users,
bookings,
}) => {
const { user, eventType, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
]);
await prisma.eventType.update({
where: { id: eventType.id },
data: { requiresConfirmation: false },
});
await user.apiLogin();
const webhookReceiver = await createWebhookReceiver(page);
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
select: {
id: true,
email: true,
},
});
const bookingSeats = bookingAttendees.map((attendee) => ({
bookingId: booking.id,
attendeeId: attendee.id,
referenceUid: uuidv4(),
}));
await prisma.bookingSeat.createMany({
data: bookingSeats,
});
const references = await prisma.bookingSeat.findMany({
where: { bookingId: booking.id },
include: { attendee: true },
});
await page.goto(`/reschedule/${references[0].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
const newBooking = await prisma.booking.findFirst({
where: {
attendees: {
some: {
email: bookingAttendees[0].email,
},
},
},
});
// --- ensuring that new booking was created
expect(newBooking).not.toBeNull();
// --- check that webhook was called
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [firstRequest] = webhookReceiver.requestList;
expect(firstRequest?.body).toMatchObject({
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
uid: newBooking?.uid,
},
});
await page.goto(`/reschedule/${references[1].referenceUid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(2);
});
const [_, secondRequest] = webhookReceiver.requestList;
expect(secondRequest?.body).toMatchObject({
triggerEvent: "BOOKING_RESCHEDULED",
payload: {
// in the current implementation, it is the same as the first booking
uid: newBooking?.uid,
},
});
});
});
test.describe("FORM_SUBMITTED", async () => {
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();

View File

@ -1078,7 +1078,7 @@
"url_start_with_https": "يجب أن يبدأ العنوان بـ http:// أو https://",
"number_provided": "سيتم توفير رقم الهاتف",
"before_event_trigger": "قبل بدء الحدث",
"event_cancelled_trigger": "متى تم إلغاء هذا الحدث",
"event_cancelled_trigger": "عند إلغاء هذا الحدث",
"new_event_trigger": "متى تم حجز الحدث الجديد",
"email_host_action": "إرسال رسالة إلكترونية إلى المضيف",
"email_attendee_action": "إرسال رسالة إلكترونية إلى الحضور",
@ -1833,7 +1833,7 @@
"invite_link_copied": "تم نسخ رابط الدعوة",
"invite_link_deleted": "تم حذف رابط الدعوة",
"invite_link_updated": "تم حفظ إعدادات رابط الدعوة",
"link_expires_after": "تم تعيين الروابط للانتهاء بعد...",
"link_expires_after": "تم تعيين انتهاء صلاحية الروابط بعد...",
"one_day": "1 يوم",
"seven_days": "7 أيام",
"thirty_days": "30 يومًا",
@ -1884,7 +1884,7 @@
"organization_verify_email_body": "الرجاء استخدام الرمز أدناه لتأكيد عنوان بريدك الإلكتروني لمواصلة إعداد منظمتك.",
"additional_url_parameters": "معلمات الرابط الإضافية",
"about_your_organization": "حول منظمتك",
"about_your_organization_description": "المنظمات هي بيئات مشتركة حيث يمكنك إنشاء فرق متعددة مع أعضاء مشتركين وأنواع الأحداث والتطبيقات ومهام سير العمل والمزيد.",
"about_your_organization_description": "المنظمات هي بيئات مشتركة حيث يمكنك إنشاء فرق متعددة مع أعضاء مشتركين وأنواع الأحداث والتطبيقات ومهام سير العمل المشتركة والمزيد.",
"create_your_teams": "إنشاء فرقك",
"create_your_teams_description": "ابدأوا الجدولة معًا عن طريق إضافة أعضاء فريقك إلى منظمتك",
"invite_organization_admins": "دعوة مشرفي منظمتك",
@ -1925,7 +1925,7 @@
"404_the_org": "المنظمة",
"404_the_team": "الفريق",
"404_claim_entity_org": "المطالبة بنطاقك الفرعي لمنظمتك",
"404_claim_entity_team": "المطالبة بهذا الفريق والبدء في إدارة الجداول الزمنية بشكل جماعي",
"404_claim_entity_team": "انضم لهذا الفريق وابدأ في إدارة الجداول الزمنية بشكل جماعي",
"insights_all_org_filter": "الكل",
"insights_team_filter": "الفريق: {{teamName}}",
"insights_user_filter": "المستخدم: {{userName}}",

View File

@ -2045,5 +2045,6 @@
"recently_added":"Recently added",
"no_members_found": "No members found",
"event_setup_length_error":"Event Setup: The duration must be at least 1 minute.",
"availability_schedules":"Availability Schedules",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -129,7 +129,7 @@
"team_upgrade_banner_description": "Gracias por probar nuestro nuevo plan de equipo. Notamos que su equipo \"{{teamName}}\" necesita actualizarse.",
"upgrade_banner_action": "Actualizar aquí",
"team_upgraded_successfully": "¡Tu equipo se actualizó con éxito!",
"org_upgrade_banner_description": "Gracias por probar nuestro nuevo plan de equipo. Notamos que su equipo \"{{teamName}}\" necesita actualizarse.",
"org_upgrade_banner_description": "Gracias por probar nuestro nuevo plan Organization. Notamos que su Organization \"{{teamName}}\" necesita actualizarse.",
"org_upgraded_successfully": "Su Organization se actualizó con éxito.",
"use_link_to_reset_password": "Utilice el enlace de abajo para restablecer su contraseña",
"hey_there": "Hola,",
@ -306,7 +306,7 @@
"password_has_been_reset_login": "Su contraseña ha sido restablecida. Ahora puede iniciar sesión con su nueva contraseña.",
"layout": "Diseño",
"bookerlayout_default_title": "Vista predeterminada",
"bookerlayout_description": "Puede seleccionar varios y quienes le reserven pueden cambiar de vista.",
"bookerlayout_description": "Puede seleccionar varias y quienes le reserven pueden cambiar de vista.",
"bookerlayout_user_settings_title": "Diseño de reserva",
"bookerlayout_user_settings_description": "Puede seleccionar varios y quienes le reservan pueden cambiar de vista. Esto se puede anular por evento.",
"bookerlayout_month_view": "Mes",
@ -315,7 +315,7 @@
"bookerlayout_error_min_one_enabled": "Al menos un diseño tiene que estar habilitado.",
"bookerlayout_error_default_not_enabled": "El diseño que seleccionó como vista predeterminada no es parte de los diseños habilitados.",
"bookerlayout_error_unknown_layout": "El diseño seleccionado no es un diseño válido.",
"bookerlayout_override_global_settings": "Puede gestionar esto para todos sus tipos de eventos en <2>configuración / apariencia</2> o <6>sobrescribir solo para este evento</6>.",
"bookerlayout_override_global_settings": "Puede gestionar esto para todos sus tipos de eventos en <2>configuración / apariencia</2> o <6>anular solo para este evento</6>.",
"unexpected_error_try_again": "Ocurrió un error inesperado. Inténtelo de nuevo.",
"sunday_time_error": "Hora inválida del domingo",
"monday_time_error": "Hora inválida del lunes",
@ -551,7 +551,7 @@
"team_description": "Comentarios sobre tu equipo. Esta información aparecerá en la página de la URL de tu equipo.",
"org_description": "Algunas frases sobre su organización. Esto aparecerá en la página de la URL de su organización.",
"members": "Miembros",
"organization_members": "Miembros de la organización",
"organization_members": "Miembros de Organization",
"member": "Miembro",
"number_member_one": "{{count}} miembro",
"number_member_other": "{{count}} miembros",
@ -699,7 +699,7 @@
"create_team_to_get_started": "Crea un equipo para empezar",
"teams": "Equipos",
"team": "Equipo",
"organization": "Organización",
"organization": "Organization",
"team_billing": "Facturación del equipo",
"team_billing_description": "Gestione la facturación para su equipo",
"upgrade_to_flexible_pro_title": "Hemos cambiado la facturación de los equipos",
@ -1916,7 +1916,7 @@
"org_no_teams_yet_description": "Si usted es un administrador, asegúrese de crear equipos para que se muestren aquí.",
"set_up": "Configurar",
"set_up_your_profile": "Configure su perfil",
"set_up_your_profile_description": "Informe a las personas quién es usted dentro de {{orgName}} cuándo interactúan con su enlace público.",
"set_up_your_profile_description": "Informe a las personas quién es usted dentro de {{orgName}} y cuándo interactúen con su enlace público.",
"my_profile": "Mi perfil",
"my_settings": "Mi configuración",
"crm": "CRM",

View File

@ -1060,7 +1060,7 @@
"your_unique_api_key": "Votre clé API unique",
"copy_safe_api_key": "Copiez cette clé API et conservez-la dans un endroit sûr. Si vous perdez cette clé, vous devrez en générer une nouvelle.",
"zapier_setup_instructions": "<0>Connectez-vous à votre compte Zapier et créez un nouveau Zap.</0><1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre déclencheur.</3><4>Vous êtes prêt !</4>",
"make_setup_instructions": "<0>Connectez-vous à votre compte Make et créez un nouveau Scénario.</0><1>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</1><2>Choisissez votre compte, puis saisissez votre clé API unique.</2><3>Testez votre déclencheur.</3><4>Vous êtes prêt !</4>",
"make_setup_instructions": "<0>Accédez au <1><0>lien d'invitation Make</0></1> et installez l'application Cal.com.</0><1>Connectez-vous à votre compte Make et créez un nouveau scénario.</1><2>Sélectionnez Cal.com comme application déclencheur. Choisissez également un événement déclencheur.</2><3>Choisissez votre compte, puis saisissez votre clé API unique.</3><4>Testez votre déclencheur.</4><5>Vous êtes prêt !</5>",
"install_zapier_app": "Veuillez d'abord installer l'application Zapier dans l'App Store.",
"install_make_app": "Veuillez d'abord installer l'application Make dans l'App Store.",
"connect_apple_server": "Se connecter au serveur d'Apple",
@ -1688,8 +1688,11 @@
"delete_sso_configuration_confirmation_description": "Voulez-vous vraiment supprimer la configuration {{connectionType}} ? Les membres de votre équipe utilisant la connexion {{connectionType}} ne pourront plus accéder à Cal.com.",
"organizer_timezone": "Fuseau horaire de l'organisateur",
"email_user_cta": "Voir l'invitation",
"email_no_user_invite_heading_team": "Vous avez été invité à rejoindre une équipe {{appName}}",
"email_no_user_invite_heading_org": "Vous avez été invité à rejoindre une organisation {{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} vous a invité à rejoindre son équipe sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre équipe d'organiser des rendez-vous sans échanges d'e-mails.",
"email_user_invite_subheading_team": "{{invitedBy}} vous a invité à rejoindre son équipe « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre équipe d'organiser des rendez-vous sans échanges d'e-mails.",
"email_user_invite_subheading_org": "{{invitedBy}} vous a invité à rejoindre son organisation « {{teamName}} » sur {{appName}}. {{appName}} est le planificateur d'événements qui vous permet à vous et à votre organisation d'organiser des rendez-vous sans échanges d'e-mails.",
"email_no_user_invite_steps_intro": "Nous vous guiderons à travers quelques étapes courtes et vous profiterez d'une planification sans stress avec votre {{entity}} en un rien de temps.",
"email_no_user_step_one": "Choisissez votre nom d'utilisateur",
"email_no_user_step_two": "Connectez votre compte de calendrier",
@ -2041,5 +2044,7 @@
"include_calendar_event": "Inclure l'événement du calendrier",
"recently_added": "Ajouté récemment",
"no_members_found": "Aucun membre trouvé",
"event_setup_length_error": "Configuration de l'événement : la durée doit être d'au moins 1 minute.",
"availability_schedules": "Horaires de disponibilité",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -306,7 +306,7 @@
"password_has_been_reset_login": "パスワードがリセットされました。新しく作成したパスワードでログインできるようになりました。",
"layout": "レイアウト",
"bookerlayout_default_title": "デフォルトの表示",
"bookerlayout_description": "複数のものを選択することができ、予約者は表示を切り替えることができます。",
"bookerlayout_description": "複数のビューを選択することができ、予約者は表示を切り替えることができます。",
"bookerlayout_user_settings_title": "予約のレイアウト",
"bookerlayout_user_settings_description": "複数のものを選択することができ、予約者は表示を切り替えることができます。これはイベントごとに上書きが可能です。",
"bookerlayout_month_view": "月",
@ -391,7 +391,7 @@
"user_dynamic_booking_disabled": "グループ内の一部のユーザーは、現在動的なグループ予約を無効にしています",
"allow_dynamic_booking_tooltip": "\"+\" を使って複数のユーザー名を追加することで動的に作成できるグループ予約リンク。例: \"{{appName}}/bailey+peer\"",
"allow_dynamic_booking": "出席者が動的なグループ予約を通じてあなたを予約できるようにする",
"dynamic_booking": "動的なグループリンク",
"dynamic_booking": "ダイナミックグループリンク",
"email": "Eメールアドレス",
"email_placeholder": "jdoe@example.com",
"full_name": "フルネーム",
@ -555,7 +555,7 @@
"member": "メンバー",
"number_member_one": "{{count}} 人のメンバー",
"number_member_other": "{{count}} 人のメンバー",
"number_selected": "{{count}} が選択されました",
"number_selected": "{{count}} が選択されました",
"owner": "所有者",
"admin": "管理者",
"administrator_user": "管理者ユーザー",
@ -1150,8 +1150,8 @@
"choose_template": "テンプレートを選択する",
"custom": "カスタム",
"reminder": "リマインダー",
"rescheduled": "スケジュール変更済み",
"completed": "完了",
"rescheduled": "スケジュールが変更されました",
"completed": "完了しました",
"reminder_email": "リマインダー:{{date}} の {{name}} との {{eventType}}",
"not_triggering_existing_bookings": "イベントの予約時に電話番号の入力を求められるため、すでにある予約はトリガーされません。",
"minute_one": "{{count}} 分",
@ -1672,7 +1672,7 @@
"scheduler": "{Scheduler}",
"no_workflows": "ワークフローがありません",
"change_filter": "個人やチームのワークフローを表示するためのフィルターを変更します。",
"change_filter_common": "結果を表示するフィルターを変更します。",
"change_filter_common": "フィルターを変更して結果を表示します。",
"no_results_for_filter": "このフィルターに該当する結果はありません",
"recommended_next_steps": "推奨される次のステップ",
"create_a_managed_event": "管理されたイベントの種類を作成",
@ -1696,7 +1696,7 @@
"booking_questions_title": "予約の質問",
"booking_questions_description": "予約ページで尋ねる質問をカスタマイズする",
"add_a_booking_question": "質問を追加",
"identifier": "識別子",
"identifier": "ID",
"duplicate_email": "メールが重複しています",
"booking_with_payment_cancelled": "このイベントの支払いはもうできません",
"booking_with_payment_cancelled_already_paid": "この予約に関するお支払いの払い戻しについては、現在処理中です。",
@ -1840,7 +1840,7 @@
"invite_link_copied": "招待リンクをコピーしました",
"invite_link_deleted": "招待リンクを削除しました",
"invite_link_updated": "招待リンクの設定を保存しました",
"link_expires_after": "リンクが期限切れとなるまで、あと...",
"link_expires_after": "リンクの期限切れまで...",
"one_day": "1 日",
"seven_days": "7 日",
"thirty_days": "30 日",
@ -1936,7 +1936,7 @@
"insights_all_org_filter": "すべて",
"insights_team_filter": "チーム: {{teamName}}",
"insights_user_filter": "ユーザー: {{userName}}",
"insights_subtitle": "イベント全体での予約に関する分析情報を表示する",
"insights_subtitle": "イベント全体での予約に関する Insights を表示する",
"custom_plan": "カスタムプラン",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -12,7 +12,7 @@
"have_any_questions": "Tem perguntas? Estamos disponíveis para ajudar.",
"reset_password_subject": "{{appName}}: Instruções de redefinição da senha",
"verify_email_subject": "{{appName}}: Verifique a sua conta",
"check_your_email": "Confirme o seu e-mail",
"check_your_email": "Verifique o seu e-mail",
"verify_email_page_body": "Enviámos um e-mail para {{email}}. É importante verificar o seu endereço de e-mail para garantir que receberá as comunicações de {{appName}}.",
"verify_email_banner_body": "Confirme o seu endereço de e-mail para garantir a melhor entrega possível de e-mail e de agenda",
"verify_email_email_header": "Confirme o seu endereço de e-mail",

View File

@ -118,7 +118,7 @@
"team_info": "Информация о команде",
"request_another_invitation_email": "Если вы не хотите использовать {{toEmail}} как ваш {{appName}} адрес электронной почты или уже есть аккаунт {{appName}}, пожалуйста, запросите другое приглашение на это письмо.",
"you_have_been_invited": "Вас пригласили присоединиться к команде {{teamName}}",
"user_invited_you": "{{user}} пригласил вас в команду {{entity}} {{team}} в {{appName}}",
"user_invited_you": "{{user}} пригласил вас в команду {{team}} {{entity}} в {{appName}}",
"hidden_team_member_title": "В этой команде вы скрытый пользователь",
"hidden_team_member_message": "Ваше место не оплачено. Перейдите на аккаунт PRO или свяжитесь с руководителем команды, чтобы сообщить ему, что он может оплатить ваше место.",
"hidden_team_owner_message": "Чтобы работать с командами, необходим аккаунт Pro. До перехода на этот тариф Вы остаетесь скрытым пользователем.",
@ -403,7 +403,7 @@
"recording_ready": "Ссылка для скачивания записи готова",
"booking_created": "Бронирование создано",
"booking_rejected": "Бронирование отклонено",
"booking_requested": "Поступил запрос на бронирование",
"booking_requested": "Запрос на бронирование отправлен",
"meeting_ended": "Встреча завершилась",
"form_submitted": "Форма отправлена",
"event_triggers": "Триггеры событий",
@ -1879,7 +1879,7 @@
"create_for": "Создать для",
"organization_banner_description": "Создавайте рабочие среды, в рамках которых ваши команды смогут создавать общие приложения, рабочие процессы и типы событий с назначением участников по очереди и коллективным планированием.",
"organization_banner_title": "Управляйте организациями с несколькими командами",
"set_up_your_organization": "Настройте организацию",
"set_up_your_organization": "Настройка профиля организации",
"organizations_description": "Организация — это общая рабочая среда, в которой команды могут создавать общие типы событий, приложения, рабочие процессы и многое другое.",
"must_enter_organization_name": "Необходимо ввести название организации",
"must_enter_organization_admin_email": "Необходимо ввести ваш адрес электронной почты в организации",
@ -1887,7 +1887,7 @@
"admin_username": "Имя пользователя администратора",
"organization_name": "Название организации",
"organization_url": "URL-адрес организации",
"organization_verify_header": "Подтвердите адрес электронной почты вашей организации",
"organization_verify_header": "Подтвердите свой адрес электронной почты в организации",
"organization_verify_email_body": "С помощью кода ниже подтвердите свой адрес электронной почты, чтобы продолжить настройку организации.",
"additional_url_parameters": "Дополнительные параметры URL-адреса",
"about_your_organization": "О вашей организации",

View File

@ -130,7 +130,7 @@
"team_upgrade_banner_description": "Hvala vam što isprobavate naš novi plan za timove. Primetili smo da vaš tim „{{teamName}}“ treba da se nadogradi.",
"upgrade_banner_action": "Nadogradite ovde",
"team_upgraded_successfully": "Vaš tim je uspešno pretplaćen!",
"org_upgrade_banner_description": "Hvala što isprobavate plan naše organizacije. Primetili smo da tim vaše organizacije „{{teamName}}” treba da se nadogradi.",
"org_upgrade_banner_description": "Hvala što isprobavate naš Organization plan. Primetili smo da vaš Organization „{{teamName}}” treba da se nadogradi.",
"org_upgraded_successfully": "Vaš Organization je uspešno nadograđen!",
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
"hey_there": "Zdravo,",

View File

@ -93,6 +93,12 @@
--cal-brand-text: black;
}
@layer base {
* {
@apply border-default
}
}
::-moz-selection {
color: var(--cal-brand-text);
background: var(--cal-brand);

View File

@ -3,15 +3,15 @@ import type { App_RoutingForms_Form } from "@prisma/client";
import { BaseEmailHtml, Info } from "@calcom/emails/src/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import type { Response } from "../../types/types";
import type { OrderedResponses } from "../../types/types";
export const ResponseEmail = ({
form,
response,
orderedResponses,
...props
}: {
form: Pick<App_RoutingForms_Form, "id" | "name">;
response: Response;
orderedResponses: OrderedResponses;
subject: string;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>) => {
return (
@ -36,11 +36,11 @@ export const ResponseEmail = ({
title={form.name}
subtitle="New Response Received"
{...props}>
{Object.entries(response).map(([fieldId, fieldResponse]) => {
{orderedResponses.map((fieldResponse, index) => {
return (
<Info
withSpacer
key={fieldId}
key={index}
label={fieldResponse.label}
description={
fieldResponse.value instanceof Array ? fieldResponse.value.join(",") : fieldResponse.value

View File

@ -3,17 +3,25 @@ import type { App_RoutingForms_Form } from "@prisma/client";
import { renderEmail } from "@calcom/emails";
import BaseEmail from "@calcom/emails/templates/_base-email";
import type { Response } from "../../types/types";
import type { OrderedResponses } from "../../types/types";
type Form = Pick<App_RoutingForms_Form, "id" | "name">;
export default class ResponseEmail extends BaseEmail {
response: Response;
orderedResponses: OrderedResponses;
toAddresses: string[];
form: Form;
constructor({ toAddresses, response, form }: { form: Form; toAddresses: string[]; response: Response }) {
constructor({
toAddresses,
orderedResponses,
form,
}: {
form: Form;
toAddresses: string[];
orderedResponses: OrderedResponses;
}) {
super();
this.form = form;
this.response = response;
this.orderedResponses = orderedResponses;
this.toAddresses = toAddresses;
}
@ -26,7 +34,7 @@ export default class ResponseEmail extends BaseEmail {
subject,
html: renderEmail("ResponseEmail", {
form: this.form,
response: this.response,
orderedResponses: this.orderedResponses,
subject,
}),
};

View File

@ -6,6 +6,7 @@ import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
import type { OrderedResponses } from "../types/types";
import type { Response, SerializableForm } from "../types/types";
export async function onFormSubmission(
@ -61,23 +62,28 @@ export async function onFormSubmission(
});
await Promise.all(promises);
const orderedResponses = form.fields.reduce((acc, field) => {
acc.push(response[field.id]);
return acc;
}, [] as OrderedResponses);
if (form.settings?.emailOwnerOnSubmission) {
logger.debug(
`Preparing to send Form Response email for Form:${form.id} to form owner: ${form.user.email}`
);
await sendResponseEmail(form, response, form.user.email);
await sendResponseEmail(form, orderedResponses, form.user.email);
}
}
export const sendResponseEmail = async (
form: Pick<App_RoutingForms_Form, "id" | "name">,
response: Response,
orderedResponses: OrderedResponses,
ownerEmail: string
) => {
try {
if (typeof window === "undefined") {
const { default: ResponseEmail } = await import("../emails/templates/response-email");
const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], response: response });
const email = new ResponseEmail({ form: form, toAddresses: [ownerEmail], orderedResponses });
await email.sendEmail();
}
} catch (e) {

View File

@ -45,3 +45,5 @@ export type SerializableRoute =
isFallback?: LocalRoute["isFallback"];
})
| GlobalRoute;
export type OrderedResponses = Response[string][];

View File

@ -57,8 +57,12 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
if (variable === bookingField) {
let fieldValue;
if (eventNameObj.bookingFields) {
fieldValue =
eventNameObj.bookingFields[bookingField as keyof typeof eventNameObj.bookingFields]?.toString();
const field = eventNameObj.bookingFields[bookingField as keyof typeof eventNameObj.bookingFields];
if (field && typeof field === "object" && "value" in field) {
fieldValue = field?.value?.toString();
} else {
fieldValue = field?.toString();
}
}
dynamicEventName = dynamicEventName.replace(`{${variable}}`, fieldValue || "");
}

View File

@ -21,11 +21,7 @@ function RenderIcon({
return (
<img
src={eventLocationType.iconUrl}
className={classNames(
"me-[10px] h-4 w-4 opacity-70 dark:opacity-100",
eventLocationType.iconUrl?.includes("-dark") ? "dark:invert-[.65]" : "",
eventLocationType.iconUrl?.includes("-dark") && isTooltip && "invert"
)}
className="me-[10px] h-4 w-4 opacity-70 invert-[.65] dark:invert-0"
alt={`${eventLocationType.label} icon`}
/>
);

View File

@ -1756,6 +1756,7 @@ async function handler(
const webhookData = {
...evt,
...eventTypeInfo,
uid: resultBooking?.uid || uid,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime

View File

@ -1,3 +1,4 @@
import { useEffect } from "react";
import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
@ -100,40 +101,6 @@ const NoAvailabilityOverlay = ({
);
};
/**
* Takes care of selecting a valid date in the month if the selected date is not available in the month
*/
const useHandleInitialDateSelection = ({
daysToRenderForTheMonth,
selected,
onChange,
}: {
daysToRenderForTheMonth: { day: Dayjs | null; disabled: boolean }[];
selected: Dayjs | Dayjs[] | null | undefined;
onChange: (date: Dayjs | null) => void;
}) => {
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
if (selected instanceof Array) {
return;
}
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day;
const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true;
})
: false;
if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
onChange(firstAvailableDateOfTheMonth);
}
if (!firstAvailableDateOfTheMonth) {
onChange(null);
}
};
const Days = ({
minDate = dayjs.utc(),
excludedDates = [],
@ -218,11 +185,34 @@ const Days = ({
};
});
useHandleInitialDateSelection({
daysToRenderForTheMonth,
selected,
onChange: props.onChange,
});
/**
* Takes care of selecting a valid date in the month if the selected date is not available in the month
*/
const useHandleInitialDateSelection = () => {
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
if (selected instanceof Array) {
return;
}
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day;
const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true;
})
: false;
if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
props.onChange(firstAvailableDateOfTheMonth);
}
if (!firstAvailableDateOfTheMonth) {
props.onChange(null);
}
};
useEffect(useHandleInitialDateSelection);
return (
<>

View File

@ -0,0 +1,31 @@
import classNames from "@calcom/lib/classNames";
import { Avatar } from "@calcom/ui";
import type { AvatarProps } from "@calcom/ui";
type OrganizationAvatarProps = AvatarProps & {
organizationSlug: string | null | undefined;
};
const OrganizationAvatar = ({ size, imageSrc, alt, organizationSlug, ...rest }: OrganizationAvatarProps) => {
return (
<Avatar
size={size}
imageSrc={imageSrc}
alt={alt}
indicator={
organizationSlug ? (
<div
className={classNames("absolute bottom-0 right-0 z-10", size === "lg" ? "h-3 w-3" : "h-10 w-10")}>
<img
src={`/org/${organizationSlug}/avatar.png`}
alt={alt}
className="flex h-full items-center justify-center rounded-full ring-2 ring-white"
/>
</div>
) : null
}
/>
);
};
export default OrganizationAvatar;

View File

@ -103,7 +103,7 @@ export function AvailabilitySliderTable() {
.padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
return (
<div className="flex flex-col">
<div className="flex flex-col text-center">
<span className="text-default text-sm font-medium">{time}</span>
<span className="text-subtle text-xs leading-none">GMT {offsetFormatted}</span>
</div>

View File

@ -5,7 +5,7 @@ import { useOrgBranding } from "@calcom/ee/organizations/context/provider";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Sheet, SheetContent, SheetFooter, Avatar, Skeleton, Loader } from "@calcom/ui";
import { Sheet, SheetContent, SheetFooter, Avatar, Skeleton, Loader, Label } from "@calcom/ui";
import type { State, Action } from "../UserListTable";
import { DisplayInfo } from "./DisplayInfo";
@ -69,14 +69,23 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis
/>
<DisplayInfo label={t("role")} value={loadedUser?.role ?? ""} asBadge badgeColor="blue" />
<DisplayInfo label={t("timezone")} value={loadedUser?.timeZone ?? ""} />
<DisplayInfo
label={t("availability_schedules")}
value={
schedulesNames && schedulesNames?.length === 0
? [t("user_has_no_schedules")]
: schedulesNames ?? "" // TS wtf
}
/>
<div className="flex flex-col">
<Label className="text-subtle mb-1 text-xs font-semibold uppercase leading-none">
{t("availability_schedules")}
</Label>
<div className="flex flex-col">
{schedulesNames
? schedulesNames.map((scheduleName) => (
<span
key={scheduleName}
className="text-emphasis inline-flex items-center gap-1 text-sm font-normal leading-5">
{scheduleName}
</span>
))
: t("user_has_no_schedules")}
</div>
</div>
<DisplayInfo
label={t("teams")}
displayCount={teamNames?.length ?? 0}

View File

@ -64,6 +64,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
id: true,
slug: true,
metadata: true,
name: true,
members: {
select: { userId: true },
where: {

View File

@ -14,7 +14,7 @@ import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/Syn
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import { prisma } from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
@ -31,11 +31,10 @@ type UpdateProfileOptions = {
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const { metadata: metadataFromInput } = input;
const cleanMetadata = cleanMetadataAllowedUpdateKeys(metadataFromInput);
const userMetadata = handleUserMetadata({ ctx, input });
const data: Prisma.UserUpdateInput = {
...input,
metadata: cleanMetadata,
metadata: userMetadata,
};
// some actions can invalidate a user session.
@ -65,26 +64,9 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
data.avatar = await resizeBase64Image(input.avatar);
}
const fetchUserCurrentMetadata = await prisma.user.findUnique({
where: {
id: user.id,
},
select: {
metadata: true,
},
});
const metadata = userMetadata.parse(fetchUserCurrentMetadata?.metadata);
// Required so we don't override and delete saved values
data.metadata = {
...metadata,
cleanMetadata,
};
const isPremium = metadata?.isPremium;
if (isPremiumUsername) {
const stripeCustomerId = metadata?.stripeCustomerId;
const stripeCustomerId = userMetadata?.stripeCustomerId;
const isPremium = userMetadata?.isPremium;
if (!isPremium || !stripeCustomerId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" });
}
@ -199,12 +181,21 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
if (!metadata) {
return {} as Prisma.InputJsonValue;
return {};
}
const cleanedMetadata = updateUserMetadataAllowedKeys.safeParse(metadata);
if (!cleanedMetadata.success) {
logger.error("Error cleaning metadata", cleanedMetadata.error);
return {};
}
return cleanedMetadata as Prisma.InputJsonValue;
return cleanedMetadata.data;
};
const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const cleanMetadata = cleanMetadataAllowedUpdateKeys(input.metadata);
const userMetadata = userMetadataSchema.parse(user.metadata);
// Required so we don't override and delete saved values
return { ...userMetadata, ...cleanMetadata };
};

View File

@ -275,8 +275,7 @@ export const getByViewerHandler = async ({ ctx, input }: GetByViewerOptions) =>
const bookerUrl = await getBookerUrl(user);
return {
// don't display event teams without event types,
eventTypeGroups: eventTypeGroups.filter((groupBy) => groupBy.parentId || !!groupBy.eventTypes?.length),
eventTypeGroups,
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
...group.profile,

View File

@ -7,7 +7,6 @@ import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import type { Maybe } from "@trpc/server";
import { Check } from "../icon";
import { Tooltip } from "../tooltip";
export type AvatarProps = {
@ -20,6 +19,7 @@ export type AvatarProps = {
fallback?: React.ReactNode;
accepted?: boolean;
asChild?: boolean; // Added to ignore the outer span on the fallback component - messes up styling
indicator?: React.ReactNode;
};
const sizesPropsBySize = {
@ -34,12 +34,13 @@ const sizesPropsBySize = {
} as const;
export function Avatar(props: AvatarProps) {
const { imageSrc, size = "md", alt, title, href } = props;
const { imageSrc, size = "md", alt, title, href, indicator } = props;
const rootClass = classNames("aspect-square rounded-full", sizesPropsBySize[size]);
let avatar = (
<AvatarPrimitive.Root
className={classNames(
"bg-emphasis item-center relative inline-flex aspect-square justify-center overflow-hidden rounded-full",
"bg-emphasis item-center relative inline-flex aspect-square justify-center rounded-full",
indicator ? "overflow-visible" : "overflow-hidden",
props.className,
sizesPropsBySize[size]
)}>
@ -57,17 +58,7 @@ export function Avatar(props: AvatarProps) {
{props.fallback ? props.fallback : <img src={AVATAR_FALLBACK} alt={alt} className={rootClass} />}
</>
</AvatarPrimitive.Fallback>
{props.accepted && (
<div
className={classNames(
"text-inverted absolute bottom-0 right-0 block rounded-full bg-green-400 ring-2 ring-white",
size === "lg" ? "h-5 w-5" : "h-2 w-2"
)}>
<div className="flex h-full items-center justify-center p-[2px]">
{size === "lg" && <Check />}
</div>
</div>
)}
{indicator}
</>
</AvatarPrimitive.Root>
);

View File

@ -11,7 +11,6 @@ export type AvatarGroupProps = {
href?: string;
}[];
className?: string;
accepted?: boolean;
truncateAfter?: number;
};
@ -36,7 +35,6 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
imageSrc={item.image}
title={item.title}
alt={item.alt || ""}
accepted={props.accepted}
size={props.size}
href={item.href}
/>

View File

@ -108,7 +108,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={classNames(
"aria-selected:bg-subtle aria-selected:text-emphasis relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"aria-selected:bg-muted aria-selected:text-emphasis relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}

View File

@ -48,7 +48,13 @@ export function EmptyScreen({
</div>
)}
<div className="flex max-w-[420px] flex-col items-center">
<h2 className="text-semibold font-cal text-emphasis mt-6 text-center text-xl">{headline}</h2>
<h2
className={classNames(
"text-semibold font-cal text-emphasis text-center text-xl",
Icon && "mt-6"
)}>
{headline}
</h2>
{description && (
<div className="text-default mb-8 mt-3 text-center text-sm font-normal leading-6">
{description}

View File

@ -36,10 +36,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
({ className, ...props }, ref) => (
<tr
ref={ref}
className={classNames(
"hover:bg-subtle data-[state=selected]:bg-subtle border-subtle border-b",
className
)}
className={classNames("hover:muted data-[state=selected]:bg-muted border-subtle border-b", className)}
{...props}
/>
)