diff --git a/apps/web/pages/api/user/avatar.ts b/apps/web/pages/api/user/avatar.ts index 6a99a802c6..27b171c2da 100644 --- a/apps/web/pages/api/user/avatar.ts +++ b/apps/web/pages/api/user/avatar.ts @@ -2,7 +2,7 @@ import crypto from "crypto"; import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; -import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import prisma from "@calcom/prisma"; import { defaultAvatarSrc } from "@lib/profile"; diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index fc4986ceb9..057f996b26 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -7,7 +7,7 @@ import { useEffect } from "react"; import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription"; import { CAL_URL } from "@calcom/lib/constants"; -import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; import { md } from "@calcom/lib/markdownIt"; diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 440563ef47..a99c48fc2d 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -504,6 +504,7 @@ describe("getSchedule", () => { ); }); + // FIXME: Fix minimumBookingNotice is respected test test.skip("minimumBookingNotice is respected", async () => { jest.useFakeTimers().setSystemTime( (() => { diff --git a/jest.config.ts b/jest.config.ts index 31b6064651..a7b3448f64 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -35,9 +35,15 @@ const config: Config = { displayName: "@calcom/lib", roots: ["/packages/lib"], testEnvironment: "node", + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], transform: { "^.+\\.tsx?$": "ts-jest", }, + globals: { + "ts-jest": { + tsconfig: "/packages/lib/tsconfig.test.json", + }, + }, }, { displayName: "@calcom/closecom", diff --git a/packages/features/ee/teams/components/TeamInviteListItem.tsx b/packages/features/ee/teams/components/TeamInviteListItem.tsx index 96c4e79671..17b74831f0 100644 --- a/packages/features/ee/teams/components/TeamInviteListItem.tsx +++ b/packages/features/ee/teams/components/TeamInviteListItem.tsx @@ -1,7 +1,7 @@ -import { MembershipRole } from "@prisma/client"; +import type { MembershipRole } from "@prisma/client"; import classNames from "@calcom/lib/classNames"; -import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { diff --git a/packages/features/ee/teams/components/TeamListItem.tsx b/packages/features/ee/teams/components/TeamListItem.tsx index a237d72581..6ef0d2ae4a 100644 --- a/packages/features/ee/teams/components/TeamListItem.tsx +++ b/packages/features/ee/teams/components/TeamListItem.tsx @@ -5,9 +5,10 @@ import { useState } from "react"; import MemberInvitationModal from "@calcom/ee/teams/components/MemberInvitationModal"; import classNames from "@calcom/lib/classNames"; -import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { RouterOutputs, trpc } from "@calcom/trpc/react"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; import { Avatar, Button, diff --git a/packages/features/ee/teams/pages/availability.tsx b/packages/features/ee/teams/pages/availability.tsx index 41164f41e7..500d7d4e05 100644 --- a/packages/features/ee/teams/pages/availability.tsx +++ b/packages/features/ee/teams/pages/availability.tsx @@ -1,7 +1,7 @@ import { useRouter } from "next/router"; import { useMemo, useState } from "react"; -import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { trpc } from "@calcom/trpc/react"; import { Alert, Avatar, Loader, Shell } from "@calcom/ui"; diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 34e7530037..419aec51a0 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -8,7 +8,7 @@ import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; -import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar"; +import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import objectKeys from "@calcom/lib/objectKeys"; diff --git a/packages/lib/CustomBranding.test.tsx b/packages/lib/CustomBranding.test.tsx new file mode 100644 index 0000000000..5affe88bb1 --- /dev/null +++ b/packages/lib/CustomBranding.test.tsx @@ -0,0 +1,155 @@ +import { colorNameToHex, fallBackHex, isValidHexCode } from "./CustomBranding"; + +describe("Custom Branding tests", () => { + describe("fn: colorNameToHex", () => { + it("should return a hex color when a valid color name is provided", () => { + const cases = [ + { + input: "red", + expected: "#ff0000", + }, + { + input: "green", + expected: "#008000", + }, + { + input: "salmon", + expected: "#fa8072", + }, + { + input: "rebeccapurple", + expected: "#663399", + }, + ]; + + for (const { input, expected } of cases) { + const result = colorNameToHex(input); + + expect(result).toEqual(expected); + } + }); + + it("should return false when an invalid color name is provided", () => { + const result = colorNameToHex("invalid"); + + expect(result).toEqual(false); + }); + }); + + describe("fn: isValidHexCode", () => { + it("should return true when a valid hex code is provided", () => { + const cases = [ + { + input: "#ff0000", + expected: true, + }, + { + input: "#00Ff00", + expected: true, + }, + { + input: "#fA8072", + expected: true, + }, + { + input: "#663", + expected: true, + }, + { + input: "#fAb", + expected: true, + }, + { + input: "#F00F00", + expected: true, + }, + ]; + + for (const { input, expected } of cases) { + const result = isValidHexCode(input); + + if (!result) { + console.log("input", input); + } + + expect(result).toEqual(expected); + } + }); + + it("should return false when an invalid hex code is provided", () => { + const cases = [ + { + input: "#ff000", + expected: false, + }, + { + input: "#F000G0", + expected: false, + }, + { + input: "#00ff00a", + expected: false, + }, + { + input: "#fa8072aa", + expected: false, + }, + { + input: "#663399aa", + expected: false, + }, + ]; + + for (const { input, expected } of cases) { + const result = isValidHexCode(input); + + expect(result).toEqual(expected); + } + }); + }); + + describe("fn: fallBackHex", () => { + it("should return a hex color when a valid color name is provided", () => { + const cases = [ + { + input: "red", + expected: "#ff0000", + }, + { + input: "green", + expected: "#008000", + }, + { + input: "salmon", + expected: "#fa8072", + }, + { + input: "rebeccapurple", + expected: "#663399", + }, + ]; + + for (const { input, expected } of cases) { + const result = colorNameToHex(input); + + expect(result).toEqual(expected); + } + }); + + it("should return a brand color when there is no hex fallback", () => { + // BRAND_COLOR => "#292929" + // BRAND_TEXT_COLOR => "#ffffff" + // DARK_BRAND_COLOR => "#fafafa" + + const inputs = ["reddit", null, "darkbruwn"]; + + for (const input of inputs) { + const resultLight = fallBackHex(input, false); + const resultDark = fallBackHex(input, true); + + expect(resultLight).toEqual("#292929"); + expect(resultDark).toEqual("#fafafa"); + } + }); + }); +}); diff --git a/packages/lib/CustomBranding.tsx b/packages/lib/CustomBranding.tsx index 8512de4de0..4bf695a8bd 100644 --- a/packages/lib/CustomBranding.tsx +++ b/packages/lib/CustomBranding.tsx @@ -2,165 +2,178 @@ import Head from "next/head"; import { useBrandColors } from "@calcom/embed-core/embed-iframe"; -const brandColor = "#292929"; -const brandTextColor = "#ffffff"; -const darkBrandColor = "#fafafa"; +const BRAND_COLOR = "#292929"; +const BRAND_TEXT_COLOR = "#ffffff"; +const DARK_BRAND_COLOR = "#fafafa"; + +const HTML_COLORS = { + aliceblue: "#f0f8ff", + antiquewhite: "#faebd7", + aqua: "#00ffff", + aquamarine: "#7fffd4", + azure: "#f0ffff", + beige: "#f5f5dc", + bisque: "#ffe4c4", + black: "#000000", + blanchedalmond: "#ffebcd", + blue: "#0000ff", + blueviolet: "#8a2be2", + brown: "#a52a2a", + burlywood: "#deb887", + cadetblue: "#5f9ea0", + chartreuse: "#7fff00", + chocolate: "#d2691e", + coral: "#ff7f50", + cornflowerblue: "#6495ed", + cornsilk: "#fff8dc", + crimson: "#dc143c", + cyan: "#00ffff", + darkblue: "#00008b", + darkcyan: "#008b8b", + darkgoldenrod: "#b8860b", + darkgray: "#a9a9a9", + darkgreen: "#006400", + darkkhaki: "#bdb76b", + darkmagenta: "#8b008b", + darkolivegreen: "#556b2f", + darkorange: "#ff8c00", + darkorchid: "#9932cc", + darkred: "#8b0000", + darksalmon: "#e9967a", + darkseagreen: "#8fbc8f", + darkslateblue: "#483d8b", + darkslategray: "#2f4f4f", + darkturquoise: "#00ced1", + darkviolet: "#9400d3", + deeppink: "#ff1493", + deepskyblue: "#00bfff", + dimgray: "#696969", + dodgerblue: "#1e90ff", + firebrick: "#b22222", + floralwhite: "#fffaf0", + forestgreen: "#228b22", + fuchsia: "#ff00ff", + gainsboro: "#dcdcdc", + ghostwhite: "#f8f8ff", + gold: "#ffd700", + goldenrod: "#daa520", + gray: "#808080", + green: "#008000", + greenyellow: "#adff2f", + honeydew: "#f0fff0", + hotpink: "#ff69b4", + "indianred ": "#cd5c5c", + indigo: "#4b0082", + ivory: "#fffff0", + khaki: "#f0e68c", + lavender: "#e6e6fa", + lavenderblush: "#fff0f5", + lawngreen: "#7cfc00", + lemonchiffon: "#fffacd", + lightblue: "#add8e6", + lightcoral: "#f08080", + lightcyan: "#e0ffff", + lightgoldenrodyellow: "#fafad2", + lightgrey: "#d3d3d3", + lightgreen: "#90ee90", + lightpink: "#ffb6c1", + lightsalmon: "#ffa07a", + lightseagreen: "#20b2aa", + lightskyblue: "#87cefa", + lightslategray: "#778899", + lightsteelblue: "#b0c4de", + lightyellow: "#ffffe0", + lime: "#00ff00", + limegreen: "#32cd32", + linen: "#faf0e6", + magenta: "#ff00ff", + maroon: "#800000", + mediumaquamarine: "#66cdaa", + mediumblue: "#0000cd", + mediumorchid: "#ba55d3", + mediumpurple: "#9370d8", + mediumseagreen: "#3cb371", + mediumslateblue: "#7b68ee", + mediumspringgreen: "#00fa9a", + mediumturquoise: "#48d1cc", + mediumvioletred: "#c71585", + midnightblue: "#191970", + mintcream: "#f5fffa", + mistyrose: "#ffe4e1", + moccasin: "#ffe4b5", + navajowhite: "#ffdead", + navy: "#000080", + oldlace: "#fdf5e6", + olive: "#808000", + olivedrab: "#6b8e23", + orange: "#ffa500", + orangered: "#ff4500", + orchid: "#da70d6", + palegoldenrod: "#eee8aa", + palegreen: "#98fb98", + paleturquoise: "#afeeee", + palevioletred: "#d87093", + papayawhip: "#ffefd5", + peachpuff: "#ffdab9", + peru: "#cd853f", + pink: "#ffc0cb", + plum: "#dda0dd", + powderblue: "#b0e0e6", + purple: "#800080", + rebeccapurple: "#663399", + red: "#ff0000", + rosybrown: "#bc8f8f", + royalblue: "#4169e1", + saddlebrown: "#8b4513", + salmon: "#fa8072", + sandybrown: "#f4a460", + seagreen: "#2e8b57", + seashell: "#fff5ee", + sienna: "#a0522d", + silver: "#c0c0c0", + skyblue: "#87ceeb", + slateblue: "#6a5acd", + slategray: "#708090", + snow: "#fffafa", + springgreen: "#00ff7f", + steelblue: "#4682b4", + tan: "#d2b48c", + teal: "#008080", + thistle: "#d8bfd8", + tomato: "#ff6347", + turquoise: "#40e0d0", + violet: "#ee82ee", + wheat: "#f5deb3", + white: "#ffffff", + whitesmoke: "#f5f5f5", + yellow: "#ffff00", + yellowgreen: "#9acd32", +}; + +// Shadow the HTML_COLORS constant so we can create a type guard for it. +type HTML_COLORS = typeof HTML_COLORS; + +function isHtmlColor(color: string): color is keyof HTML_COLORS { + return color in HTML_COLORS; +} export function colorNameToHex(color: string) { - const colors = { - aliceblue: "#f0f8ff", - antiquewhite: "#faebd7", - aqua: "#00ffff", - aquamarine: "#7fffd4", - azure: "#f0ffff", - beige: "#f5f5dc", - bisque: "#ffe4c4", - black: "#000000", - blanchedalmond: "#ffebcd", - blue: "#0000ff", - blueviolet: "#8a2be2", - brown: "#a52a2a", - burlywood: "#deb887", - cadetblue: "#5f9ea0", - chartreuse: "#7fff00", - chocolate: "#d2691e", - coral: "#ff7f50", - cornflowerblue: "#6495ed", - cornsilk: "#fff8dc", - crimson: "#dc143c", - cyan: "#00ffff", - darkblue: "#00008b", - darkcyan: "#008b8b", - darkgoldenrod: "#b8860b", - darkgray: "#a9a9a9", - darkgreen: "#006400", - darkkhaki: "#bdb76b", - darkmagenta: "#8b008b", - darkolivegreen: "#556b2f", - darkorange: "#ff8c00", - darkorchid: "#9932cc", - darkred: "#8b0000", - darksalmon: "#e9967a", - darkseagreen: "#8fbc8f", - darkslateblue: "#483d8b", - darkslategray: "#2f4f4f", - darkturquoise: "#00ced1", - darkviolet: "#9400d3", - deeppink: "#ff1493", - deepskyblue: "#00bfff", - dimgray: "#696969", - dodgerblue: "#1e90ff", - firebrick: "#b22222", - floralwhite: "#fffaf0", - forestgreen: "#228b22", - fuchsia: "#ff00ff", - gainsboro: "#dcdcdc", - ghostwhite: "#f8f8ff", - gold: "#ffd700", - goldenrod: "#daa520", - gray: "#808080", - green: "#008000", - greenyellow: "#adff2f", - honeydew: "#f0fff0", - hotpink: "#ff69b4", - "indianred ": "#cd5c5c", - indigo: "#4b0082", - ivory: "#fffff0", - khaki: "#f0e68c", - lavender: "#e6e6fa", - lavenderblush: "#fff0f5", - lawngreen: "#7cfc00", - lemonchiffon: "#fffacd", - lightblue: "#add8e6", - lightcoral: "#f08080", - lightcyan: "#e0ffff", - lightgoldenrodyellow: "#fafad2", - lightgrey: "#d3d3d3", - lightgreen: "#90ee90", - lightpink: "#ffb6c1", - lightsalmon: "#ffa07a", - lightseagreen: "#20b2aa", - lightskyblue: "#87cefa", - lightslategray: "#778899", - lightsteelblue: "#b0c4de", - lightyellow: "#ffffe0", - lime: "#00ff00", - limegreen: "#32cd32", - linen: "#faf0e6", - magenta: "#ff00ff", - maroon: "#800000", - mediumaquamarine: "#66cdaa", - mediumblue: "#0000cd", - mediumorchid: "#ba55d3", - mediumpurple: "#9370d8", - mediumseagreen: "#3cb371", - mediumslateblue: "#7b68ee", - mediumspringgreen: "#00fa9a", - mediumturquoise: "#48d1cc", - mediumvioletred: "#c71585", - midnightblue: "#191970", - mintcream: "#f5fffa", - mistyrose: "#ffe4e1", - moccasin: "#ffe4b5", - navajowhite: "#ffdead", - navy: "#000080", - oldlace: "#fdf5e6", - olive: "#808000", - olivedrab: "#6b8e23", - orange: "#ffa500", - orangered: "#ff4500", - orchid: "#da70d6", - palegoldenrod: "#eee8aa", - palegreen: "#98fb98", - paleturquoise: "#afeeee", - palevioletred: "#d87093", - papayawhip: "#ffefd5", - peachpuff: "#ffdab9", - peru: "#cd853f", - pink: "#ffc0cb", - plum: "#dda0dd", - powderblue: "#b0e0e6", - purple: "#800080", - rebeccapurple: "#663399", - red: "#ff0000", - rosybrown: "#bc8f8f", - royalblue: "#4169e1", - saddlebrown: "#8b4513", - salmon: "#fa8072", - sandybrown: "#f4a460", - seagreen: "#2e8b57", - seashell: "#fff5ee", - sienna: "#a0522d", - silver: "#c0c0c0", - skyblue: "#87ceeb", - slateblue: "#6a5acd", - slategray: "#708090", - snow: "#fffafa", - springgreen: "#00ff7f", - steelblue: "#4682b4", - tan: "#d2b48c", - teal: "#008080", - thistle: "#d8bfd8", - tomato: "#ff6347", - turquoise: "#40e0d0", - violet: "#ee82ee", - wheat: "#f5deb3", - white: "#ffffff", - whitesmoke: "#f5f5f5", - yellow: "#ffff00", - yellowgreen: "#9acd32", - }; + const normalizedColor = color.toLowerCase(); - return colors[color.toLowerCase() as keyof typeof colors] !== undefined - ? colors[color.toLowerCase() as keyof typeof colors] - : false; + if (isHtmlColor(normalizedColor)) { + return HTML_COLORS[normalizedColor]; + } + + return false; } function computeContrastRatio(a: number[], b: number[]) { const lum1 = computeLuminance(a[0], a[1], a[2]); const lum2 = computeLuminance(b[0], b[1], b[2]); + const brightest = Math.max(lum1, lum2); const darkest = Math.min(lum1, lum2); + return (brightest + 0.05) / (darkest + 0.05); } @@ -169,19 +182,25 @@ function computeLuminance(r: number, g: number, b: number) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } function hexToRGB(hex: string) { const color = hex.replace("#", ""); + return [parseInt(color.slice(0, 2), 16), parseInt(color.slice(2, 4), 16), parseInt(color.slice(4, 6), 16)]; } function normalizeHexCode(hex: string | null, dark: boolean) { if (!hex) { - return !dark ? brandColor : darkBrandColor; + return !dark ? BRAND_COLOR : DARK_BRAND_COLOR; } + hex = hex.replace("#", ""); + + // If the length of the hex code is 3, double up each character + // e.g. fff => ffffff or a0e => aa00ee if (hex.length === 3) { hex = hex .split("") @@ -190,54 +209,84 @@ function normalizeHexCode(hex: string | null, dark: boolean) { }) .join(""); } + return hex; } function getContrastingTextColor(bgColor: string | null, dark: boolean): string { - bgColor = bgColor == "" || bgColor == null ? (dark ? darkBrandColor : brandColor) : bgColor; + bgColor = bgColor == "" || bgColor == null ? (dark ? DARK_BRAND_COLOR : BRAND_COLOR) : bgColor; + const rgb = hexToRGB(bgColor); + const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]); const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929 - return whiteContrastRatio > blackContrastRatio ? brandTextColor : brandColor; + + return whiteContrastRatio > blackContrastRatio ? BRAND_TEXT_COLOR : BRAND_COLOR; } +/** + * Given a string, determine if it's a valid 6 character hex code. + */ export function isValidHexCode(val: string | null) { if (val) { + // Normalize the value into include a leading "#" if it's missing val = val.indexOf("#") === 0 ? val : "#" + val; - const regex = new RegExp("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"); + + const regex = /^#([A-F0-9]{6}|[A-F0-9]{3})$/i; + return regex.test(val); } + return false; } +/** + * Given a html color name, check if it exists in our color palette + * and if it does, return the hex code for that color. Otherwise, + * return the default brand color. + */ export function fallBackHex(val: string | null, dark: boolean): string { - if (val) if (colorNameToHex(val)) return colorNameToHex(val) as string; - return dark ? darkBrandColor : brandColor; + if (val && colorNameToHex(val)) { + return colorNameToHex(val) as string; + } + + // Otherwise, return the default color + return dark ? DARK_BRAND_COLOR : BRAND_COLOR; } +/** + * Given a light and dark brand color value, update the css variables + * within the document to reflect the new brand colors. + */ const BrandColor = ({ - lightVal = brandColor, - darkVal = darkBrandColor, + lightVal = BRAND_COLOR, + darkVal = DARK_BRAND_COLOR, }: { lightVal: string | undefined | null; darkVal: string | undefined | null; }) => { const embedBrandingColors = useBrandColors(); + lightVal = embedBrandingColors.brandColor || lightVal; + // convert to 6 digit equivalent if 3 digit code is entered lightVal = normalizeHexCode(lightVal, false); + darkVal = normalizeHexCode(darkVal, true); + // ensure acceptable hex-code lightVal = isValidHexCode(lightVal) ? lightVal?.indexOf("#") === 0 ? lightVal : "#" + lightVal : fallBackHex(lightVal, false); + darkVal = isValidHexCode(darkVal) ? darkVal?.indexOf("#") === 0 ? darkVal : "#" + darkVal : fallBackHex(darkVal, true); + return (