Compare commits
45 Commits
main
...
availabili
Author | SHA1 | Date |
---|---|---|
Ryukemeister | 730f9267d2 | |
Ryukemeister | f2980eb6c6 | |
Ryukemeister | 49725e11b3 | |
Ryukemeister | 508a5db62f | |
Ryukemeister | a1e7d16822 | |
Ryukemeister | 0ff0c1203b | |
Ryukemeister | 344c2c3996 | |
Ryukemeister | c7aac9bbc2 | |
Ryukemeister | fa796f07d5 | |
Ryukemeister | d4156f5dc7 | |
Ryukemeister | 97921ea035 | |
Ryukemeister | 11a78f95bd | |
Ryukemeister | 79dba1827c | |
Ryukemeister | 0d4f36c00f | |
Ryukemeister | 2e8398a255 | |
Ryukemeister | 7723cbfee7 | |
Ryukemeister | 0b2c730c72 | |
Ryukemeister | 501f9ad2fd | |
Ryukemeister | 6d514a27d7 | |
Ryukemeister | b6030c321a | |
Ryukemeister | 4b42404f4a | |
Ryukemeister | 9d3b512ef6 | |
Ryukemeister | 7a4f1fc098 | |
Ryukemeister | 7329531839 | |
Ryukemeister | 0cbac3b01d | |
Ryukemeister | fef821022e | |
Ryukemeister | 0f0b879ebe | |
Ryukemeister | 18318f8501 | |
Ryukemeister | 38ac778fbc | |
Ryukemeister | 11dde215bc | |
Ryukemeister | de0bd85ea5 | |
Ryukemeister | 2273a510f3 | |
Ryukemeister | e62bfc1825 | |
Ryukemeister | 4a7a9e391e | |
Ryukemeister | 0f6f5c716c | |
Ryukemeister | 8ba3a79f0e | |
Ryukemeister | 7adfe559ba | |
Ryukemeister | 5e9394248e | |
Ryukemeister | e948c5cfb8 | |
Ryukemeister | e4a5062008 | |
Ryukemeister | 6f14fe7f4e | |
Ryukemeister | 743a80f2b2 | |
Ryukemeister | 0b90f1b698 | |
Ryukemeister | 95bd0ef83f | |
Ryukemeister | de218e96bb |
|
@ -0,0 +1,18 @@
|
||||||
|
name: Auto Comment Merge Conflicts
|
||||||
|
on: push
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-comment-merge-conflicts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: codytseng/auto-comment-merge-conflicts@v1
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
comment-body: "Hey there, there is a merge conflict, can you take a look?"
|
||||||
|
wait-ms: 3000
|
||||||
|
max-retries: 5
|
||||||
|
label-name: "🚨 merge conflict"
|
||||||
|
ignore-authors: dependabot,otherAuthor
|
|
@ -3,6 +3,7 @@ import { z } from "zod";
|
||||||
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
||||||
|
|
||||||
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
||||||
|
credentialId: true,
|
||||||
integration: true,
|
integration: true,
|
||||||
externalId: true,
|
externalId: true,
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
|
@ -14,6 +15,7 @@ const schemaDestinationCalendarCreateParams = z
|
||||||
.object({
|
.object({
|
||||||
integration: z.string(),
|
integration: z.string(),
|
||||||
externalId: z.string(),
|
externalId: z.string(),
|
||||||
|
credentialId: z.number(),
|
||||||
eventTypeId: z.number().optional(),
|
eventTypeId: z.number().optional(),
|
||||||
bookingId: z.number().optional(),
|
bookingId: z.number().optional(),
|
||||||
userId: z.number().optional(),
|
userId: z.number().optional(),
|
||||||
|
@ -45,4 +47,5 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
|
||||||
eventTypeId: true,
|
eventTypeId: true,
|
||||||
bookingId: true,
|
bookingId: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
credentialId: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +1,6 @@
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
|
||||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
schemaDestinationCalendarEditBodyParams,
|
schemaDestinationCalendarEditBodyParams,
|
||||||
|
@ -62,251 +56,16 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||||
* 404:
|
* 404:
|
||||||
* description: Destination calendar not found
|
* description: Destination calendar not found
|
||||||
*/
|
*/
|
||||||
type DestinationCalendarType = {
|
|
||||||
userId?: number | null;
|
|
||||||
eventTypeId?: number | null;
|
|
||||||
credentialId: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserCredentialType = {
|
|
||||||
id: number;
|
|
||||||
appId: string | null;
|
|
||||||
type: string;
|
|
||||||
userId: number | null;
|
|
||||||
user: {
|
|
||||||
email: string;
|
|
||||||
} | null;
|
|
||||||
teamId: number | null;
|
|
||||||
key: Prisma.JsonValue;
|
|
||||||
invalid: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function patchHandler(req: NextApiRequest) {
|
export async function patchHandler(req: NextApiRequest) {
|
||||||
const { userId, isAdmin, prisma, query, body } = req;
|
const { prisma, query, body } = req;
|
||||||
const { id } = schemaQueryIdParseInt.parse(query);
|
const { id } = schemaQueryIdParseInt.parse(query);
|
||||||
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
|
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
|
||||||
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
|
|
||||||
|
|
||||||
validateIntegrationInput(parsedBody);
|
|
||||||
const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma);
|
|
||||||
await validateRequestAndOwnership({ destinationCalendarObject, parsedBody, assignedUserId, prisma });
|
|
||||||
|
|
||||||
const userCredentials = await getUserCredentials({
|
|
||||||
credentialId: destinationCalendarObject.credentialId,
|
|
||||||
userId: assignedUserId,
|
|
||||||
prisma,
|
|
||||||
});
|
|
||||||
const credentialId = await verifyCredentialsAndGetId({
|
|
||||||
parsedBody,
|
|
||||||
userCredentials,
|
|
||||||
currentCredentialId: destinationCalendarObject.credentialId,
|
|
||||||
});
|
|
||||||
// If the user has passed eventTypeId, we need to remove userId from the update data to make sure we don't link it to user as well
|
|
||||||
if (parsedBody.eventTypeId) parsedBody.userId = undefined;
|
|
||||||
const destinationCalendar = await prisma.destinationCalendar.update({
|
const destinationCalendar = await prisma.destinationCalendar.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { ...parsedBody, credentialId },
|
data: parsedBody,
|
||||||
});
|
});
|
||||||
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
|
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves user credentials associated with a given credential ID and user ID and validates if the credentials belong to this user
|
|
||||||
*
|
|
||||||
* @param credentialId - The ID of the credential to fetch. If not provided, an error is thrown.
|
|
||||||
* @param userId - The user ID against which the credentials need to be verified.
|
|
||||||
* @param prisma - An instance of PrismaClient for database operations.
|
|
||||||
*
|
|
||||||
* @returns - An array containing the matching user credentials.
|
|
||||||
*
|
|
||||||
* @throws HttpError - If `credentialId` is not provided or no associated credentials are found in the database.
|
|
||||||
*/
|
|
||||||
async function getUserCredentials({
|
|
||||||
credentialId,
|
|
||||||
userId,
|
|
||||||
prisma,
|
|
||||||
}: {
|
|
||||||
credentialId: number | null;
|
|
||||||
userId: number;
|
|
||||||
prisma: PrismaClient;
|
|
||||||
}) {
|
|
||||||
if (!credentialId) {
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 404,
|
|
||||||
message: `Destination calendar missing credential id`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const userCredentials = await prisma.credential.findMany({
|
|
||||||
where: { id: credentialId, userId },
|
|
||||||
select: credentialForCalendarServiceSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userCredentials || userCredentials.length === 0) {
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: `Bad request, no associated credentials found`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return userCredentials;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifies the provided credentials and retrieves the associated credential ID.
|
|
||||||
*
|
|
||||||
* This function checks if the `integration` and `externalId` properties from the parsed body are present.
|
|
||||||
* If both properties exist, it fetches the connected calendar credentials using the provided user credentials
|
|
||||||
* and checks for a matching external ID and integration from the list of connected calendars.
|
|
||||||
*
|
|
||||||
* If a match is found, it updates the `credentialId` with the one from the connected calendar.
|
|
||||||
* Otherwise, it throws an HTTP error with a 400 status indicating an invalid credential ID.
|
|
||||||
*
|
|
||||||
* If the parsed body does not contain the necessary properties, the function
|
|
||||||
* returns the `credentialId` from the destination calendar object.
|
|
||||||
*
|
|
||||||
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
|
||||||
* Checked if it contain properties like `integration` and `externalId`.
|
|
||||||
* @param userCredentials - An array of user credentials used to fetch the connected calendar credentials.
|
|
||||||
* @param destinationCalendarObject - An object representing the destination calendar. Primarily used
|
|
||||||
* to fetch the default `credentialId`.
|
|
||||||
*
|
|
||||||
* @returns - The verified `credentialId` either from the matched connected calendar in case of updating the destination calendar,
|
|
||||||
* or the provided destination calendar object in other cases.
|
|
||||||
*
|
|
||||||
* @throws HttpError - If no matching connected calendar is found for the given `integration` and `externalId`.
|
|
||||||
*/
|
|
||||||
async function verifyCredentialsAndGetId({
|
|
||||||
parsedBody,
|
|
||||||
userCredentials,
|
|
||||||
currentCredentialId,
|
|
||||||
}: {
|
|
||||||
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
|
||||||
userCredentials: UserCredentialType[];
|
|
||||||
currentCredentialId: number | null;
|
|
||||||
}) {
|
|
||||||
if (parsedBody.integration && parsedBody.externalId) {
|
|
||||||
const calendarCredentials = getCalendarCredentials(userCredentials);
|
|
||||||
|
|
||||||
const { connectedCalendars } = await getConnectedCalendars(
|
|
||||||
calendarCredentials,
|
|
||||||
[],
|
|
||||||
parsedBody.externalId
|
|
||||||
);
|
|
||||||
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
|
||||||
const calendar = eligibleCalendars?.find(
|
|
||||||
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!calendar?.credentialId)
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "Bad request, credential id invalid",
|
|
||||||
});
|
|
||||||
return calendar?.credentialId;
|
|
||||||
}
|
|
||||||
return currentCredentialId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the request for updating a destination calendar.
|
|
||||||
*
|
|
||||||
* This function checks the validity of the provided eventTypeId against the existing destination calendar object
|
|
||||||
* in the sense that if the destination calendar is not linked to an event type, the eventTypeId can not be provided.
|
|
||||||
*
|
|
||||||
* It also ensures that the eventTypeId, if provided, belongs to the assigned user.
|
|
||||||
*
|
|
||||||
* @param destinationCalendarObject - An object representing the destination calendar.
|
|
||||||
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
|
||||||
* @param assignedUserId - The user ID assigned for the operation, which might be an admin or a regular user.
|
|
||||||
* @param prisma - An instance of PrismaClient for database operations.
|
|
||||||
*
|
|
||||||
* @throws HttpError - If the validation fails or inconsistencies are detected in the request data.
|
|
||||||
*/
|
|
||||||
async function validateRequestAndOwnership({
|
|
||||||
destinationCalendarObject,
|
|
||||||
parsedBody,
|
|
||||||
assignedUserId,
|
|
||||||
prisma,
|
|
||||||
}: {
|
|
||||||
destinationCalendarObject: DestinationCalendarType;
|
|
||||||
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
|
||||||
assignedUserId: number;
|
|
||||||
prisma: PrismaClient;
|
|
||||||
}) {
|
|
||||||
if (parsedBody.eventTypeId) {
|
|
||||||
if (!destinationCalendarObject.eventTypeId) {
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: `The provided destination calendar can not be linked to an event type`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const userEventType = await prisma.eventType.findFirst({
|
|
||||||
where: { id: parsedBody.eventTypeId },
|
|
||||||
select: { userId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userEventType || userEventType.userId !== assignedUserId) {
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 404,
|
|
||||||
message: `Event type with ID ${parsedBody.eventTypeId} not found`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsedBody.eventTypeId) {
|
|
||||||
if (destinationCalendarObject.eventTypeId) {
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: `The provided destination calendar can only be linked to an event type`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (destinationCalendarObject.userId !== assignedUserId) {
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 403,
|
|
||||||
message: `Forbidden`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the destination calendar based on the provided ID as the path parameter, specifically `credentialId` and `eventTypeId`.
|
|
||||||
*
|
|
||||||
* If no matching destination calendar is found for the provided ID, an HTTP error with a 404 status
|
|
||||||
* indicating that the desired destination calendar was not found is thrown.
|
|
||||||
*
|
|
||||||
* @param id - The ID of the destination calendar to be retrieved.
|
|
||||||
* @param prisma - An instance of PrismaClient for database operations.
|
|
||||||
*
|
|
||||||
* @returns - An object containing details of the matching destination calendar, specifically `credentialId` and `eventTypeId`.
|
|
||||||
*
|
|
||||||
* @throws HttpError - If no destination calendar matches the provided ID.
|
|
||||||
*/
|
|
||||||
async function getDestinationCalendar(id: number, prisma: PrismaClient) {
|
|
||||||
const destinationCalendarObject = await prisma.destinationCalendar.findFirst({
|
|
||||||
where: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
select: { userId: true, eventTypeId: true, credentialId: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!destinationCalendarObject) {
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 404,
|
|
||||||
message: `Destination calendar with ID ${id} not found`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return destinationCalendarObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateIntegrationInput(parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>) {
|
|
||||||
if (parsedBody.integration && !parsedBody.externalId) {
|
|
||||||
throw new HttpError({ statusCode: 400, message: "External Id is required with integration value" });
|
|
||||||
}
|
|
||||||
if (!parsedBody.integration && parsedBody.externalId) {
|
|
||||||
throw new HttpError({ statusCode: 400, message: "Integration value is required with external ID" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defaultResponder(patchHandler);
|
export default defaultResponder(patchHandler);
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server";
|
import { defaultResponder } from "@calcom/lib/server";
|
||||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
schemaDestinationCalendarReadPublic,
|
schemaDestinationCalendarReadPublic,
|
||||||
|
@ -40,6 +38,9 @@ import {
|
||||||
* externalId:
|
* externalId:
|
||||||
* type: string
|
* type: string
|
||||||
* description: 'The external ID of the integration'
|
* description: 'The external ID of the integration'
|
||||||
|
* credentialId:
|
||||||
|
* type: integer
|
||||||
|
* description: 'The credential ID it is associated with'
|
||||||
* eventTypeId:
|
* eventTypeId:
|
||||||
* type: integer
|
* type: integer
|
||||||
* description: 'The ID of the eventType it is associated with'
|
* description: 'The ID of the eventType it is associated with'
|
||||||
|
@ -64,38 +65,20 @@ async function postHandler(req: NextApiRequest) {
|
||||||
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
||||||
await checkPermissions(req, userId);
|
await checkPermissions(req, userId);
|
||||||
|
|
||||||
const assignedUserId = isAdmin && parsedBody.userId ? parsedBody.userId : userId;
|
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
|
||||||
|
|
||||||
/* Check if credentialId data matches the ownership and integration passed in */
|
/* Check if credentialId data matches the ownership and integration passed in */
|
||||||
const userCredentials = await prisma.credential.findMany({
|
const credential = await prisma.credential.findFirst({
|
||||||
where: {
|
where: { type: parsedBody.integration, userId: assignedUserId },
|
||||||
type: parsedBody.integration,
|
select: { id: true, type: true, userId: true },
|
||||||
userId: assignedUserId,
|
|
||||||
},
|
|
||||||
select: credentialForCalendarServiceSelect,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (userCredentials.length === 0)
|
if (!credential)
|
||||||
throw new HttpError({
|
throw new HttpError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: "Bad request, credential id invalid",
|
message: "Bad request, credential id invalid",
|
||||||
});
|
});
|
||||||
|
|
||||||
const calendarCredentials = getCalendarCredentials(userCredentials);
|
|
||||||
|
|
||||||
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId);
|
|
||||||
|
|
||||||
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
|
||||||
const calendar = eligibleCalendars?.find(
|
|
||||||
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
|
||||||
);
|
|
||||||
if (!calendar?.credentialId)
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "Bad request, credential id invalid",
|
|
||||||
});
|
|
||||||
const credentialId = calendar.credentialId;
|
|
||||||
|
|
||||||
if (parsedBody.eventTypeId) {
|
if (parsedBody.eventTypeId) {
|
||||||
const eventType = await prisma.eventType.findFirst({
|
const eventType = await prisma.eventType.findFirst({
|
||||||
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
||||||
|
@ -108,9 +91,7 @@ async function postHandler(req: NextApiRequest) {
|
||||||
parsedBody.userId = undefined;
|
parsedBody.userId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const destination_calendar = await prisma.destinationCalendar.create({
|
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
|
||||||
data: { ...parsedBody, credentialId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
||||||
|
|
|
@ -141,6 +141,17 @@ function BookingListItem(booking: BookingItemProps) {
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const showRecordingActions: ActionType[] = [
|
||||||
|
{
|
||||||
|
id: "view_recordings",
|
||||||
|
label: t("view_recordings"),
|
||||||
|
onClick: () => {
|
||||||
|
setViewRecordingsDialogIsOpen(true);
|
||||||
|
},
|
||||||
|
disabled: mutation.isLoading,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
let bookedActions: ActionType[] = [
|
let bookedActions: ActionType[] = [
|
||||||
{
|
{
|
||||||
id: "cancel",
|
id: "cancel",
|
||||||
|
@ -259,21 +270,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||||
const bookingLink = buildBookingLink();
|
const bookingLink = buildBookingLink();
|
||||||
|
|
||||||
const title = booking.title;
|
const title = booking.title;
|
||||||
|
// To be used after we run query on legacy bookings
|
||||||
|
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
|
||||||
|
|
||||||
const showRecordingsButtons = !!(booking.isRecorded && isPast && isConfirmed);
|
const showRecordingsButtons =
|
||||||
const checkForRecordingsButton =
|
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||||
!showRecordingsButtons && (booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
|
||||||
|
|
||||||
const showRecordingActions: ActionType[] = [
|
|
||||||
{
|
|
||||||
id: checkForRecordingsButton ? "check_for_recordings" : "view_recordings",
|
|
||||||
label: checkForRecordingsButton ? t("check_for_recordings") : t("view_recordings"),
|
|
||||||
onClick: () => {
|
|
||||||
setViewRecordingsDialogIsOpen(true);
|
|
||||||
},
|
|
||||||
disabled: mutation.isLoading,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -298,7 +299,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||||
paymentCurrency={booking.payment[0].currency}
|
paymentCurrency={booking.payment[0].currency}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(showRecordingsButtons || checkForRecordingsButton) && (
|
{showRecordingsButtons && (
|
||||||
<ViewRecordingsDialog
|
<ViewRecordingsDialog
|
||||||
booking={booking}
|
booking={booking}
|
||||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||||
|
@ -468,9 +469,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||||
{(showRecordingsButtons || checkForRecordingsButton) && (
|
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||||
<TableActions actions={showRecordingActions} />
|
|
||||||
)}
|
|
||||||
{isCancelled && booking.rescheduled && (
|
{isCancelled && booking.rescheduled && (
|
||||||
<div className="hidden h-full items-center md:flex">
|
<div className="hidden h-full items-center md:flex">
|
||||||
<RequestSentMessage />
|
<RequestSentMessage />
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.4.6",
|
"version": "3.4.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|
|
@ -62,46 +62,6 @@ const triggerWebhook = async ({
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkIfUserIsPartOfTheSameTeam = async (
|
|
||||||
teamId: number | undefined | null,
|
|
||||||
userId: number,
|
|
||||||
userEmail: string | undefined | null
|
|
||||||
) => {
|
|
||||||
if (!teamId) return false;
|
|
||||||
|
|
||||||
const getUserQuery = () => {
|
|
||||||
if (!!userEmail) {
|
|
||||||
return {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: userEmail,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
id: userId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const team = await prisma.team.findFirst({
|
|
||||||
where: {
|
|
||||||
id: teamId,
|
|
||||||
members: {
|
|
||||||
some: {
|
|
||||||
user: getUserQuery(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!team;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
||||||
return res.status(405).json({ message: "No SendGrid API key or email" });
|
return res.status(405).json({ message: "No SendGrid API key or email" });
|
||||||
|
@ -177,22 +137,12 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|
||||||
const isUserAttendeeOrOrganiser =
|
const isUserAttendeeOrOrganiser =
|
||||||
booking?.user?.id === session.user.id ||
|
booking?.user?.id === session.user.id ||
|
||||||
attendeesList.find(
|
attendeesList.find((attendee) => attendee.id === session.user.id);
|
||||||
(attendee) => attendee.id === session.user.id || attendee.email === session.user.email
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isUserAttendeeOrOrganiser) {
|
if (!isUserAttendeeOrOrganiser) {
|
||||||
const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam(
|
return res.status(403).send({
|
||||||
booking?.eventType?.teamId,
|
message: "Unauthorised",
|
||||||
session.user.id,
|
});
|
||||||
session.user.email
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isUserMemberOfTheTeam) {
|
|
||||||
return res.status(403).send({
|
|
||||||
message: "Unauthorised",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
|
@ -252,7 +202,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
|
||||||
return res.status(403).json({ message: "User does not have team plan to send out emails" });
|
return res.status(403).json({ message: "User does not have team plan to send out emails" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Error in /recorded-daily-video", err);
|
console.warn("something_went_wrong", err);
|
||||||
return res.status(500).json({ message: "something went wrong" });
|
return res.status(500).json({ message: "something went wrong" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -342,17 +342,14 @@ export default function Success(props: SuccessProps) {
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
shouldAlignCentrally ? "text-center" : "",
|
shouldAlignCentrally ? "text-center" : "",
|
||||||
"flex items-end justify-center px-4 pb-20 pt-4 sm:flex sm:p-0"
|
"flex items-end justify-center px-4 pb-20 pt-4 sm:block sm:p-0"
|
||||||
)}>
|
)}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : " inset-0")}
|
||||||
"main my-4 flex flex-col transition-opacity sm:my-0 ",
|
|
||||||
isEmbed ? "" : " inset-0"
|
|
||||||
)}
|
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
"main inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||||
!isBackgroundTransparent && " bg-default dark:bg-muted border-booker border-booker-width",
|
!isBackgroundTransparent && " bg-default dark:bg-muted border-booker border-booker-width",
|
||||||
"px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
|
"px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -164,13 +164,14 @@ const PasswordView = ({ user }: PasswordViewProps) => {
|
||||||
<>
|
<>
|
||||||
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||||
<div className="border-subtle rounded-b-xl border border-t-0 px-4 py-6 sm:px-6">
|
<div>
|
||||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
<div className="mt-6">
|
||||||
{t("account_managed_by_identity_provider", {
|
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||||
provider: identityProviderNameMap[user.identityProvider],
|
{t("account_managed_by_identity_provider", {
|
||||||
})}
|
provider: identityProviderNameMap[user.identityProvider],
|
||||||
</h2>
|
})}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
<p className="text-subtle mt-1 text-sm">
|
<p className="text-subtle mt-1 text-sm">
|
||||||
{t("account_managed_by_identity_provider_description", {
|
{t("account_managed_by_identity_provider_description", {
|
||||||
provider: identityProviderNameMap[user.identityProvider],
|
provider: identityProviderNameMap[user.identityProvider],
|
||||||
|
@ -179,7 +180,7 @@ const PasswordView = ({ user }: PasswordViewProps) => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||||
<div className="border-subtle border-x px-4 py-6 sm:px-6">
|
<div className="border-x px-4 py-6 sm:px-6">
|
||||||
{formMethods.formState.errors.apiError && (
|
{formMethods.formState.errors.apiError && (
|
||||||
<div className="pb-6">
|
<div className="pb-6">
|
||||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||||
|
|
|
@ -1,441 +0,0 @@
|
||||||
import { loginUser } from "../fixtures/regularBookings";
|
|
||||||
import { test } from "../lib/fixtures";
|
|
||||||
|
|
||||||
test.describe("Booking With Phone Question and Each Other Question", () => {
|
|
||||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page, users, bookingPage }) => {
|
|
||||||
await loginUser(users);
|
|
||||||
await page.goto("/event-types");
|
|
||||||
await bookingPage.goToEventType("30 min");
|
|
||||||
await bookingPage.goToTab("event_advanced_tab_title");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and Address Question", () => {
|
|
||||||
test("Select required and Address required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and Address question (both required)",
|
|
||||||
secondQuestion: "address",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and Address not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and Address question (only select required)",
|
|
||||||
secondQuestion: "address",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and checkbox group Question", () => {
|
|
||||||
test("Select required and checkbox group required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and Checkbox question (both required)",
|
|
||||||
secondQuestion: "checkbox",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and checkbox group not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and Checkbox question (only Select required)",
|
|
||||||
secondQuestion: "checkbox",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and checkbox Question", () => {
|
|
||||||
test("Select required and checkbox required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and boolean question (both required)",
|
|
||||||
secondQuestion: "boolean",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and checkbox not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and boolean question (only select required)",
|
|
||||||
secondQuestion: "boolean",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and Long text Question", () => {
|
|
||||||
test("Select required and Long text required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and textarea question (both required)",
|
|
||||||
secondQuestion: "textarea",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and Long text not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and textarea question (only select required)",
|
|
||||||
secondQuestion: "textarea",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and Multi email Question", () => {
|
|
||||||
test("Select required and Multi email required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion(
|
|
||||||
"multiemail",
|
|
||||||
"multiemail-test",
|
|
||||||
"multiemail test",
|
|
||||||
true,
|
|
||||||
"multiemail test"
|
|
||||||
);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and multiemail question (both required)",
|
|
||||||
secondQuestion: "multiemail",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and Multi email not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion(
|
|
||||||
"multiemail",
|
|
||||||
"multiemail-test",
|
|
||||||
"multiemail test",
|
|
||||||
false,
|
|
||||||
"multiemail test"
|
|
||||||
);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and multiemail question (only select required)",
|
|
||||||
secondQuestion: "multiemail",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and multiselect Question", () => {
|
|
||||||
test("Select required and multiselect text required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and multiselect question (both required)",
|
|
||||||
secondQuestion: "multiselect",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and multiselect text not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and multiselect question (only select required)",
|
|
||||||
secondQuestion: "multiselect",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and Number Question", () => {
|
|
||||||
test("Select required and Number required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and number question (both required)",
|
|
||||||
secondQuestion: "number",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and Number not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and number question (only select required)",
|
|
||||||
secondQuestion: "number",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and Phone Question", () => {
|
|
||||||
test("Select required and select required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and phone question (both required)",
|
|
||||||
secondQuestion: "phone",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and Phone not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and phone question (only select required)",
|
|
||||||
secondQuestion: "phone",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and Radio group Question", () => {
|
|
||||||
test("Select required and Radio group required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and radio question (both required)",
|
|
||||||
secondQuestion: "radio",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and Radio group not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and radio question (only select required)",
|
|
||||||
secondQuestion: "radio",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe("Booking With Select Question and Short text question", () => {
|
|
||||||
test("Select required and Short text required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and text question (both required)",
|
|
||||||
secondQuestion: "text",
|
|
||||||
options: bookingOptions,
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Select and Short text not required", async ({ bookingPage }) => {
|
|
||||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
|
||||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
|
||||||
await bookingPage.updateEventType();
|
|
||||||
const eventTypePage = await bookingPage.previewEventType();
|
|
||||||
await bookingPage.selectTimeSlot(eventTypePage);
|
|
||||||
await bookingPage.fillAndConfirmBooking({
|
|
||||||
eventTypePage,
|
|
||||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
|
||||||
question: "select",
|
|
||||||
fillText: "Test Select question and text question (only select required)",
|
|
||||||
secondQuestion: "text",
|
|
||||||
options: { ...bookingOptions, isRequired: false },
|
|
||||||
});
|
|
||||||
await bookingPage.rescheduleBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
|
||||||
await bookingPage.cancelBooking(eventTypePage);
|
|
||||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
|
||||||
|
|
||||||
import type { createUsersFixture } from "./users";
|
import type { createUsersFixture } from "./users";
|
||||||
|
|
||||||
const reschedulePlaceholderText = "Let others know why you need to reschedule";
|
const reschedulePlaceholderText = "Let others know why you need to reschedule";
|
||||||
|
@ -40,12 +38,6 @@ type fillAndConfirmBookingParams = {
|
||||||
|
|
||||||
type UserFixture = ReturnType<typeof createUsersFixture>;
|
type UserFixture = ReturnType<typeof createUsersFixture>;
|
||||||
|
|
||||||
function isLastDayOfMonth(): boolean {
|
|
||||||
const today = dayjs();
|
|
||||||
const endOfMonth = today.endOf("month");
|
|
||||||
return today.isSame(endOfMonth, "day");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
|
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
|
||||||
const questionActions: QuestionActions = {
|
const questionActions: QuestionActions = {
|
||||||
phone: async () => {
|
phone: async () => {
|
||||||
|
@ -111,6 +103,7 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
|
||||||
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
|
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (questionActions[questionType]) {
|
if (questionActions[questionType]) {
|
||||||
await questionActions[questionType]();
|
await questionActions[questionType]();
|
||||||
}
|
}
|
||||||
|
@ -121,17 +114,6 @@ export async function loginUser(users: UserFixture) {
|
||||||
await pro.apiLogin();
|
await pro.apiLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToNextMonthIfNoAvailabilities = async (eventTypePage: Page) => {
|
|
||||||
try {
|
|
||||||
if (isLastDayOfMonth()) {
|
|
||||||
await eventTypePage.getByTestId("view_next_month").waitFor({ timeout: 6000 });
|
|
||||||
await eventTypePage.getByTestId("view_next_month").click();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.info("No need to click on view next month button");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createBookingPageFixture(page: Page) {
|
export function createBookingPageFixture(page: Page) {
|
||||||
return {
|
return {
|
||||||
goToEventType: async (eventType: string) => {
|
goToEventType: async (eventType: string) => {
|
||||||
|
@ -172,13 +154,19 @@ export function createBookingPageFixture(page: Page) {
|
||||||
return eventtypePromise;
|
return eventtypePromise;
|
||||||
},
|
},
|
||||||
selectTimeSlot: async (eventTypePage: Page) => {
|
selectTimeSlot: async (eventTypePage: Page) => {
|
||||||
await goToNextMonthIfNoAvailabilities(eventTypePage);
|
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
||||||
|
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
||||||
|
}
|
||||||
await eventTypePage.getByTestId("time").first().click();
|
await eventTypePage.getByTestId("time").first().click();
|
||||||
},
|
},
|
||||||
clickReschedule: async () => {
|
clickReschedule: async () => {
|
||||||
await page.getByText("Reschedule").click();
|
await page.getByText("Reschedule").click();
|
||||||
},
|
},
|
||||||
|
navigateToAvailableTimeSlot: async () => {
|
||||||
|
while (await page.getByRole("button", { name: "View next" }).isVisible()) {
|
||||||
|
await page.getByRole("button", { name: "View next" }).click();
|
||||||
|
}
|
||||||
|
},
|
||||||
selectFirstAvailableTime: async () => {
|
selectFirstAvailableTime: async () => {
|
||||||
await page.getByTestId("time").first().click();
|
await page.getByTestId("time").first().click();
|
||||||
},
|
},
|
||||||
|
@ -198,7 +186,6 @@ export function createBookingPageFixture(page: Page) {
|
||||||
},
|
},
|
||||||
|
|
||||||
rescheduleBooking: async (eventTypePage: Page) => {
|
rescheduleBooking: async (eventTypePage: Page) => {
|
||||||
await goToNextMonthIfNoAvailabilities(eventTypePage);
|
|
||||||
await eventTypePage.getByText("Reschedule").click();
|
await eventTypePage.getByText("Reschedule").click();
|
||||||
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
||||||
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
||||||
|
@ -235,7 +222,7 @@ export function createBookingPageFixture(page: Page) {
|
||||||
|
|
||||||
// Change the selector for specifics cases related to select question
|
// Change the selector for specifics cases related to select question
|
||||||
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
|
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
|
||||||
question === "select" && ["multiemail", "multiselect", "address"].includes(secondQuestion);
|
question === "select" && ["multiemail", "multiselect"].includes(secondQuestion);
|
||||||
|
|
||||||
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
|
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
|
||||||
question === "radio" && secondQuestion === "checkbox";
|
question === "radio" && secondQuestion === "checkbox";
|
||||||
|
|
|
@ -1296,7 +1296,6 @@
|
||||||
"select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.",
|
"select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.",
|
||||||
"check_for_conflicts": "Check for conflicts",
|
"check_for_conflicts": "Check for conflicts",
|
||||||
"view_recordings": "View recordings",
|
"view_recordings": "View recordings",
|
||||||
"check_for_recordings":"Check for recordings",
|
|
||||||
"adding_events_to": "Adding events to",
|
"adding_events_to": "Adding events to",
|
||||||
"follow_system_preferences": "Follow system preferences",
|
"follow_system_preferences": "Follow system preferences",
|
||||||
"custom_brand_colors": "Custom brand colors",
|
"custom_brand_colors": "Custom brand colors",
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import type { Schedule } from "availability-list";
|
||||||
|
import { Controls } from "availability-list/components/controls";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
|
import { availabilityAsString } from "@calcom/lib/availability";
|
||||||
|
|
||||||
|
type AvailabilityProps = {
|
||||||
|
schedule: Schedule;
|
||||||
|
isDeletable: boolean;
|
||||||
|
updateDefault: ({ scheduleId, isDefault }: { scheduleId: number; isDefault: boolean }) => void;
|
||||||
|
duplicateFunction: ({ scheduleId }: { scheduleId: number }) => void;
|
||||||
|
deleteFunction: ({ scheduleId }: { scheduleId: number }) => void;
|
||||||
|
displayOptions?: {
|
||||||
|
timeZone?: string;
|
||||||
|
hour12?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Availability({
|
||||||
|
schedule,
|
||||||
|
isDeletable,
|
||||||
|
displayOptions,
|
||||||
|
updateDefault,
|
||||||
|
duplicateFunction,
|
||||||
|
deleteFunction,
|
||||||
|
}: AvailabilityProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!isDeletable) {
|
||||||
|
toast({
|
||||||
|
description: "You are required to have at least one schedule",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deleteFunction({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetDefault() {
|
||||||
|
updateDefault({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDuplicate() {
|
||||||
|
duplicateFunction({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={schedule.id}>
|
||||||
|
<div className="hover:bg-muted flex items-center justify-between py-5 ltr:pl-4 rtl:pr-4 sm:ltr:pl-0 sm:rtl:pr-0">
|
||||||
|
<div className="group flex w-full items-center justify-between sm:px-6">
|
||||||
|
<a className="flex-grow truncate text-sm" href={`/availability/${schedule.id}`}>
|
||||||
|
<h1>{schedule.name}</h1>
|
||||||
|
<div className="space-x-2 rtl:space-x-reverse">
|
||||||
|
{schedule.isDefault && <Badge className="bg-success text-success text-xs">Default</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-subtle mt-1">
|
||||||
|
{schedule.availability
|
||||||
|
.filter((availability) => !!availability.days.length)
|
||||||
|
.map((availability) => (
|
||||||
|
<Fragment key={availability.id}>
|
||||||
|
{availabilityAsString(availability, {
|
||||||
|
hour12: displayOptions?.hour12,
|
||||||
|
})}
|
||||||
|
<br />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{(schedule.timeZone || displayOptions?.timeZone) && (
|
||||||
|
<p className="my-1 flex items-center first-letter:text-xs">
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
{schedule.timeZone ?? displayOptions?.timeZone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Controls
|
||||||
|
schedule={schedule}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
handleDuplicate={handleDuplicate}
|
||||||
|
handleSetDefault={handleSetDefault}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import type { Schedule } from "availability-list";
|
||||||
|
import { MoreHorizontal, Star, Copy, Trash } from "lucide-react";
|
||||||
|
|
||||||
|
type ControlsProps = {
|
||||||
|
schedule: Schedule;
|
||||||
|
handleDelete: () => void;
|
||||||
|
handleDuplicate: () => void;
|
||||||
|
handleSetDefault: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Controls({ schedule, handleDelete, handleDuplicate, handleSetDefault }: ControlsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<Button type="button" color="secondary" className="bg-secondary text-secondary mx-5">
|
||||||
|
<MoreHorizontal />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
{!schedule.isDefault && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
handleSetDefault();
|
||||||
|
}}
|
||||||
|
className="min-w-40 focus:ring-mute min-w-40 focus:ring-muted">
|
||||||
|
<Star />
|
||||||
|
Set as default
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
handleDuplicate();
|
||||||
|
}}>
|
||||||
|
<Copy />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="min-w-40 focus:ring-muted"
|
||||||
|
onClick={() => {
|
||||||
|
handleDelete();
|
||||||
|
}}>
|
||||||
|
<Trash />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { LucideIcon as IconType } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import type { SVGComponent } from "@calcom/types/SVGComponent";
|
||||||
|
|
||||||
|
type EmptyScreenProps = {
|
||||||
|
Icon?: SVGComponent | IconType;
|
||||||
|
avatar?: React.ReactElement;
|
||||||
|
headline: string | React.ReactElement;
|
||||||
|
description?: string | React.ReactElement;
|
||||||
|
buttonText?: string;
|
||||||
|
buttonOnClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||||
|
buttonRaw?: ReactNode; // Used incase you want to provide your own button.
|
||||||
|
border?: boolean;
|
||||||
|
dashedBorder?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyScreen({
|
||||||
|
Icon,
|
||||||
|
avatar,
|
||||||
|
headline,
|
||||||
|
description,
|
||||||
|
buttonText,
|
||||||
|
buttonOnClick,
|
||||||
|
buttonRaw,
|
||||||
|
border = true,
|
||||||
|
dashedBorder = true,
|
||||||
|
className,
|
||||||
|
}: EmptyScreenProps & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
data-testid="empty-screen"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full select-none flex-col items-center justify-center rounded-lg p-7 lg:p-20",
|
||||||
|
border && "border-subtle border",
|
||||||
|
dashedBorder && "border-dashed",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{!avatar ? null : (
|
||||||
|
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full">{avatar}</div>
|
||||||
|
)}
|
||||||
|
{!Icon ? null : (
|
||||||
|
<div className="bg-emphasis flex h-[72px] w-[72px] items-center justify-center rounded-full ">
|
||||||
|
<Icon className="text-default inline-block h-10 w-10 stroke-[1.3px]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex max-w-[420px] flex-col items-center">
|
||||||
|
<h2 className={cn("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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!buttonOnClick && !!buttonText && <Button onClick={(e) => buttonOnClick(e)}>{buttonText}</Button>}
|
||||||
|
{buttonRaw}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { ReactElement, Ref } from "react";
|
||||||
|
import React, { forwardRef } from "react";
|
||||||
|
import type { FieldValues, SubmitHandler, UseFormReturn } from "react-hook-form";
|
||||||
|
import { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
|
type FormProps<T extends object> = { form: UseFormReturn<T>; handleSubmit: SubmitHandler<T> } & Omit<
|
||||||
|
JSX.IntrinsicElements["form"],
|
||||||
|
"onSubmit"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const PlainForm = <T extends FieldValues>(props: FormProps<T>, ref: Ref<HTMLFormElement>) => {
|
||||||
|
const { form, handleSubmit, ...passThrough } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form
|
||||||
|
ref={ref}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
form
|
||||||
|
.handleSubmit(handleSubmit)(event)
|
||||||
|
.catch((err) => {
|
||||||
|
// FIXME: Booking Pages don't have toast, so this error is never shown
|
||||||
|
// showToast(`${getErrorFromUnknown(err).message}`, "error");
|
||||||
|
console.error(`${getErrorFromUnknown(err).message}`, "error");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
{...passThrough}>
|
||||||
|
{props.children}
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Form = forwardRef(PlainForm) as <T extends FieldValues>(
|
||||||
|
p: FormProps<T> & { ref?: Ref<HTMLFormElement> }
|
||||||
|
) => ReactElement;
|
|
@ -0,0 +1,68 @@
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { Plus } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import { Button } from "../../../src/components/ui/button";
|
||||||
|
import { Form } from "../form";
|
||||||
|
import type { Schedule } from ".prisma/client";
|
||||||
|
|
||||||
|
// create mutation handler to be handled outside the component
|
||||||
|
// then passed in as a prop
|
||||||
|
// TODO: translations can be taken care of later
|
||||||
|
|
||||||
|
type NewScheduleButtonProps = {
|
||||||
|
name?: string;
|
||||||
|
createMutation: (values: {
|
||||||
|
onSucess: (schedule: Schedule) => void;
|
||||||
|
onError: (err: HttpError) => void;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NewScheduleButton({ name = "new-schedule", createMutation }: NewScheduleButtonProps) {
|
||||||
|
const form = useForm<{
|
||||||
|
name: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" data-testid={name}>
|
||||||
|
{Plus}
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add new schedule</DialogTitle>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
handleSubmit={(values) => {
|
||||||
|
createMutation(values);
|
||||||
|
}}>
|
||||||
|
<Label htmlFor="working-hours">Name</Label>
|
||||||
|
<Input id="working-hours" placeholder="Working Hours" />
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" className="mr-2 border-none">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Continue</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { AvailabilityList } from ".";
|
||||||
|
export { Availability } from "./components/availability";
|
||||||
|
export * from "../types";
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { NewScheduleButton } from "availability-list/components/new-schedule-button";
|
||||||
|
|
||||||
|
import type { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { Clock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
|
import { Availability } from "./components/availability";
|
||||||
|
import { EmptyScreen } from "./components/empty-screen";
|
||||||
|
|
||||||
|
export type Schedule = {
|
||||||
|
isDefault: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
availability: {
|
||||||
|
id: number;
|
||||||
|
startTime: Date;
|
||||||
|
endTime: Date;
|
||||||
|
userId: number | null;
|
||||||
|
eventTypeId: number | null;
|
||||||
|
date: Date | null;
|
||||||
|
days: number[];
|
||||||
|
scheduleId: number | null;
|
||||||
|
}[];
|
||||||
|
timeZone: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AvailabilityListProps = {
|
||||||
|
schedules: Schedule[] | [];
|
||||||
|
onCreateMutation: (values: {
|
||||||
|
onSucess: (schedule: Schedule) => void;
|
||||||
|
onError: (err: HttpError) => void;
|
||||||
|
}) => void;
|
||||||
|
updateMutation: ({ scheduleId, isDefault }: { scheduleId: number; isDefault: boolean }) => void;
|
||||||
|
duplicateMutation: ({ scheduleId }: { scheduleId: number }) => void;
|
||||||
|
deleteMutation: ({ scheduleId }: { scheduleId: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AvailabilityList({
|
||||||
|
schedules,
|
||||||
|
onCreateMutation,
|
||||||
|
updateMutation,
|
||||||
|
duplicateMutation,
|
||||||
|
deleteMutation,
|
||||||
|
}: AvailabilityListProps) {
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<EmptyScreen
|
||||||
|
Icon={Clock}
|
||||||
|
headline="Create an availability schedule"
|
||||||
|
subtitle="Creating availability schedules allows you to manage availability across event types. They can be applied to one or more event types."
|
||||||
|
className="w-full"
|
||||||
|
buttonRaw={<NewScheduleButton createMutation={onCreateMutation} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-subtle bg-default mb-16 overflow-hidden rounded-md border">
|
||||||
|
<ul className="divide-subtle divide-y" data-testid="schedules">
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<Availability
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
isDeletable={schedules.length !== 1}
|
||||||
|
updateDefault={updateMutation}
|
||||||
|
deleteFunction={deleteMutation}
|
||||||
|
duplicateFunction={duplicateMutation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.cjs",
|
||||||
|
"css": "globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,3 +8,76 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@import "../ui/styles/shared-globals.css";
|
@import "../ui/styles/shared-globals.css";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1,3 @@
|
||||||
export { Booker } from "./booker/Booker";
|
export { Booker } from "./booker/Booker";
|
||||||
|
export { AvailabilityList } from "./availability-list";
|
||||||
|
export { Availability } from "./availability-list/components/availability";
|
||||||
|
|
|
@ -11,12 +11,27 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||||
|
"@types/node": "^20.8.3",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.0.26",
|
||||||
"@types/react-dom": "^18.0.9",
|
"@types/react-dom": "^18.0.9",
|
||||||
"@vitejs/plugin-react": "^2.2.0",
|
"@vitejs/plugin-react": "^2.2.0",
|
||||||
|
"react-hook-form": "^7.47.0",
|
||||||
"rollup-plugin-node-builtins": "^2.1.2",
|
"rollup-plugin-node-builtins": "^2.1.2",
|
||||||
"typescript": "^4.9.4",
|
"typescript": "^4.9.4",
|
||||||
"vite": "^4.1.2"
|
"vite": "^4.1.2"
|
||||||
},
|
},
|
||||||
"main": "./index"
|
"main": "./index",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"lucide-react": "^0.284.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg md:w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
|
||||||
|
};
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"ring-offset-background hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground/50 hover:text-foreground absolute right-2 top-2 rounded-md p-1 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
|
@ -0,0 +1,31 @@
|
||||||
|
import {
|
||||||
|
Toast,
|
||||||
|
ToastClose,
|
||||||
|
ToastDescription,
|
||||||
|
ToastProvider,
|
||||||
|
ToastTitle,
|
||||||
|
ToastViewport,
|
||||||
|
} from "@/components/ui/toast";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,186 @@
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_VALUE;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
|
};
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
|
@ -3,5 +3,78 @@ const base = require("@calcom/config/tailwind-preset");
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...base,
|
...base,
|
||||||
content: ["./bookings/**/*.tsx"],
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{ts,tsx}",
|
||||||
|
"./components/**/*.{ts,tsx}",
|
||||||
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
"./bookings/**/*.tsx",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["/*"]
|
"~/*": ["/*"],
|
||||||
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import { resolve } from "path";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { resolve, path } from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// setting up shadcn for vite: https://ui.shadcn.com/docs/installation/vite
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
|
plugins: [react()],
|
||||||
lib: {
|
lib: {
|
||||||
entry: [resolve(__dirname, "booker/export.ts")],
|
entry: [resolve(__dirname, "booker/export.ts")],
|
||||||
name: "CalAtoms",
|
name: "CalAtoms",
|
||||||
|
@ -23,6 +27,7 @@ export default defineConfig({
|
||||||
fs: resolve("../../node_modules/rollup-plugin-node-builtins"),
|
fs: resolve("../../node_modules/rollup-plugin-node-builtins"),
|
||||||
path: resolve("../../node_modules/rollup-plugin-node-builtins"),
|
path: resolve("../../node_modules/rollup-plugin-node-builtins"),
|
||||||
os: resolve("../../node_modules/rollup-plugin-node-builtins"),
|
os: resolve("../../node_modules/rollup-plugin-node-builtins"),
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -161,8 +161,8 @@ const EmailStep = (props: { translationString: string; iconsrc: string }) => {
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#E5E7EB",
|
backgroundColor: "#E5E7EB",
|
||||||
borderRadius: "48px",
|
borderRadius: "48px",
|
||||||
minHeight: "48px",
|
height: "48px",
|
||||||
minWidth: "48px",
|
width: "48px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
|
@ -50,13 +50,8 @@ export default class OrganizerDailyVideoDownloadRecordingEmail extends BaseEmail
|
||||||
return this.getFormattedRecipientTime({ time: this.calEvent.endTime, format });
|
return this.getFormattedRecipientTime({ time: this.calEvent.endTime, format });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getLocale(): string {
|
|
||||||
return this.calEvent.organizer.language.locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getFormattedDate() {
|
protected getFormattedDate() {
|
||||||
const organizerTimeFormat = this.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
|
const organizerTimeFormat = this.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
|
||||||
|
|
||||||
return `${this.getOrganizerStart(organizerTimeFormat)} - ${this.getOrganizerEnd(
|
return `${this.getOrganizerStart(organizerTimeFormat)} - ${this.getOrganizerEnd(
|
||||||
organizerTimeFormat
|
organizerTimeFormat
|
||||||
)}, ${this.t(this.getOrganizerStart("dddd").toLowerCase())}, ${this.t(
|
)}, ${this.t(this.getOrganizerStart("dddd").toLowerCase())}, ${this.t(
|
||||||
|
|
|
@ -95,7 +95,7 @@ const NoAvailabilityOverlay = ({
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted border-subtle absolute left-1/2 top-40 -mt-10 w-max -translate-x-1/2 -translate-y-1/2 transform rounded-md border p-8 shadow-sm">
|
<div className="bg-muted border-subtle absolute left-1/2 top-40 -mt-10 w-max -translate-x-1/2 -translate-y-1/2 transform rounded-md border p-8 shadow-sm">
|
||||||
<h4 className="text-emphasis mb-4 font-medium">{t("no_availability_in_month", { month: month })}</h4>
|
<h4 className="text-emphasis mb-4 font-medium">{t("no_availability_in_month", { month: month })}</h4>
|
||||||
<Button onClick={nextMonthButton} color="primary" EndIcon={ArrowRight} data-testid="view_next_month">
|
<Button onClick={nextMonthButton} color="primary" EndIcon={ArrowRight}>
|
||||||
{t("view_next_month")}
|
{t("view_next_month")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
|
||||||
import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth";
|
import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth";
|
||||||
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
||||||
|
|
||||||
|
@ -64,22 +63,5 @@ describe("Test Suite: Date Picker", () => {
|
||||||
vi.setSystemTime(vi.getRealSystemTime());
|
vi.setSystemTime(vi.getRealSystemTime());
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("it returns the correct responses end of month", () => {
|
|
||||||
// test a date at one minute past midnight, end of month.
|
|
||||||
// we use dayjs() as the system timezone can still modify the Date.
|
|
||||||
vi.useFakeTimers().setSystemTime(dayjs().endOf("month").startOf("day").add(1, "second").toDate());
|
|
||||||
|
|
||||||
const currentDate = new Date();
|
|
||||||
const result = getAvailableDatesInMonth({
|
|
||||||
browsingDate: currentDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
|
|
||||||
// Undo the forced time we applied earlier, reset to system default.
|
|
||||||
vi.setSystemTime(vi.getRealSystemTime());
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import dayjs from "@calcom/dayjs";
|
|
||||||
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
||||||
|
|
||||||
// calculate the available dates in the month:
|
// calculate the available dates in the month:
|
||||||
|
@ -22,9 +21,7 @@ export function getAvailableDatesInMonth({
|
||||||
);
|
);
|
||||||
for (
|
for (
|
||||||
let date = browsingDate > minDate ? browsingDate : minDate;
|
let date = browsingDate > minDate ? browsingDate : minDate;
|
||||||
// Check if date is before the last date of the month
|
date <= lastDateOfMonth;
|
||||||
// or is the same day, in the same month, in the same year.
|
|
||||||
date < lastDateOfMonth || dayjs(date).isSame(lastDateOfMonth, "day");
|
|
||||||
date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
||||||
) {
|
) {
|
||||||
// intersect included dates
|
// intersect included dates
|
||||||
|
|
|
@ -194,7 +194,7 @@ const OrgAppearanceView = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-5">
|
<div className="border-subtle rounded-md border p-5">
|
||||||
<span className="text-default text-sm">{t("only_owner_change")}</span>
|
<span className="text-default text-sm">{t("only_owner_change")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -113,7 +113,7 @@ const OrgProfileView = () => {
|
||||||
{isOrgAdminOrOwner ? (
|
{isOrgAdminOrOwner ? (
|
||||||
<OrgProfileForm defaultValues={defaultValues} />
|
<OrgProfileForm defaultValues={defaultValues} />
|
||||||
) : (
|
) : (
|
||||||
<div className="border-subtle flex rounded-b-md border border-t-0 px-4 py-8 sm:px-6">
|
<div className="flex">
|
||||||
<div className="flex-grow">
|
<div className="flex-grow">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-emphasis">{t("org_name")}</Label>
|
<Label className="text-emphasis">{t("org_name")}</Label>
|
||||||
|
|
|
@ -147,7 +147,7 @@ const PaymentForm = (props: Props) => {
|
||||||
formatParams: { amount: { currency: props.payment.currency } },
|
formatParams: { amount: { currency: props.payment.currency } },
|
||||||
})}
|
})}
|
||||||
onChange={(e) => setHoldAcknowledged(e.target.checked)}
|
onChange={(e) => setHoldAcknowledged(e.target.checked)}
|
||||||
descriptionClassName="text-info font-semibold"
|
descriptionClassName="text-blue-900 font-semibold"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -174,7 +174,7 @@ export const ViewRecordingsDialog = (props: IViewRecordingsDialog) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent enableOverflow>
|
<DialogContent>
|
||||||
<DialogHeader title={t("recordings_title")} subtitle={subtitle} />
|
<DialogHeader title={t("recordings_title")} subtitle={subtitle} />
|
||||||
{roomName ? (
|
{roomName ? (
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
|
|
Loading…
Reference in New Issue