diff --git a/.env.example b/.env.example index 9a65204948..33f70bb046 100644 --- a/.env.example +++ b/.env.example @@ -127,6 +127,14 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= NEXT_PUBLIC_FRESHCHAT_HOST= +# For holiday feature: +# Step-by-step: Get a Google Calendar API Key +# 1. Go to Google Cloud Console: https://console.cloud.google.com/ +# 2. Select or Create a Project +# 3. Enable Google Calendar API (APIs & Services → Library , Search for Google Calendar API) +# 4. Create the API Key (APIs & Services → Credentials) +GOOGLE_CALENDAR_API_KEY= + # Google OAuth credentials # To enable Login with Google you need to: # 1. Set `GOOGLE_API_CREDENTIALS` below diff --git a/apps/web/modules/settings/my-account/out-of-office-view.tsx b/apps/web/modules/settings/my-account/out-of-office-view.tsx index 223d96e841..b1410fc464 100644 --- a/apps/web/modules/settings/my-account/out-of-office-view.tsx +++ b/apps/web/modules/settings/my-account/out-of-office-view.tsx @@ -4,9 +4,11 @@ import { useEffect, useState } from "react"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList"; import { CreateOrEditOutOfOfficeEntryModal } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal"; import type { BookingRedirectForm } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal"; +import { HolidaysView } from "@calcom/features/settings/outOfOffice/HolidaysView"; +import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList"; +import { OutOfOfficeTab } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup"; export default function OutOfOfficeView() { const [openModal, setOpenModal] = useState(false); @@ -15,6 +17,7 @@ export default function OutOfOfficeView() { const params = useCompatSearchParams(); const openModalOnStart = !!params?.get("om"); + const selectedTab = params?.get("type") ?? OutOfOfficeTab.MINE; useEffect(() => { if (openModalOnStart) { @@ -37,6 +40,11 @@ export default function OutOfOfficeView() { setCurrentlyEditingOutOfOfficeEntry(null); }; + // Show HolidaysView when holidays tab is selected + if (selectedTab === OutOfOfficeTab.HOLIDAYS) { + return ; + } + return ( <> ({ + getHolidayService: vi.fn(() => ({ + getHolidayDatesInRange: vi.fn(), + })), +})); + +import { getHolidayService } from "@calcom/lib/holidays"; + +import { UserAvailabilityService } from "./getUserAvailability"; + +const mockDependencies = { + oooRepo: {} as never, + bookingRepo: {} as never, + redisClient: {} as never, +}; + +describe("UserAvailabilityService.calculateHolidayBlockedDates", () => { + let service: UserAvailabilityService; + let mockHolidayService: { getHolidayDatesInRange: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new UserAvailabilityService(mockDependencies); + mockHolidayService = { + getHolidayDatesInRange: vi.fn(), + }; + vi.mocked(getHolidayService).mockReturnValue(mockHolidayService as never); + }); + + it("should return empty object when user has no holiday settings", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue(null); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] // Monday to Friday + ); + + expect(prismaMock.userHolidaySettings.findUnique).toHaveBeenCalledWith({ + where: { userId: 123 }, + select: { + countryCode: true, + disabledIds: true, + }, + }); + expect(result).toEqual({}); + }); + + it("should return empty object when user has no country selected", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: null, + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + expect(result).toEqual({}); + }); + + it("should return empty object when no holidays in date range", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-02-01"), + new Date("2025-02-28"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + // Dates are expanded to full day range (startOfDay to endOfDay in UTC) + // to ensure holidays stored at midnight UTC are found + expect(mockHolidayService.getHolidayDatesInRange).toHaveBeenCalledWith( + "US", + [], + expect.any(Date), + expect.any(Date) + ); + // Verify the date range covers the full days + const [, , startDate, endDate] = mockHolidayService.getHolidayDatesInRange.mock.calls[0]; + expect(startDate.toISOString()).toBe("2025-02-01T00:00:00.000Z"); + expect(endDate.toISOString()).toBe("2025-02-28T23:59:59.999Z"); + expect(result).toEqual({}); + }); + + it("should return holiday data for dates that match user's working days", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Wednesday, January 1, 2025 - New Year's Day + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2025-01-01", + holiday: { + id: "new_years_day_2025", + name: "New Year's Day", + date: "2025-01-01", + year: 2025, + }, + }, + ]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] // Monday(1) to Friday(5) - includes Wednesday(3) + ); + + expect(result).toEqual({ + "2025-01-01": { + fromUser: null, + toUser: null, + reason: "New Year's Day", + emoji: "📆", + }, + }); + }); + + it("should skip holidays that fall on non-working days", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Saturday, December 25, 2027 - Christmas Day + const christmasDate = "2027-12-25"; + const christmasDayOfWeek = dayjs(christmasDate).day(); // Should be 6 (Saturday) + + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: christmasDate, + holiday: { + id: "christmas_2027", + name: "Christmas Day", + date: christmasDate, + year: 2027, + }, + }, + ]); + + // User only works Monday to Friday (days 1-5), not Saturday (6) or Sunday (0) + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2027-12-01"), + new Date("2027-12-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + // Christmas 2027 is on Saturday (day 6), should be skipped + expect(christmasDayOfWeek).toBe(6); + expect(result).toEqual({}); + }); + + it("should handle multiple holidays correctly", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2025-01-01", // Wednesday + holiday: { + id: "new_years_day_2025", + name: "New Year's Day", + date: "2025-01-01", + year: 2025, + }, + }, + { + date: "2025-01-20", // Monday - MLK Day + holiday: { + id: "mlk_day_2025", + name: "Martin Luther King Jr. Day", + date: "2025-01-20", + year: 2025, + }, + }, + ]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + expect(result).toEqual({ + "2025-01-01": { + fromUser: null, + toUser: null, + reason: "New Year's Day", + emoji: "📆", + }, + "2025-01-20": { + fromUser: null, + toUser: null, + reason: "Martin Luther King Jr. Day", + emoji: "📆", + }, + }); + }); + + it("should handle availability with multiple schedules", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Thursday, July 4, 2024 - Independence Day + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2024-07-04", + holiday: { + id: "independence_day_2024", + name: "Independence Day", + date: "2024-07-04", + year: 2024, + }, + }, + ]); + + // User has multiple availability schedules, some include Thursday (4) + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2024-07-01"), + new Date("2024-07-31"), + [ + { days: [1, 3] }, // Monday, Wednesday only + { days: [2, 4] }, // Tuesday, Thursday - includes the holiday + ] + ); + + expect(result).toEqual({ + "2024-07-04": { + fromUser: null, + toUser: null, + reason: "Independence Day", + emoji: "📆", + }, + }); + }); + + it("should respect disabled holidays", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: ["christmas_2025"], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // The holiday service should receive the disabledIds and filter them out + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2025-01-01", + holiday: { + id: "new_years_day_2025", + name: "New Year's Day", + date: "2025-01-01", + year: 2025, + }, + }, + // Christmas is NOT returned because it's in disabledIds + ]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-12-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + // Verify disabledIds were passed to the service + expect(mockHolidayService.getHolidayDatesInRange).toHaveBeenCalledWith( + "US", + ["christmas_2025"], + expect.any(Date), + expect.any(Date) + ); + + // Only New Year's Day should be blocked + expect(result).toEqual({ + "2025-01-01": { + fromUser: null, + toUser: null, + reason: "New Year's Day", + emoji: "📆", + }, + }); + }); +}); + diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 49713e6f30..9722865cd4 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -18,6 +18,7 @@ import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-r import { getWorkingHours } from "@calcom/lib/availability"; import { stringToDayjsZod } from "@calcom/lib/dayjs"; import { ErrorCode } from "@calcom/lib/errorCodes"; +import { getHolidayService } from "@calcom/lib/holidays"; import { HttpError } from "@calcom/lib/http-error"; import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"; @@ -25,6 +26,7 @@ import { getPeriodStartDatesBetween as getPeriodStartDatesBetweenUtil } from "@c import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import prisma from "@calcom/prisma"; import type { Booking, Prisma, @@ -558,6 +560,19 @@ export class UserAvailabilityService { const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability); + const holidayBlockedDates = await this.calculateHolidayBlockedDates( + user.id, + dateFrom.toDate(), + dateTo.toDate(), + availability + ); + + for (const [date, holidayData] of Object.entries(holidayBlockedDates)) { + if (!datesOutOfOffice[date]) { + datesOutOfOffice[date] = holidayData; + } + } + const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ dateFrom, dateTo, @@ -687,4 +702,68 @@ export class UserAvailabilityService { } getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability"); + + async calculateHolidayBlockedDates( + userId: number, + startDate: Date, + endDate: Date, + availability: GetUserAvailabilityParamsDTO["availability"] + ): Promise { + const holidaySettings = await prisma.userHolidaySettings.findUnique({ + where: { userId }, + select: { + countryCode: true, + disabledIds: true, + }, + }); + + if (!holidaySettings || !holidaySettings.countryCode) { + return {}; + } + + // Holidays are stored as midnight UTC (e.g., 2025-12-25T00:00:00Z). + // When checking availability for a specific booking slot (e.g., 10:00-11:00), + // we need to expand the date range to include the full day so holidays are found. + // Otherwise, a booking at 10:00 wouldn't find a holiday stored at 00:00. + const startOfDay = dayjs(startDate).utc().startOf("day").toDate(); + const endOfDay = dayjs(endDate).utc().endOf("day").toDate(); + + const holidayService = getHolidayService(); + const holidayDates = await holidayService.getHolidayDatesInRange( + holidaySettings.countryCode, + holidaySettings.disabledIds, + startOfDay, + endOfDay + ); + + if (holidayDates.length === 0) { + return {}; + } + + // Match OOO pattern: get working days from availability + const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort( + (a, b) => a - b + ); + + const result: IOutOfOfficeData = {}; + + for (const { date, holiday } of holidayDates) { + // Match OOO pattern: use dayjs.utc() to parse date string and get day of week + const dayOfWeek = dayjs.utc(date).day(); + + if (!flattenDays.includes(dayOfWeek)) { + continue; + } + + // Match OOO pattern: key by the date string (already in YYYY-MM-DD UTC format) + result[date] = { + fromUser: null, + toUser: null, + reason: holiday.name, + emoji: "📆", + }; + } + + return result; + } } diff --git a/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx b/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx index bd0536e9a3..31406c096c 100644 --- a/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx +++ b/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx @@ -25,6 +25,7 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => { fromUser, toUser, emoji = "🏝️", + reason, borderDashed = true, date, className, @@ -35,7 +36,12 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => { const router = useRouter(); - if (!fromUser) return null; + // Check if this is a holiday (no fromUser but has reason) + const isHoliday = !fromUser && reason; + + // For regular OOO, require fromUser (toUser is optional for redirect) + // For holidays, we just need the reason + if (!isHoliday && !fromUser) return null; return (
{ {emoji}
-

- {t("ooo_user_is_ooo", { displayName: fromUser.displayName })} -

+ {isHoliday ? ( + <> +

{reason}

+

{t("holiday_no_availability")}

+ + ) : ( + <> +

+ {t("ooo_user_is_ooo", { displayName: fromUser?.displayName })} +

- {notes && showNotePublicly && ( -

- {notes} -

- )} + {notes && showNotePublicly && ( +

+ {notes} +

+ )} - {fromUser?.displayName && toUser?.displayName && ( -

- username]} - /> -

+ {fromUser?.displayName && toUser?.displayName && ( +

+ username]} + /> +

+ )} + )}
- {toUser?.id && ( + {!isHoliday && toUser?.id && (
) : affectedMembers?.length ? ( <> -
    +
      {affectedMembers.slice(0, 5).map((m) => (
    • {m.name ? `${m.name} (${m.email})` : m.email} diff --git a/packages/features/ee/teams/components/EditMemberSheet.tsx b/packages/features/ee/teams/components/EditMemberSheet.tsx index 8611d8d9f3..8a3e10e1e0 100644 --- a/packages/features/ee/teams/components/EditMemberSheet.tsx +++ b/packages/features/ee/teams/components/EditMemberSheet.tsx @@ -219,8 +219,8 @@ export function EditMemberSheet({ - -
      + +

      {t("profile")}

      diff --git a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx index 36f5f5b604..5e087a23aa 100644 --- a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx +++ b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx @@ -4,21 +4,21 @@ import React, { useState } from "react"; import { useFormContext, Controller } from "react-hook-form"; import { z } from "zod"; -import PhoneInput from "@calcom/features/components/phone-input"; import { getTemplateFieldsSchema } from "@calcom/features/calAIPhone/getTemplateFieldsSchema"; import { templateFieldsMap } from "@calcom/features/calAIPhone/template-fields-map"; import type { TemplateType } from "@calcom/features/calAIPhone/zod-utils"; +import PhoneInput from "@calcom/features/components/phone-input"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types"; import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Label } from "@calcom/ui/components/form"; -import { Divider } from "@calcom/ui/components/divider"; -import { TextField } from "@calcom/ui/components/form"; -import { Button } from "@calcom/ui/components/button"; import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; +import { Divider } from "@calcom/ui/components/divider"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { Label } from "@calcom/ui/components/form"; +import { TextField } from "@calcom/ui/components/form"; import { SettingsToggle } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index 70b393c773..54021d3e03 100644 --- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -96,7 +96,7 @@ const ChildrenEventTypesList = ({ } & Omit>, "onChange" | "value">) => { const { t } = useLocale(); return ( -
      +

      @@ -475,7 +475,7 @@ const RoundRobinHosts = ({

      @@ -830,7 +830,7 @@ export const EventTeamAssignmentTab = ({

      {t("assignment_description")} @@ -865,7 +865,7 @@ export const EventTeamAssignmentTab = ({

      -

      +

      {t("rr_distribution_method_description")}

      diff --git a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx index 2f13c9226c..2e63292860 100644 --- a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx +++ b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import dayjs from "@calcom/dayjs"; @@ -11,6 +11,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import classNames from "@calcom/ui/classNames"; +import { Alert } from "@calcom/ui/components/alert"; import { UpgradeTeamsBadge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; @@ -162,9 +163,28 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ const watchedTeamUserId = watch("toTeamUserId"); const watchForUserId = watch("forUserId"); + const watchedDateRange = watch("dateRange"); const watchedNotes = watch("notes"); const hasValidNotes = Boolean(watchedNotes?.trim()); + // Fetch user's holiday settings to show warning if OOO dates overlap with holidays + const { data: holidaySettings } = trpc.viewer.holidays.getUserSettings.useQuery({}); + + // Check if selected dates overlap with any enabled holidays + const overlappingHolidays = useMemo(() => { + if (!holidaySettings?.countryCode || !watchedDateRange?.startDate || !watchedDateRange?.endDate) { + return []; + } + + // Filter holidays that are enabled and fall within the date range + const startStr = dayjs(watchedDateRange.startDate).format("YYYY-MM-DD"); + const endStr = dayjs(watchedDateRange.endDate).format("YYYY-MM-DD"); + + return (holidaySettings.holidays || []) + .filter((h) => h.enabled && h.date >= startStr && h.date <= endStr) + .map((h) => ({ date: h.date, holiday: { id: h.id, name: h.name } })); + }, [holidaySettings, watchedDateRange]); + const createOrEditOutOfOfficeEntry = trpc.viewer.ooo.outOfOfficeCreateOrUpdate.useMutation({ onSuccess: () => { showToast( @@ -310,6 +330,29 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ )} />
      + + {/* Holiday overlap warning */} + {overlappingHolidays.length > 0 && ( + h.holiday.name) + .join(", "), + }) + } + /> + )}
      {/* Reason Select */} diff --git a/packages/features/settings/outOfOffice/HolidaysView.tsx b/packages/features/settings/outOfOffice/HolidaysView.tsx new file mode 100644 index 0000000000..026affbab6 --- /dev/null +++ b/packages/features/settings/outOfOffice/HolidaysView.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { memo, useMemo, useCallback } from "react"; + +import dayjs from "@calcom/dayjs"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import { Alert } from "@calcom/ui/components/alert"; +import { Button } from "@calcom/ui/components/button"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { Label, Select } from "@calcom/ui/components/form"; +import { Switch } from "@calcom/ui/components/form"; +import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; +import { showToast } from "@calcom/ui/components/toast"; + +import { OutOfOfficeToggleGroup } from "./OutOfOfficeToggleGroup"; + +function HolidaysCTA() { + const { t } = useLocale(); + return ( +
      + + +
      + ); +} + +type HolidayWithStatus = RouterOutputs["viewer"]["holidays"]["getUserSettings"]["holidays"][number]; +type Country = { code: string; name: string }; + +const CountrySelector = memo(function CountrySelector({ + countries, + value, + onChange, + isLoading, +}: { + countries: Country[]; + value: string; + onChange: (value: string) => void; + isLoading: boolean; +}) { + const { t } = useLocale(); + + const options = useMemo( + () => [ + { value: "", label: t("no_holidays") }, + ...countries.map((country) => ({ + value: country.code, + label: country.name, + })), + ], + [countries, t] + ); + + const selectedOption = useMemo(() => { + if (!value) return { value: "", label: t("select_country") }; + const country = countries.find((c) => c.code === value); + return { value, label: country?.name || value }; + }, [value, countries, t]); + + if (isLoading) { + return ; + } + + return ( +