diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 3f82d01118f157..c9c6563e88d29b 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -83,7 +83,7 @@ jobs: - uses: ./.github/actions/yarn-install - uses: ./.github/actions/cache-db - name: Run Tests - run: yarn test -- --integrationTestsOnly + run: VITEST_MODE=integration yarn test # TODO: Generate test results so we can upload them # - name: Upload Test Results # if: ${{ always() }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4d78faf507d56f..34fd4c1d086bd7 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -16,4 +16,4 @@ jobs: - uses: ./.github/actions/yarn-install - run: yarn test -- --no-isolate # We could add different timezones here that we need to run our tests in - - run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly --no-isolate + - run: TZ=America/Los_Angeles VITEST_MODE=timezone yarn test -- --no-isolate diff --git a/apps/api/v1/lib/helpers/verifyApiKey.test.ts b/apps/api/v1/lib/helpers/verifyApiKey.test.ts index 8dbe3504bc421a..1ec41081b8f085 100644 --- a/apps/api/v1/lib/helpers/verifyApiKey.test.ts +++ b/apps/api/v1/lib/helpers/verifyApiKey.test.ts @@ -73,8 +73,8 @@ describe("Verify API key - Unit Tests", () => { verifyKeyByHashedKey: vi.fn(), } as unknown as ApiKeyService; - vi.mocked(ApiKeyService).mockImplementation(() => mockApiKeyService); - vi.mocked(PrismaApiKeyRepository).mockImplementation(() => ({} as unknown as PrismaApiKeyRepository)); + vi.mocked(ApiKeyService).mockImplementation(function() { return mockApiKeyService; }); + vi.mocked(PrismaApiKeyRepository).mockImplementation(function() { return {} as unknown as PrismaApiKeyRepository; }); vi.mocked(isAdminGuard).mockReset(); vi.mocked(isLockedOrBlocked).mockReset(); diff --git a/apps/api/v1/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts index de13aeb627b71e..c415a6cd2c839f 100644 --- a/apps/api/v1/test/lib/bookings/_post.test.ts +++ b/apps/api/v1/test/lib/bookings/_post.test.ts @@ -67,9 +67,9 @@ vi.mock("@calcom/features/webhooks/lib/sendOrSchedulePayload", () => ({ const mockFindOriginalRescheduledBooking = vi.fn(); vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({ - BookingRepository: vi.fn().mockImplementation(() => ({ + BookingRepository: vi.fn().mockImplementation(function() { return { findOriginalRescheduledBooking: mockFindOriginalRescheduledBooking, - })), + }; }), })); vi.mock("@calcom/features/watchlist/operations/check-if-users-are-blocked.controller", () => ({ @@ -87,23 +87,25 @@ vi.mock("@calcom/features/di/containers/QualifiedHosts", () => ({ })); vi.mock("@calcom/features/bookings/lib/EventManager", () => ({ - default: vi.fn().mockImplementation(() => ({ - reschedule: vi.fn().mockResolvedValue({ - results: [], - referencesToCreate: [], - }), - create: vi.fn().mockResolvedValue({ - results: [], - referencesToCreate: [], - }), - update: vi.fn().mockResolvedValue({ - results: [], - referencesToCreate: [], - }), - createAllCalendarEvents: vi.fn().mockResolvedValue([]), - updateAllCalendarEvents: vi.fn().mockResolvedValue([]), - deleteEventsAndMeetings: vi.fn().mockResolvedValue([]), - })), + default: vi.fn().mockImplementation(function() { + return { + reschedule: vi.fn().mockResolvedValue({ + results: [], + referencesToCreate: [], + }), + create: vi.fn().mockResolvedValue({ + results: [], + referencesToCreate: [], + }), + update: vi.fn().mockResolvedValue({ + results: [], + referencesToCreate: [], + }), + createAllCalendarEvents: vi.fn().mockResolvedValue([]), + updateAllCalendarEvents: vi.fn().mockResolvedValue([]), + deleteEventsAndMeetings: vi.fn().mockResolvedValue([]), + }; + }), placeholderCreatedEvent: { results: [], referencesToCreate: [], @@ -146,10 +148,10 @@ vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({ }, })); vi.mock("@calcom/features/flags/features.repository", () => ({ - FeaturesRepository: vi.fn().mockImplementation(() => ({ + FeaturesRepository: vi.fn().mockImplementation(function() { return { checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(false), checkIfTeamHasFeature: vi.fn().mockResolvedValue(false), - })), + }; }), })); vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({ diff --git a/apps/api/v1/test/lib/users/_post.test.ts b/apps/api/v1/test/lib/users/_post.test.ts index 09a9c34a1b6b4e..42f84bf329beb6 100644 --- a/apps/api/v1/test/lib/users/_post.test.ts +++ b/apps/api/v1/test/lib/users/_post.test.ts @@ -35,9 +35,9 @@ vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.contr const mockCreate = vi.fn(); vi.mock("@calcom/features/users/repositories/UserRepository", () => ({ - UserRepository: vi.fn().mockImplementation(() => ({ + UserRepository: vi.fn().mockImplementation(function() { return { create: mockCreate, - })), + }; }), })); vi.mock("@calcom/lib/auth/hashPassword", () => ({ diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 81077807818690..df86d487797452 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -54,14 +54,14 @@ "@nestjs/jwt": "10.2.0", "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "10.4.20", - "@nestjs/swagger": "7.3.0", + "@nestjs/swagger": "7.4.2", "@nestjs/throttler": "6.2.1", "@sentry/nestjs": "9.46.0", "@sentry/node": "9.46.0", "@sentry/profiling-node": "9.46.0", "@snyk/protect": "latest", "axios": "1.13.2", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "bull": "4.15.1", "class-transformer": "0.5.1", "class-validator": "0.14.3", diff --git a/apps/web/app/api/cron/selected-calendars/__tests__/cron.test.ts b/apps/web/app/api/cron/selected-calendars/__tests__/cron.test.ts index 0157e420d11387..37037d4b1e5d17 100644 --- a/apps/web/app/api/cron/selected-calendars/__tests__/cron.test.ts +++ b/apps/web/app/api/cron/selected-calendars/__tests__/cron.test.ts @@ -11,9 +11,9 @@ import { handleCreateSelectedCalendars, isSameEmail } from "../route"; const getPrimaryCalendarMock = vi.fn(); vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => { return { - default: vi.fn().mockImplementation(() => ({ + default: vi.fn().mockImplementation(function() { return { getPrimaryCalendar: getPrimaryCalendarMock, - })), + }; }), }; }); diff --git a/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts b/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts index 5eafdbe7614db3..ec51b25d2426b8 100644 --- a/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts +++ b/apps/web/app/api/routing-forms/queued-response/__tests__/queued-response.test.ts @@ -50,9 +50,9 @@ const mockQueuedFormResponse = { describe("queuedResponseHandler", () => { beforeEach(() => { - vi.mocked(RoutingFormResponseRepository).mockImplementation( - () => mockRoutingFormResponseRepository as any - ); + vi.mocked(RoutingFormResponseRepository).mockImplementation(function () { + return mockRoutingFormResponseRepository as any; + }); }); it("should process a queued form response", async () => { diff --git a/apps/web/app/api/social/og/image/__tests__/route.test.ts b/apps/web/app/api/social/og/image/__tests__/route.test.ts index 34512010ac9619..a7ec4c7681ab3f 100644 --- a/apps/web/app/api/social/og/image/__tests__/route.test.ts +++ b/apps/web/app/api/social/og/image/__tests__/route.test.ts @@ -6,14 +6,14 @@ import { getOGImageVersion } from "@calcom/lib/OgImages"; import { GET } from "../route"; vi.mock("next/og", () => ({ - ImageResponse: vi.fn().mockImplementation(() => ({ + ImageResponse: vi.fn().mockImplementation(function() { return { body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([1, 2, 3, 4])); controller.close(); }, }), - })), + }; }), })); vi.mock("@calcom/lib/OgImages", async (importOriginal) => { @@ -102,8 +102,11 @@ describe("GET /api/social/og/image", () => { }); describe("Server errors (500 Internal Server Error)", () => { - test("returns 500 when font loading fails", async () => { - vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed")); + test("returns 500 when ImageResponse throws", async () => { + const { ImageResponse } = await import("next/og"); + vi.mocked(ImageResponse).mockImplementation(function () { + throw new Error("ImageResponse failed"); + }); const request = createNextRequest( "http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John" diff --git a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts index 785ae113eda61f..52331372c84823 100644 --- a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts @@ -76,10 +76,10 @@ const mockSendCreditBalanceLimitReachedEmails = vi.fn(); const mockSendCreditBalanceLowWarningEmails = vi.fn(); vi.mock("@calcom/features/ee/billing/credit-service", () => ({ - CreditService: vi.fn().mockImplementation(() => ({ + CreditService: vi.fn().mockImplementation(function() { return { hasAvailableCredits: mockHasAvailableCredits, chargeCredits: mockChargeCredits, - })), + }; }), })); vi.mock("@calcom/emails/email-manager", () => ({ @@ -92,15 +92,15 @@ const mockFindByPhoneNumber = vi.fn(); const mockFindByProviderAgentId = vi.fn(); vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => ({ - PrismaPhoneNumberRepository: vi.fn().mockImplementation(() => ({ + PrismaPhoneNumberRepository: vi.fn().mockImplementation(function() { return { findByPhoneNumber: mockFindByPhoneNumber, - })), + }; }), })); vi.mock("@calcom/features/calAIPhone/repositories/PrismaAgentRepository", () => ({ - PrismaAgentRepository: vi.fn().mockImplementation(() => ({ + PrismaAgentRepository: vi.fn().mockImplementation(function() { return { findByProviderAgentId: mockFindByProviderAgentId, - })), + }; }), })); vi.mock("next/server", () => ({ diff --git a/apps/web/components/apps/routing-forms/TestFormDialog.test.tsx b/apps/web/components/apps/routing-forms/TestFormDialog.test.tsx index 9c4b7e69185235..5f2b366b83e0f7 100644 --- a/apps/web/components/apps/routing-forms/TestFormDialog.test.tsx +++ b/apps/web/components/apps/routing-forms/TestFormDialog.test.tsx @@ -89,6 +89,10 @@ vi.mock("@calcom/lib/hooks/useLocale", () => ({ useLocale: vi.fn(() => ({ t: (key: string) => key })), })); +vi.mock("@calcom/features/ee/organizations/context/provider", () => ({ + useOrgBranding: vi.fn(() => null), +})); + let findTeamMembersMatchingAttributeLogicResponse: { result: { users: { email: string }[] } | null; checkedFallback: boolean; diff --git a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx index 4848a8027e4034..b1143b244da480 100644 --- a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx +++ b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx @@ -85,6 +85,10 @@ vi.mock("@calcom/lib/hooks/useLocale", () => ({ useLocale: vi.fn(() => ({ t: (key: string) => key })), })); +vi.mock("@calcom/features/ee/organizations/context/provider", () => ({ + useOrgBranding: vi.fn(() => null), +})); + vi.mock("@calcom/web/lib/hooks/useRouterQuery", () => ({ default: vi.fn(() => { return { diff --git a/apps/web/modules/users/views/users-public-view.test.tsx b/apps/web/modules/users/views/users-public-view.test.tsx index 407f2a6293eae7..ef780434760e6c 100644 --- a/apps/web/modules/users/views/users-public-view.test.tsx +++ b/apps/web/modules/users/views/users-public-view.test.tsx @@ -10,6 +10,14 @@ vi.mock("@calcom/lib/constants", async () => { return await vi.importActual("@calcom/lib/constants"); }); +vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({ + getOrgFullOrigin: vi.fn(), +})); + +vi.mock("@calcom/lib/hooks/useRouterQuery", () => ({ + useRouterQuery: vi.fn(), +})); + function mockedUserPageComponentProps(props: Partial>) { return { themeBasis: "dark", diff --git a/apps/web/test/lib/checkBookingLimits.test.ts b/apps/web/test/lib/checkBookingLimits.test.ts index 563fa66d893098..c0bd694716822b 100644 --- a/apps/web/test/lib/checkBookingLimits.test.ts +++ b/apps/web/test/lib/checkBookingLimits.test.ts @@ -7,9 +7,9 @@ import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateI const mockCountBookingsByEventTypeAndDateRange = vi.fn(); vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({ - BookingRepository: vi.fn().mockImplementation(() => ({ + BookingRepository: vi.fn().mockImplementation(function() { return { countBookingsByEventTypeAndDateRange: mockCountBookingsByEventTypeAndDateRange, - })), + }; }), })); type Mockdata = { diff --git a/apps/web/test/lib/checkDurationLimits.test.ts b/apps/web/test/lib/checkDurationLimits.test.ts index 2d2c435558dbf1..ff3ca6cd6d1392 100644 --- a/apps/web/test/lib/checkDurationLimits.test.ts +++ b/apps/web/test/lib/checkDurationLimits.test.ts @@ -6,9 +6,9 @@ import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateI const mockGetTotalBookingDuration = vi.fn(); vi.mock("@calcom/features/bookings/repositories/BookingRepository", () => ({ - BookingRepository: vi.fn().mockImplementation(() => ({ + BookingRepository: vi.fn().mockImplementation(function() { return { getTotalBookingDuration: mockGetTotalBookingDuration, - })), + }; }), })); type MockData = { diff --git a/apps/web/test/lib/getSchedule/restrictionSchedule.test.ts b/apps/web/test/lib/getSchedule/restrictionSchedule.test.ts index 257fc32f66ca62..975c588449a83f 100644 --- a/apps/web/test/lib/getSchedule/restrictionSchedule.test.ts +++ b/apps/web/test/lib/getSchedule/restrictionSchedule.test.ts @@ -18,13 +18,15 @@ import { setupAndTeardown } from "./setupAndTeardown"; // Mock the FeaturesRepository to enable restriction-schedule feature vi.mock("@calcom/features/flags/features.repository", () => ({ - FeaturesRepository: vi.fn().mockImplementation(() => ({ - checkIfTeamHasFeature: vi.fn().mockResolvedValue(true), - checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), - getAllFeatures: vi.fn().mockResolvedValue([]), - getFeatureFlagMap: vi.fn().mockResolvedValue({}), - checkIfUserHasFeature: vi.fn().mockResolvedValue(true), - })), + FeaturesRepository: vi.fn().mockImplementation(function() { + return { + checkIfTeamHasFeature: vi.fn().mockResolvedValue(true), + checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true), + getAllFeatures: vi.fn().mockResolvedValue([]), + getFeatureFlagMap: vi.fn().mockResolvedValue({}), + checkIfUserHasFeature: vi.fn().mockResolvedValue(true), + }; + }), })); type ScheduleScenario = { diff --git a/apps/web/test/utils/bookingScenario/test.ts b/apps/web/test/utils/bookingScenario/test.ts index f91c3f9cafc837..890ce3461a688f 100644 --- a/apps/web/test/utils/bookingScenario/test.ts +++ b/apps/web/test/utils/bookingScenario/test.ts @@ -1,39 +1,39 @@ import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; -import type { TestFunction } from "vitest"; - import { WEBSITE_URL } from "@calcom/lib/constants"; import { test } from "@calcom/web/test/fixtures/fixtures"; import type { Fixtures } from "@calcom/web/test/fixtures/fixtures"; +type OrgContext = { + org: { + organization: { id: number | null }; + urlOrigin?: string; + } | null; +}; + const WEBSITE_PROTOCOL = new URL(WEBSITE_URL).protocol; const _testWithAndWithoutOrg = ( - description: Parameters[0], - fn: Parameters[1], - timeout: Parameters[2], + description: string, + fn: (context: Fixtures & OrgContext) => Promise | void, + timeout: number | undefined, mode: "only" | "skip" | "run" = "run" -) => { +): void => { const t = mode === "only" ? test.only : mode === "skip" ? test.skip : test; t( `${description} - With org`, - async ({ emails, sms, task, onTestFailed, expect, skip, onTestFinished }) => { + async ({ emails, sms }) => { const org = await createOrganization({ name: "Test Org", slug: "testorg", }); await fn({ - task, - onTestFailed, - expect, emails, sms, - skip, org: { organization: org, urlOrigin: `${WEBSITE_PROTOCOL}//${org.slug}.cal.local:3000`, }, - onTestFinished, }); }, timeout @@ -41,41 +41,31 @@ const _testWithAndWithoutOrg = ( t( `${description}`, - async ({ emails, sms, task, onTestFailed, expect, skip, onTestFinished }) => { + async ({ emails, sms }) => { await fn({ emails, sms, - task, - onTestFailed, - expect, - skip, org: null, - onTestFinished, }); }, timeout ); }; +type TestFunctionWithOrg = (context: Fixtures & OrgContext) => Promise | void; + export const testWithAndWithoutOrg = ( description: string, - fn: TestFunction< - Fixtures & { - org: { - organization: { id: number | null }; - urlOrigin?: string; - } | null; - } - >, + fn: TestFunctionWithOrg, timeout?: number -) => { +): void => { _testWithAndWithoutOrg(description, fn, timeout, "run"); }; -testWithAndWithoutOrg.only = ((description, fn, timeout) => { +testWithAndWithoutOrg.only = ((description: string, fn: TestFunctionWithOrg, timeout?: number): void => { _testWithAndWithoutOrg(description, fn, timeout, "only"); -}) as typeof _testWithAndWithoutOrg; +}) as typeof testWithAndWithoutOrg; -testWithAndWithoutOrg.skip = ((description, fn, timeout) => { +testWithAndWithoutOrg.skip = ((description: string, fn: TestFunctionWithOrg, timeout?: number): void => { _testWithAndWithoutOrg(description, fn, timeout, "skip"); -}) as typeof _testWithAndWithoutOrg; +}) as typeof testWithAndWithoutOrg; diff --git a/biome.json b/biome.json index 96783919f24126..afc4318e1f5618 100644 --- a/biome.json +++ b/biome.json @@ -69,6 +69,16 @@ } }, "overrides": [ + { + "includes": ["**/*.test.ts", "**/*.test.tsx"], + "linter": { + "rules": { + "complexity": { + "useArrowFunction": "off" + } + } + } + }, { "includes": ["**/*.tsx"], "javascript": { @@ -76,11 +86,7 @@ } }, { - "includes": [ - "apps/web/app/**/page.tsx", - "apps/web/app/**/layout.tsx", - "apps/web/app/pages/**/*.tsx" - ], + "includes": ["apps/web/app/**/page.tsx", "apps/web/app/**/layout.tsx", "apps/web/app/pages/**/*.tsx"], "linter": { "rules": { "style": { diff --git a/companion/app.json b/companion/app.json index a5303ae54b72b2..ed64a3a595fdf7 100644 --- a/companion/app.json +++ b/companion/app.json @@ -1,6 +1,6 @@ { "expo": { - "name": "expo-wxt-app", + "name": "Calcom", "slug": "calcom-companion", "scheme": "expo-wxt-app", "version": "1.0.0", @@ -22,7 +22,8 @@ }, "entitlements": { "com.apple.developer.default-data-protection": "NSFileProtectionComplete" - } + }, + "icon": "./assets/cal-logo.icon" }, "android": { "adaptiveIcon": { @@ -44,7 +45,20 @@ } }, "owner": "calcoms-organization", - "plugins": ["expo-router", "expo-secure-store", "expo-web-browser", "expo-image"], + "plugins": [ + "expo-router", + "expo-secure-store", + "expo-web-browser", + "expo-image", + [ + "expo-splash-screen", + { + "backgroundColor": "#ffffff", + "image": "./assets/splash-icon.png", + "imageWidth": 200 + } + ] + ], "experiments": { "typedRoutes": true, "reactCompiler": true diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx index 8a4e401b848232..650b0d5308ec44 100644 --- a/companion/app/(tabs)/(availability)/index.tsx +++ b/companion/app/(tabs)/(availability)/index.tsx @@ -1,17 +1,20 @@ import { isLiquidGlassAvailable } from "expo-glass-effect"; import { Stack, useRouter } from "expo-router"; import { useState } from "react"; -import { Alert, Platform } from "react-native"; +import { Alert, Platform, Pressable } from "react-native"; import { AvailabilityListScreen } from "@/components/screens/AvailabilityListScreen"; -import { useCreateSchedule } from "@/hooks"; +import { useCreateSchedule, useUserProfile } from "@/hooks"; import { CalComAPIService } from "@/services/calcom"; import { showErrorAlert } from "@/utils/alerts"; +import { getAvatarUrl } from "@/utils/getAvatarUrl"; +import { Image } from "expo-image"; export default function Availability() { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(""); const [showCreateModal, setShowCreateModal] = useState(false); const { mutate: createScheduleMutation } = useCreateSchedule(); + const { data: userProfile } = useUserProfile(); const handleCreateNew = () => { // Use native iOS Alert.prompt for a native look @@ -68,7 +71,10 @@ export default function Availability() { console.error("Failed to create schedule", message); if (__DEV__) { const stack = error instanceof Error ? error.stack : undefined; - console.debug("[Availability] createSchedule failed", { message, stack }); + console.debug("[Availability] createSchedule failed", { + message, + stack, + }); } showErrorAlert("Error", "Failed to create schedule. Please try again."); }, @@ -102,9 +108,20 @@ export default function Availability() { {/* Profile Button */} - router.push("/profile-sheet")}> - - + {userProfile?.avatarUrl ? ( + + router.push("/profile-sheet")}> + + + + ) : ( + router.push("/profile-sheet")}> + + + )} - - - - - - ); } diff --git a/companion/app/(tabs)/(event-types)/index.ios.tsx b/companion/app/(tabs)/(event-types)/index.ios.tsx index 40d9f4f573b849..3487ce603f6cf5 100644 --- a/companion/app/(tabs)/(event-types)/index.ios.tsx +++ b/companion/app/(tabs)/(event-types)/index.ios.tsx @@ -8,6 +8,7 @@ import { useMemo, useState } from "react"; import { ActionSheetIOS, Alert, + Pressable, RefreshControl, ScrollView, Share, @@ -17,13 +18,13 @@ import { } from "react-native"; import { EmptyScreen } from "@/components/EmptyScreen"; import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem"; -import { FullScreenModal } from "@/components/FullScreenModal"; import { LoadingSpinner } from "@/components/LoadingSpinner"; import { useCreateEventType, useDeleteEventType, useDuplicateEventType, useEventTypes, + useUserProfile, } from "@/hooks"; import { CalComAPIService, type EventType } from "@/services/calcom"; import { showErrorAlert } from "@/utils/alerts"; @@ -32,10 +33,13 @@ import { getEventDuration } from "@/utils/getEventDuration"; import { offlineAwareRefresh } from "@/utils/network"; import { normalizeMarkdown } from "@/utils/normalizeMarkdown"; import { slugify } from "@/utils/slugify"; +import { Image } from "expo-image"; +import { getAvatarUrl } from "@/utils/getAvatarUrl"; export default function EventTypesIOS() { const router = useRouter(); const [searchQuery, setSearchQuery] = useState(""); + const { data: userProfile } = useUserProfile(); // No modal state needed for iOS - using native Alert.prompt @@ -52,7 +56,7 @@ export default function EventTypesIOS() { const refreshing = isFetching && !loading; const { mutate: createEventTypeMutation } = useCreateEventType(); - const { mutate: deleteEventTypeMutation, isPending: isDeleting } = useDeleteEventType(); + const { mutate: deleteEventTypeMutation } = useDeleteEventType(); const { mutate: duplicateEventTypeMutation } = useDuplicateEventType(); // Convert query error to string @@ -64,9 +68,7 @@ export default function EventTypesIOS() { queryError?.message?.includes("401"); const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null; - // Modal state for delete confirmation - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [eventTypeToDelete, setEventTypeToDelete] = useState(null); + // No modal state needed for iOS - using native Alert for delete confirmation // Handle pull-to-refresh (offline-aware) const onRefresh = () => offlineAwareRefresh(refetch); @@ -159,30 +161,35 @@ export default function EventTypesIOS() { }; const handleDelete = (eventType: EventType) => { - setEventTypeToDelete(eventType); - setShowDeleteModal(true); - }; - - const confirmDelete = () => { - if (!eventTypeToDelete) return; - - deleteEventTypeMutation(eventTypeToDelete.id, { - onSuccess: () => { - // Close modal and reset state - setShowDeleteModal(false); - setEventTypeToDelete(null); - Alert.alert("Success", "Event type deleted successfully"); - }, - onError: (deleteError) => { - const message = deleteError instanceof Error ? deleteError.message : String(deleteError); - console.error("Failed to delete event type", message); - if (__DEV__) { - const stack = deleteError instanceof Error ? deleteError.stack : undefined; - console.debug("[EventTypes] deleteEventType failed", { message, stack }); - } - showErrorAlert("Error", "Failed to delete event type. Please try again."); - }, - }); + // Use native iOS Alert for confirmation + Alert.alert( + "Delete Event Type", + `Are you sure you want to delete "${eventType.title}"? This action cannot be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + deleteEventTypeMutation(eventType.id, { + onSuccess: () => { + Alert.alert("Success", "Event type deleted successfully"); + }, + onError: (deleteError) => { + const message = + deleteError instanceof Error ? deleteError.message : String(deleteError); + console.error("Failed to delete event type", message); + if (__DEV__) { + const stack = deleteError instanceof Error ? deleteError.stack : undefined; + console.debug("[EventTypes] deleteEventType failed", { message, stack }); + } + showErrorAlert("Error", "Failed to delete event type. Please try again."); + }, + }); + }, + }, + ] + ); }; const handleDuplicate = (eventType: EventType) => { @@ -216,7 +223,10 @@ export default function EventTypesIOS() { console.error("Failed to duplicate event type", message); if (__DEV__) { const stack = duplicateError instanceof Error ? duplicateError.stack : undefined; - console.debug("[EventTypes] duplicateEventType failed", { message, stack }); + console.debug("[EventTypes] duplicateEventType failed", { + message, + stack, + }); } showErrorAlert("Error", "Failed to duplicate event type. Please try again."); }, @@ -287,7 +297,10 @@ export default function EventTypesIOS() { console.error("Failed to create event type", message); if (__DEV__) { const stack = createError instanceof Error ? createError.stack : undefined; - console.debug("[EventTypes] createEventType failed", { message, stack }); + console.debug("[EventTypes] createEventType failed", { + message, + stack, + }); } showErrorAlert("Error", "Failed to create event type. Please try again."); }, @@ -415,9 +428,20 @@ export default function EventTypesIOS() { {/* Profile Button - Opens bottom sheet */} - router.push("/profile-sheet")}> - - + {userProfile?.avatarUrl ? ( + + router.push("/profile-sheet")}> + + + + ) : ( + router.push("/profile-sheet")}> + + + )} {/* Search Bar */} @@ -492,69 +516,6 @@ export default function EventTypesIOS() { - - {/* Delete Confirmation Modal */} - { - if (!isDeleting) { - setShowDeleteModal(false); - setEventTypeToDelete(null); - } - }} - > - - - {/* Header with icon and title */} - - - {/* Danger icon */} - - - - - {/* Title and description */} - - - Delete Event Type - - - {eventTypeToDelete ? ( - <> - This will permanently delete the "{eventTypeToDelete.title}" event type. - This action cannot be undone. - - ) : null} - - - - - - {/* Footer with buttons */} - - - Delete - - - { - setShowDeleteModal(false); - setEventTypeToDelete(null); - }} - disabled={isDeleting} - > - Cancel - - - - - ); } diff --git a/companion/app/(tabs)/(more)/index.ios.tsx b/companion/app/(tabs)/(more)/index.ios.tsx index 410c38ea6bda2e..6b9687414c0112 100644 --- a/companion/app/(tabs)/(more)/index.ios.tsx +++ b/companion/app/(tabs)/(more)/index.ios.tsx @@ -2,11 +2,14 @@ import { Ionicons } from "@expo/vector-icons"; import { isLiquidGlassAvailable } from "expo-glass-effect"; import { Stack, useRouter } from "expo-router"; import { useState } from "react"; -import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { Alert, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { LogoutConfirmModal } from "@/components/LogoutConfirmModal"; import { useAuth } from "@/contexts/AuthContext"; import { showErrorAlert } from "@/utils/alerts"; import { openInAppBrowser } from "@/utils/browser"; +import { getAvatarUrl } from "@/utils/getAvatarUrl"; +import { Image } from "expo-image"; +import { useUserProfile } from "@/hooks"; interface MoreMenuItem { name: string; @@ -20,6 +23,7 @@ export default function More() { const router = useRouter(); const { logout } = useAuth(); const [showLogoutModal, setShowLogoutModal] = useState(false); + const { data: userProfile } = useUserProfile(); const performLogout = async () => { try { @@ -88,10 +92,20 @@ export default function More() { > More - {/* Profile Button */} - router.push("/profile-sheet")}> - - + {userProfile?.avatarUrl ? ( + + router.push("/profile-sheet")}> + + + + ) : ( + router.push("/profile-sheet")}> + + + )} diff --git a/companion/app/_layout.tsx b/companion/app/_layout.tsx index 88c8931bef2dcb..88b26593b6e43f 100644 --- a/companion/app/_layout.tsx +++ b/companion/app/_layout.tsx @@ -40,6 +40,163 @@ function RootLayoutContent() { headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light", }} /> + + + + + + ) : ( diff --git a/companion/app/add-guests.ios.tsx b/companion/app/add-guests.ios.tsx new file mode 100644 index 00000000000000..d94c585db5ad6a --- /dev/null +++ b/companion/app/add-guests.ios.tsx @@ -0,0 +1,125 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen"; +import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen"; +import { type Booking, CalComAPIService } from "@/services/calcom"; + +// Semi-transparent background to prevent black flash while preserving glass effect +const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function AddGuestsIOS() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [booking, setBooking] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [guestCount, setGuestCount] = useState(0); + + const addGuestsScreenRef = useRef(null); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then(setBooking) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const handleSave = useCallback(() => { + addGuestsScreenRef.current?.submit(); + }, []); + + const handleAddGuestsSuccess = useCallback(() => { + router.back(); + }, [router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + const showSaveButton = guestCount > 0; + + return ( + <> + + + + + router.back()}> + + + + + Add Guests + + + {showSaveButton ? ( + + + + ) : null} + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/add-guests.tsx b/companion/app/add-guests.tsx similarity index 76% rename from companion/app/(tabs)/(bookings)/add-guests.tsx rename to companion/app/add-guests.tsx index 6d994f89cb5015..df99d002e18358 100644 --- a/companion/app/(tabs)/(bookings)/add-guests.tsx +++ b/companion/app/add-guests.tsx @@ -1,6 +1,7 @@ +import { Ionicons } from "@expo/vector-icons"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Alert, Platform, Text, View } from "react-native"; +import { ActivityIndicator, Alert, Platform, View } from "react-native"; import { AppPressable } from "@/components/AppPressable"; import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen"; import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen"; @@ -40,14 +41,23 @@ export default function AddGuests() { router.back(); }, [router]); + const renderHeaderLeft = useCallback( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + const renderHeaderRight = useCallback( () => ( - Save + ), [handleSave, isSaving] @@ -63,9 +73,8 @@ export default function AddGuests() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Add Guests )} @@ -83,17 +92,24 @@ export default function AddGuests() { options={{ title: "Add Guests", headerBackButtonDisplayMode: "minimal", + headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined, headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined, }} /> {Platform.OS === "ios" && ( - + + + router.back()}> + + + + Add Guests - Save + diff --git a/companion/app/availability-detail.tsx b/companion/app/availability-detail.tsx index 88d107038ac374..6aa2766ca3292b 100644 --- a/companion/app/availability-detail.tsx +++ b/companion/app/availability-detail.tsx @@ -1,17 +1,47 @@ -import { Stack, useLocalSearchParams } from "expo-router"; -import { AvailabilityDetailScreen } from "@/components/screens/AvailabilityDetailScreen"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useRef } from "react"; +import { Platform } from "react-native"; +import { + AvailabilityDetailScreen, + type AvailabilityDetailScreenHandle, +} from "@/components/screens/AvailabilityDetailScreen"; export default function AvailabilityDetail() { const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const screenRef = useRef(null); if (!id) { return null; } + const handleSave = () => { + screenRef.current?.save(); + }; + return ( <> - + + {Platform.OS === "ios" && ( + + + router.back()}> + + + + + Edit Availability + + + + Save + + + + )} + + ); } diff --git a/companion/app/edit-location.ios.tsx b/companion/app/edit-location.ios.tsx new file mode 100644 index 00000000000000..e823988b324c82 --- /dev/null +++ b/companion/app/edit-location.ios.tsx @@ -0,0 +1,119 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen"; +import EditLocationScreenComponent from "@/components/screens/EditLocationScreen"; +import { type Booking, CalComAPIService } from "@/services/calcom"; + +// Semi-transparent background to prevent black flash while preserving glass effect +const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function EditLocationIOS() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [booking, setBooking] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const editLocationScreenRef = useRef(null); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then(setBooking) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const handleSave = useCallback(() => { + editLocationScreenRef.current?.submit(); + }, []); + + const handleUpdateSuccess = useCallback(() => { + router.back(); + }, [router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + Edit Location + + + + + + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/edit-location.tsx b/companion/app/edit-location.tsx similarity index 76% rename from companion/app/(tabs)/(bookings)/edit-location.tsx rename to companion/app/edit-location.tsx index 58050595ef7fa5..345de79eda4377 100644 --- a/companion/app/(tabs)/(bookings)/edit-location.tsx +++ b/companion/app/edit-location.tsx @@ -1,6 +1,7 @@ +import { Ionicons } from "@expo/vector-icons"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Alert, Platform, Text, View } from "react-native"; +import { ActivityIndicator, Alert, Platform, View } from "react-native"; import { AppPressable } from "@/components/AppPressable"; import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen"; import EditLocationScreenComponent from "@/components/screens/EditLocationScreen"; @@ -40,14 +41,23 @@ export default function EditLocation() { router.back(); }, [router]); + const renderHeaderLeft = useCallback( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + const renderHeaderRight = useCallback( () => ( - Save + ), [handleSave, isSaving] @@ -63,9 +73,8 @@ export default function EditLocation() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Edit Location )} @@ -83,17 +92,24 @@ export default function EditLocation() { options={{ title: "Edit Location", headerBackButtonDisplayMode: "minimal", + headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined, headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined, }} /> {Platform.OS === "ios" && ( - + + + router.back()}> + + + + Edit Location - Save + diff --git a/companion/app/event-type-detail.tsx b/companion/app/event-type-detail.tsx index 5461e0097b5523..bbfec5e25ec4b5 100644 --- a/companion/app/event-type-detail.tsx +++ b/companion/app/event-type-detail.tsx @@ -15,6 +15,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AppPressable } from "@/components/AppPressable"; import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab"; import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab"; import { BasicsTab } from "@/components/event-type-detail/tabs/BasicsTab"; @@ -117,7 +118,6 @@ const tabs: Tab[] = [ ]; export default function EventTypeDetail() { - "use no memo"; const router = useRouter(); const { id, title, description, duration, slug } = useLocalSearchParams<{ id: string; @@ -363,18 +363,21 @@ export default function EventTypeDetail() { const fetchScheduleDetails = useCallback(async (scheduleId: number) => { setScheduleDetailsLoading(true); + let scheduleDetails: Awaited> | null = null; try { - const scheduleDetails = await CalComAPIService.getScheduleById(scheduleId); - setSelectedScheduleDetails(scheduleDetails); - if (scheduleDetails?.timeZone) { - setSelectedTimezone(scheduleDetails.timeZone); - } - setScheduleDetailsLoading(false); + scheduleDetails = await CalComAPIService.getScheduleById(scheduleId); } catch (error) { safeLogError("Failed to fetch schedule details:", error); setSelectedScheduleDetails(null); setScheduleDetailsLoading(false); + return; + } + setSelectedScheduleDetails(scheduleDetails); + const timeZone = scheduleDetails?.timeZone; + if (timeZone) { + setSelectedTimezone(timeZone); } + setScheduleDetailsLoading(false); }, []); const fetchSchedules = useCallback(async () => { @@ -408,344 +411,364 @@ export default function EventTypeDetail() { } }, []); - const fetchEventTypeData = useCallback(async () => { - if (!id) return; + const applyEventTypeData = useCallback((eventType: EventType) => { + setEventTypeData(eventType); + + // Load basic fields + if (eventType.title) setEventTitle(eventType.title); + if (eventType.slug) setEventSlug(eventType.slug); + if (eventType.description) setEventDescription(eventType.description); + if (eventType.lengthInMinutes) setEventDuration(eventType.lengthInMinutes.toString()); + if (eventType.hidden !== undefined) setIsHidden(eventType.hidden); + + const eventTypeExt = eventType as EventType & EventTypeExtended; + const lengthOptions = eventTypeExt.lengthInMinutesOptions; + const hasLengthOptions = + lengthOptions && Array.isArray(lengthOptions) && lengthOptions.length > 0; + if (hasLengthOptions) { + setAllowMultipleDurations(true); + const durationStrings = lengthOptions.map((mins: number) => `${mins} mins`); + setSelectedDurations(durationStrings); + if (eventType.lengthInMinutes) { + setDefaultDuration(`${eventType.lengthInMinutes} mins`); + } + } - try { - const eventType = await CalComAPIService.getEventTypeById(parseInt(id, 10)); - if (eventType) { - setEventTypeData(eventType); - - // Load basic fields - if (eventType.title) setEventTitle(eventType.title); - if (eventType.slug) setEventSlug(eventType.slug); - if (eventType.description) setEventDescription(eventType.description); - if (eventType.lengthInMinutes) setEventDuration(eventType.lengthInMinutes.toString()); - if (eventType.hidden !== undefined) setIsHidden(eventType.hidden); - - const eventTypeExt = eventType as EventType & EventTypeExtended; - if ( - eventTypeExt.lengthInMinutesOptions && - Array.isArray(eventTypeExt.lengthInMinutesOptions) && - eventTypeExt.lengthInMinutesOptions.length > 0 - ) { - setAllowMultipleDurations(true); - const durationStrings = eventTypeExt.lengthInMinutesOptions.map( - (mins: number) => `${mins} mins` - ); - setSelectedDurations(durationStrings); - if (eventType.lengthInMinutes) { - setDefaultDuration(`${eventType.lengthInMinutes} mins`); - } - } + // Load buffer times + if (eventType.beforeEventBuffer) { + setBeforeEventBuffer(`${eventType.beforeEventBuffer} Minutes`); + } + if (eventType.afterEventBuffer) { + setAfterEventBuffer(`${eventType.afterEventBuffer} Minutes`); + } - // Load buffer times - if (eventType.beforeEventBuffer) { - setBeforeEventBuffer(`${eventType.beforeEventBuffer} Minutes`); - } - if (eventType.afterEventBuffer) { - setAfterEventBuffer(`${eventType.afterEventBuffer} Minutes`); - } + // Load minimum booking notice + if (eventType.minimumBookingNotice) { + const minutes = eventType.minimumBookingNotice; + if (minutes >= 1440) { + // Days + setMinimumNoticeValue((minutes / 1440).toString()); + setMinimumNoticeUnit("Days"); + } else if (minutes >= 60) { + // Hours + setMinimumNoticeValue((minutes / 60).toString()); + setMinimumNoticeUnit("Hours"); + } else { + // Minutes + setMinimumNoticeValue(minutes.toString()); + setMinimumNoticeUnit("Minutes"); + } + } - // Load minimum booking notice - if (eventType.minimumBookingNotice) { - const minutes = eventType.minimumBookingNotice; - if (minutes >= 1440) { - // Days - setMinimumNoticeValue((minutes / 1440).toString()); - setMinimumNoticeUnit("Days"); - } else if (minutes >= 60) { - // Hours - setMinimumNoticeValue((minutes / 60).toString()); - setMinimumNoticeUnit("Hours"); - } else { - // Minutes - setMinimumNoticeValue(minutes.toString()); - setMinimumNoticeUnit("Minutes"); - } - } + // Load slot interval + if (eventType.slotInterval) { + setSlotInterval(`${eventType.slotInterval} Minutes`); + } - // Load slot interval - if (eventType.slotInterval) { - setSlotInterval(`${eventType.slotInterval} Minutes`); - } + // Load booking frequency limits + const bookingLimitsCount = eventType.bookingLimitsCount; + const hasBookingLimitsCount = bookingLimitsCount && !("disabled" in bookingLimitsCount); + if (hasBookingLimitsCount) { + setLimitBookingFrequency(true); + const limits: { id: number; value: string; unit: string }[] = []; + let idCounter = 1; + if (bookingLimitsCount.day) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.day.toString(), + unit: "Per day", + }); + } + if (bookingLimitsCount.week) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.week.toString(), + unit: "Per week", + }); + } + if (bookingLimitsCount.month) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.month.toString(), + unit: "Per month", + }); + } + if (bookingLimitsCount.year) { + limits.push({ + id: idCounter++, + value: bookingLimitsCount.year.toString(), + unit: "Per year", + }); + } + if (limits.length > 0) { + setFrequencyLimits(limits); + } + } - // Load booking frequency limits - if (eventType.bookingLimitsCount && !("disabled" in eventType.bookingLimitsCount)) { - setLimitBookingFrequency(true); - const limits: { id: number; value: string; unit: string }[] = []; - let idCounter = 1; - if (eventType.bookingLimitsCount.day) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.day.toString(), - unit: "Per day", - }); - } - if (eventType.bookingLimitsCount.week) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.week.toString(), - unit: "Per week", - }); - } - if (eventType.bookingLimitsCount.month) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.month.toString(), - unit: "Per month", - }); - } - if (eventType.bookingLimitsCount.year) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsCount.year.toString(), - unit: "Per year", - }); - } - if (limits.length > 0) { - setFrequencyLimits(limits); - } - } + // Load duration limits + const bookingLimitsDuration = eventType.bookingLimitsDuration; + const hasBookingLimitsDuration = + bookingLimitsDuration && !("disabled" in bookingLimitsDuration); + if (hasBookingLimitsDuration) { + setLimitTotalDuration(true); + const limits: { id: number; value: string; unit: string }[] = []; + let idCounter = 1; + if (bookingLimitsDuration.day) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.day.toString(), + unit: "Per day", + }); + } + if (bookingLimitsDuration.week) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.week.toString(), + unit: "Per week", + }); + } + if (bookingLimitsDuration.month) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.month.toString(), + unit: "Per month", + }); + } + if (bookingLimitsDuration.year) { + limits.push({ + id: idCounter++, + value: bookingLimitsDuration.year.toString(), + unit: "Per year", + }); + } + if (limits.length > 0) { + setDurationLimits(limits); + } + } - // Load duration limits - if (eventType.bookingLimitsDuration && !("disabled" in eventType.bookingLimitsDuration)) { - setLimitTotalDuration(true); - const limits: { id: number; value: string; unit: string }[] = []; - let idCounter = 1; - if (eventType.bookingLimitsDuration.day) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.day.toString(), - unit: "Per day", - }); - } - if (eventType.bookingLimitsDuration.week) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.week.toString(), - unit: "Per week", - }); - } - if (eventType.bookingLimitsDuration.month) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.month.toString(), - unit: "Per month", - }); - } - if (eventType.bookingLimitsDuration.year) { - limits.push({ - id: idCounter++, - value: eventType.bookingLimitsDuration.year.toString(), - unit: "Per year", - }); - } - if (limits.length > 0) { - setDurationLimits(limits); - } - } + // Load only show first slot + if (eventType.onlyShowFirstAvailableSlot !== undefined) { + setOnlyShowFirstAvailableSlot(eventType.onlyShowFirstAvailableSlot); + } - // Load only show first slot - if (eventType.onlyShowFirstAvailableSlot !== undefined) { - setOnlyShowFirstAvailableSlot(eventType.onlyShowFirstAvailableSlot); + if (eventType.bookerActiveBookingsLimit) { + const bookingLimit = eventType.bookerActiveBookingsLimit as BookerActiveBookingsLimitExtended; + const isBookingLimitEnabled = !("disabled" in bookingLimit); + if (isBookingLimitEnabled) { + const maxBookings = bookingLimit.maximumActiveBookings ?? bookingLimit.count; + if (maxBookings !== undefined) { + setMaxActiveBookingsPerBooker(true); + setMaxActiveBookingsValue(maxBookings.toString()); } - - if (eventType.bookerActiveBookingsLimit) { - const bookingLimit = - eventType.bookerActiveBookingsLimit as BookerActiveBookingsLimitExtended; - if (!("disabled" in bookingLimit)) { - const maxBookings = bookingLimit.maximumActiveBookings ?? bookingLimit.count; - if (maxBookings !== undefined) { - setMaxActiveBookingsPerBooker(true); - setMaxActiveBookingsValue(maxBookings.toString()); - } - if (bookingLimit.offerReschedule !== undefined) { - setOfferReschedule(bookingLimit.offerReschedule); - } - } + if (bookingLimit.offerReschedule !== undefined) { + setOfferReschedule(bookingLimit.offerReschedule); } + } + } - if (eventType.bookingWindow && !("disabled" in eventType.bookingWindow)) { - setLimitFutureBookings(true); - if (eventType.bookingWindow.type === "range") { - setFutureBookingType("range"); - if ( - Array.isArray(eventType.bookingWindow.value) && - eventType.bookingWindow.value.length === 2 - ) { - setRangeStartDate(eventType.bookingWindow.value[0]); - setRangeEndDate(eventType.bookingWindow.value[1]); - } - } else { - setFutureBookingType("rolling"); - if (typeof eventType.bookingWindow.value === "number") { - setRollingDays(eventType.bookingWindow.value.toString()); - } - setRollingCalendarDays(eventType.bookingWindow.type === "calendarDays"); - } + const bookingWindow = eventType.bookingWindow; + const hasBookingWindow = bookingWindow && !("disabled" in bookingWindow); + if (hasBookingWindow) { + setLimitFutureBookings(true); + if (bookingWindow.type === "range") { + setFutureBookingType("range"); + const windowValue = bookingWindow.value; + const isValidRange = Array.isArray(windowValue) && windowValue.length === 2; + if (isValidRange) { + setRangeStartDate(windowValue[0]); + setRangeEndDate(windowValue[1]); } - - if (eventTypeExt.disableCancelling !== undefined) { - setDisableCancelling(eventTypeExt.disableCancelling); - } else if (eventType.metadata?.disableCancelling) { - setDisableCancelling(true); + } else { + setFutureBookingType("rolling"); + if (typeof bookingWindow.value === "number") { + setRollingDays(bookingWindow.value.toString()); } + setRollingCalendarDays(bookingWindow.type === "calendarDays"); + } + } - if (eventTypeExt.disableRescheduling !== undefined) { - setDisableRescheduling(eventTypeExt.disableRescheduling); - } else if (eventType.metadata?.disableRescheduling) { - setDisableRescheduling(true); - } + const metadata = eventType.metadata; - if (eventTypeExt.sendCalVideoTranscription !== undefined) { - setSendCalVideoTranscription(eventTypeExt.sendCalVideoTranscription); - } else if (eventType.metadata?.sendCalVideoTranscription) { - setSendCalVideoTranscription(true); - } + if (eventTypeExt.disableCancelling !== undefined) { + setDisableCancelling(eventTypeExt.disableCancelling); + } else if (metadata?.disableCancelling) { + setDisableCancelling(true); + } - if (eventTypeExt.autoTranslate !== undefined) { - setAutoTranslate(eventTypeExt.autoTranslate); - } else if (eventType.metadata?.autoTranslate) { - setAutoTranslate(true); - } + if (eventTypeExt.disableRescheduling !== undefined) { + setDisableRescheduling(eventTypeExt.disableRescheduling); + } else if (metadata?.disableRescheduling) { + setDisableRescheduling(true); + } - if (eventType.metadata) { - const calendarEventNameValue = eventType.metadata.calendarEventName; - if (typeof calendarEventNameValue === "string") { - setCalendarEventName(calendarEventNameValue); - } - const addToCalendarEmailValue = eventType.metadata.addToCalendarEmail; - if (typeof addToCalendarEmailValue === "string") { - setAddToCalendarEmail(addToCalendarEmailValue); - } - } + if (eventTypeExt.sendCalVideoTranscription !== undefined) { + setSendCalVideoTranscription(eventTypeExt.sendCalVideoTranscription); + } else if (metadata?.sendCalVideoTranscription) { + setSendCalVideoTranscription(true); + } - // Load booker layouts - if (eventType.bookerLayouts) { - if ( - eventType.bookerLayouts.enabledLayouts && - Array.isArray(eventType.bookerLayouts.enabledLayouts) - ) { - setSelectedLayouts(eventType.bookerLayouts.enabledLayouts); - } - if (eventType.bookerLayouts.defaultLayout) { - setDefaultLayout(eventType.bookerLayouts.defaultLayout); - } - } + if (eventTypeExt.autoTranslate !== undefined) { + setAutoTranslate(eventTypeExt.autoTranslate); + } else if (metadata?.autoTranslate) { + setAutoTranslate(true); + } - if (eventType.confirmationPolicy) { - const policy = eventType.confirmationPolicy as ConfirmationPolicyExtended; - if (!("disabled" in policy) || policy.disabled === false) { - setRequiresConfirmation(true); - } - } - if (eventType.requiresConfirmation !== undefined) { - setRequiresConfirmation(eventType.requiresConfirmation); - } + if (metadata) { + const calendarEventNameValue = metadata.calendarEventName; + if (typeof calendarEventNameValue === "string") { + setCalendarEventName(calendarEventNameValue); + } + const addToCalendarEmailValue = metadata.addToCalendarEmail; + if (typeof addToCalendarEmailValue === "string") { + setAddToCalendarEmail(addToCalendarEmailValue); + } + } - if (eventType.requiresBookerEmailVerification !== undefined) { - setRequiresBookerEmailVerification(eventType.requiresBookerEmailVerification); - } - if (eventType.hideCalendarNotes !== undefined) { - setHideCalendarNotes(eventType.hideCalendarNotes); - } - if (eventType.lockTimeZoneToggleOnBookingPage !== undefined) { - setLockTimezone(eventType.lockTimeZoneToggleOnBookingPage); - } - if (eventTypeExt.lockedTimeZone) { - setLockedTimezone(eventTypeExt.lockedTimeZone); - } - if (eventTypeExt.hideCalendarEventDetails !== undefined) { - setHideCalendarEventDetails(eventTypeExt.hideCalendarEventDetails); - } - if (eventTypeExt.hideOrganizerEmail !== undefined) { - setHideOrganizerEmail(eventTypeExt.hideOrganizerEmail); - } + // Load booker layouts + const bookerLayouts = eventType.bookerLayouts; + if (bookerLayouts) { + const enabledLayouts = bookerLayouts.enabledLayouts; + const hasEnabledLayouts = enabledLayouts && Array.isArray(enabledLayouts); + if (hasEnabledLayouts) { + setSelectedLayouts(enabledLayouts); + } + if (bookerLayouts.defaultLayout) { + setDefaultLayout(bookerLayouts.defaultLayout); + } + } - // Load redirect URL - if (eventType.successRedirectUrl) { - setSuccessRedirectUrl(eventType.successRedirectUrl); - } - if (eventType.forwardParamsSuccessRedirect !== undefined) { - setForwardParamsSuccessRedirect(eventType.forwardParamsSuccessRedirect); - } + if (eventType.confirmationPolicy) { + const policy = eventType.confirmationPolicy as ConfirmationPolicyExtended; + const isPolicyEnabled = !("disabled" in policy) || policy.disabled === false; + if (isPolicyEnabled) { + setRequiresConfirmation(true); + } + } + if (eventType.requiresConfirmation !== undefined) { + setRequiresConfirmation(eventType.requiresConfirmation); + } - if (eventTypeExt.color) { - if (eventTypeExt.color.lightThemeHex) { - setEventTypeColorLight(eventTypeExt.color.lightThemeHex); - } - if (eventTypeExt.color.darkThemeHex) { - setEventTypeColorDark(eventTypeExt.color.darkThemeHex); - } - } - if (eventType.eventTypeColor) { - if (eventType.eventTypeColor.lightEventTypeColor) { - setEventTypeColorLight(eventType.eventTypeColor.lightEventTypeColor); - } - if (eventType.eventTypeColor.darkEventTypeColor) { - setEventTypeColorDark(eventType.eventTypeColor.darkEventTypeColor); - } - } + if (eventType.requiresBookerEmailVerification !== undefined) { + setRequiresBookerEmailVerification(eventType.requiresBookerEmailVerification); + } + if (eventType.hideCalendarNotes !== undefined) { + setHideCalendarNotes(eventType.hideCalendarNotes); + } + if (eventType.lockTimeZoneToggleOnBookingPage !== undefined) { + setLockTimezone(eventType.lockTimeZoneToggleOnBookingPage); + } + if (eventTypeExt.lockedTimeZone) { + setLockedTimezone(eventTypeExt.lockedTimeZone); + } + if (eventTypeExt.hideCalendarEventDetails !== undefined) { + setHideCalendarEventDetails(eventTypeExt.hideCalendarEventDetails); + } + if (eventTypeExt.hideOrganizerEmail !== undefined) { + setHideOrganizerEmail(eventTypeExt.hideOrganizerEmail); + } - if (eventType.recurrence) { - const recurrence = eventType.recurrence as RecurrenceExtended; - if (recurrence.disabled !== true && recurrence.interval && recurrence.frequency) { - setRecurringEnabled(true); - setRecurringInterval(recurrence.interval.toString()); - const freq = recurrence.frequency as "weekly" | "monthly" | "yearly"; - if (freq === "weekly" || freq === "monthly" || freq === "yearly") { - setRecurringFrequency(freq); - } - setRecurringOccurrences(recurrence.occurrences?.toString() || "12"); - } - } + // Load redirect URL + if (eventType.successRedirectUrl) { + setSuccessRedirectUrl(eventType.successRedirectUrl); + } + if (eventType.forwardParamsSuccessRedirect !== undefined) { + setForwardParamsSuccessRedirect(eventType.forwardParamsSuccessRedirect); + } - if (eventType.locations && eventType.locations.length > 0) { - const mappedLocations = eventType.locations.map((loc: ApiLocation) => - mapApiLocationToItem(loc) - ); - setLocations(mappedLocations); + const eventTypeExtColor = eventTypeExt.color; + if (eventTypeExtColor) { + if (eventTypeExtColor.lightThemeHex) { + setEventTypeColorLight(eventTypeExtColor.lightThemeHex); + } + if (eventTypeExtColor.darkThemeHex) { + setEventTypeColorDark(eventTypeExtColor.darkThemeHex); + } + } + const eventTypeColor = eventType.eventTypeColor; + if (eventTypeColor) { + if (eventTypeColor.lightEventTypeColor) { + setEventTypeColorLight(eventTypeColor.lightEventTypeColor); + } + if (eventTypeColor.darkEventTypeColor) { + setEventTypeColorDark(eventTypeColor.darkEventTypeColor); + } + } - const firstLocation = eventType.locations[0]; - if (firstLocation.address) { - setLocationAddress(firstLocation.address); - } - if (firstLocation.link) { - setLocationLink(firstLocation.link); - } - if (firstLocation.phone) { - setLocationPhone(firstLocation.phone); - } + if (eventType.recurrence) { + const recurrence = eventType.recurrence as RecurrenceExtended; + const recurrenceInterval = recurrence.interval; + const recurrenceFrequency = recurrence.frequency; + const isRecurrenceEnabled = + recurrence.disabled !== true && recurrenceInterval && recurrenceFrequency; + if (isRecurrenceEnabled) { + setRecurringEnabled(true); + setRecurringInterval(recurrenceInterval.toString()); + const freq = recurrenceFrequency as "weekly" | "monthly" | "yearly"; + if (freq === "weekly" || freq === "monthly" || freq === "yearly") { + setRecurringFrequency(freq); } + const occurrences = recurrence.occurrences; + setRecurringOccurrences(occurrences?.toString() || "12"); + } + } - if (eventType.disableGuests !== undefined) { - setDisableGuests(eventType.disableGuests); - } + const locations = eventType.locations; + const hasLocations = locations && locations.length > 0; + if (hasLocations) { + const mappedLocations = locations.map((loc: ApiLocation) => mapApiLocationToItem(loc)); + setLocations(mappedLocations); - if (eventType.seats) { - const seats = eventType.seats as SeatsExtended; - const seatsAreEnabled = - seats.disabled === false || (!("disabled" in seats) && seats.seatsPerTimeSlot); - - if (seatsAreEnabled) { - setSeatsEnabled(true); - if (seats.seatsPerTimeSlot) { - setSeatsPerTimeSlot(seats.seatsPerTimeSlot.toString()); - } - if (seats.showAttendeeInfo !== undefined) { - setShowAttendeeInfo(seats.showAttendeeInfo); - } - if (seats.showAvailabilityCount !== undefined) { - setShowAvailabilityCount(seats.showAvailabilityCount); - } - } + const firstLocation = locations[0]; + if (firstLocation.address) { + setLocationAddress(firstLocation.address); + } + if (firstLocation.link) { + setLocationLink(firstLocation.link); + } + if (firstLocation.phone) { + setLocationPhone(firstLocation.phone); + } + } + + if (eventType.disableGuests !== undefined) { + setDisableGuests(eventType.disableGuests); + } + + if (eventType.seats) { + const seats = eventType.seats as SeatsExtended; + const seatsAreEnabled = + seats.disabled === false || (!("disabled" in seats) && seats.seatsPerTimeSlot); + + if (seatsAreEnabled) { + setSeatsEnabled(true); + if (seats.seatsPerTimeSlot) { + setSeatsPerTimeSlot(seats.seatsPerTimeSlot.toString()); + } + if (seats.showAttendeeInfo !== undefined) { + setShowAttendeeInfo(seats.showAttendeeInfo); + } + if (seats.showAvailabilityCount !== undefined) { + setShowAvailabilityCount(seats.showAvailabilityCount); } } + } + }, []); + + const fetchEventTypeData = useCallback(async () => { + if (!id) return; + + let eventType: EventType | null = null; + try { + eventType = await CalComAPIService.getEventTypeById(parseInt(id, 10)); } catch (error) { safeLogError("Failed to fetch event type data:", error); + return; } - }, [id]); + + if (eventType) { + applyEventTypeData(eventType); + } + }, [id, applyEventTypeData]); useEffect(() => { if (activeTab === "availability") { @@ -775,28 +798,35 @@ export default function EventTypeDetail() { }, []); const formatTime = (time: string) => { - try { - // Handle different time formats that might come from the API - let date: Date; - - if (time.includes(":")) { - // Format like "09:00" or "09:00:00" - const [hours, minutes] = time.split(":").map(Number); - date = new Date(); - date.setHours(hours, minutes || 0, 0, 0); - } else { - // Other formats - date = new Date(time); - } + // Handle different time formats that might come from the API + // Extract conditionals outside try/catch for React Compiler + const isColonFormat = time.includes(":"); + + let date: Date; + + if (isColonFormat) { + // Format like "09:00" or "09:00:00" + const parts = time.split(":").map(Number); + const hours = parts[0]; + const minutes = parts[1] || 0; + date = new Date(); + date.setHours(hours, minutes, 0, 0); + } else { + // Other formats + date = new Date(time); + } - return date.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }); - } catch { + // Check if the date is valid + const isValidDate = !Number.isNaN(date.getTime()); + if (!isValidDate) { return time; // Return original if parsing fails } + + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); }; const getDaySchedule = () => { @@ -847,27 +877,30 @@ export default function EventTypeDetail() { }; const handlePreview = async () => { + const eventTypeSlug = eventSlug || "preview"; + let link: string; try { - const eventTypeSlug = eventSlug || "preview"; - const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); - await openInAppBrowser(link, "event type preview"); + link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); } catch (error) { safeLogError("Failed to generate preview link:", error); showErrorAlert("Error", "Failed to generate preview link. Please try again."); + return; } + await openInAppBrowser(link, "event type preview"); }; const handleCopyLink = async () => { + const eventTypeSlug = eventSlug || "event-link"; + let link: string; try { - const eventTypeSlug = eventSlug || "event-link"; - const link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); - - await Clipboard.setStringAsync(link); - Alert.alert("Success", "Link copied!"); + link = await CalComAPIService.buildEventTypeLink(eventTypeSlug); } catch (error) { safeLogError("Failed to copy link:", error); showErrorAlert("Error", "Failed to copy link. Please try again."); + return; } + await Clipboard.setStringAsync(link); + Alert.alert("Success", "Link copied!"); }; const handleDelete = () => { @@ -934,199 +967,213 @@ export default function EventTypeDetail() { // Detect create vs update mode const isCreateMode = id === "new"; + // Extract values with optional chaining outside try/catch for React Compiler + const selectedScheduleId = selectedSchedule?.id; + setSaving(true); - try { - if (isCreateMode) { - // For CREATE mode, build full payload - const payload: CreateEventTypePayload = { - title: eventTitle, - slug: eventSlug, - lengthInMinutes: durationNum, - }; - - if (eventDescription) { - payload.description = eventDescription; - } - if (locations.length > 0) { - payload.locations = locations.map((loc) => mapItemToApiLocation(loc)); - } + if (isCreateMode) { + // For CREATE mode, build full payload + const payload: CreateEventTypePayload = { + title: eventTitle, + slug: eventSlug, + lengthInMinutes: durationNum, + }; - if (selectedSchedule) { - payload.scheduleId = selectedSchedule.id; - } + if (eventDescription) { + payload.description = eventDescription; + } - payload.hidden = isHidden; + if (locations.length > 0) { + payload.locations = locations.map((loc) => mapItemToApiLocation(loc)); + } + + if (selectedScheduleId !== undefined) { + payload.scheduleId = selectedScheduleId; + } - // Create new event type + payload.hidden = isHidden; + + // Create new event type + try { await CalComAPIService.createEventType(payload); - Alert.alert("Success", "Event type created successfully", [ - { - text: "OK", - onPress: () => router.back(), - }, - ]); + } catch (error) { + safeLogError("Failed to save event type:", error); + showErrorAlert("Error", "Failed to create event type. Please try again."); setSaving(false); - } else { - // For UPDATE mode, use partial update - only send changed fields - const currentFormState = { - // Basics - eventTitle, - eventSlug, - eventDescription, - eventDuration, - isHidden, - locations, - disableGuests, - - // Multiple durations - allowMultipleDurations, - selectedDurations, - defaultDuration, - - // Availability - selectedScheduleId: selectedSchedule?.id, - - // Limits - beforeEventBuffer, - afterEventBuffer, - minimumNoticeValue, - minimumNoticeUnit, - slotInterval, - limitBookingFrequency, - frequencyLimits, - limitTotalDuration, - durationLimits, - onlyShowFirstAvailableSlot, - maxActiveBookingsPerBooker, - maxActiveBookingsValue, - offerReschedule, - limitFutureBookings, - futureBookingType, - rollingDays, - rollingCalendarDays, - rangeStartDate, - rangeEndDate, - - // Advanced - requiresConfirmation, - requiresBookerEmailVerification, - hideCalendarNotes, - hideCalendarEventDetails, - hideOrganizerEmail, - lockTimezone, - allowReschedulingPastEvents, - allowBookingThroughRescheduleLink, - successRedirectUrl, - forwardParamsSuccessRedirect, - customReplyToEmail, - eventTypeColorLight, - eventTypeColorDark, - calendarEventName, - addToCalendarEmail, - selectedLayouts, - defaultLayout, - disableCancelling, - disableRescheduling, - sendCalVideoTranscription, - autoTranslate, - - // Seats - seatsEnabled, - seatsPerTimeSlot, - showAttendeeInfo, - showAvailabilityCount, - - // Recurring - recurringEnabled, - recurringInterval, - recurringFrequency, - recurringOccurrences, - }; - - // Build partial payload with only changed fields - const payload = buildPartialUpdatePayload(currentFormState, eventTypeData); - - if (Object.keys(payload).length === 0) { - Alert.alert("No Changes", "No changes were made to the event type."); - setSaving(false); - return; - } + return; + } + Alert.alert("Success", "Event type created successfully", [ + { + text: "OK", + onPress: () => router.back(), + }, + ]); + setSaving(false); + } else { + // For UPDATE mode, use partial update - only send changed fields + const currentFormState = { + // Basics + eventTitle, + eventSlug, + eventDescription, + eventDuration, + isHidden, + locations, + disableGuests, + + // Multiple durations + allowMultipleDurations, + selectedDurations, + defaultDuration, + + // Availability + selectedScheduleId, + + // Limits + beforeEventBuffer, + afterEventBuffer, + minimumNoticeValue, + minimumNoticeUnit, + slotInterval, + limitBookingFrequency, + frequencyLimits, + limitTotalDuration, + durationLimits, + onlyShowFirstAvailableSlot, + maxActiveBookingsPerBooker, + maxActiveBookingsValue, + offerReschedule, + limitFutureBookings, + futureBookingType, + rollingDays, + rollingCalendarDays, + rangeStartDate, + rangeEndDate, + + // Advanced + requiresConfirmation, + requiresBookerEmailVerification, + hideCalendarNotes, + hideCalendarEventDetails, + hideOrganizerEmail, + lockTimezone, + allowReschedulingPastEvents, + allowBookingThroughRescheduleLink, + successRedirectUrl, + forwardParamsSuccessRedirect, + customReplyToEmail, + eventTypeColorLight, + eventTypeColorDark, + calendarEventName, + addToCalendarEmail, + selectedLayouts, + defaultLayout, + disableCancelling, + disableRescheduling, + sendCalVideoTranscription, + autoTranslate, + + // Seats + seatsEnabled, + seatsPerTimeSlot, + showAttendeeInfo, + showAvailabilityCount, + + // Recurring + recurringEnabled, + recurringInterval, + recurringFrequency, + recurringOccurrences, + }; + + // Build partial payload with only changed fields + const payload = buildPartialUpdatePayload(currentFormState, eventTypeData); + + if (Object.keys(payload).length === 0) { + Alert.alert("No Changes", "No changes were made to the event type."); + setSaving(false); + return; + } + try { await CalComAPIService.updateEventType(parseInt(id, 10), payload); - Alert.alert("Success", "Event type updated successfully"); - // Refresh event type data to sync with server - await fetchEventTypeData(); + } catch (error) { + safeLogError("Failed to save event type:", error); + showErrorAlert("Error", "Failed to update event type. Please try again."); setSaving(false); + return; } - } catch (error) { - safeLogError("Failed to save event type:", error); - const action = isCreateMode ? "create" : "update"; - showErrorAlert("Error", `Failed to ${action} event type. Please try again.`); + Alert.alert("Success", "Event type updated successfully"); + // Refresh event type data to sync with server + await fetchEventTypeData(); setSaving(false); } }; - return ( - <> - - - {/* Glass Header */} - - - router.back()} - > - - + const headerTitle = id === "new" ? "Create Event Type" : truncateTitle(title); + const saveButtonText = id === "new" ? "Create" : "Save"; - - {id === "new" ? "Create Event Type" : truncateTitle(title)} - + const renderHeaderLeft = () => ( + router.back()} className="px-2 py-2"> + + + ); - ( + + {saveButtonText} + + ); + + return ( + <> + + + {Platform.OS === "ios" && ( + + + router.back()}> + + + + + {headerTitle} + + + - - {id === "new" ? "Create" : "Save"} - - - - + {saveButtonText} + + + + )} + {/* Tabs */} {isLiquidGlassAvailable() ? ( @@ -1164,12 +1211,7 @@ export default function EventTypeDetail() { ) : ( diff --git a/companion/app/mark-no-show.ios.tsx b/companion/app/mark-no-show.ios.tsx new file mode 100644 index 00000000000000..f20985e2be80cb --- /dev/null +++ b/companion/app/mark-no-show.ios.tsx @@ -0,0 +1,137 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen"; +import { type Booking, CalComAPIService } from "@/services/calcom"; + +interface Attendee { + id?: number | string; + email: string; + name: string; + noShow?: boolean; +} + +interface BookingAttendee { + id?: number | string; + email: string; + name?: string; + noShow?: boolean; + absent?: boolean; +} + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function MarkNoShowIOS() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [booking, setBooking] = useState(null); + const [attendees, setAttendees] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then((bookingData) => { + setBooking(bookingData); + const bookingAttendees: Attendee[] = []; + if (bookingData.attendees && Array.isArray(bookingData.attendees)) { + bookingData.attendees.forEach((att: BookingAttendee) => { + bookingAttendees.push({ + id: att.id, + email: att.email, + name: att.name || att.email, + noShow: att.absent === true || att.noShow === true, + }); + }); + } + setAttendees(bookingAttendees); + }) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + Mark No-Show + + + + {isLoading ? ( + + + + ) : ( + { + setBooking(updatedBooking); + const updatedAttendees: Attendee[] = []; + if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) { + updatedBooking.attendees.forEach((att: BookingAttendee) => { + updatedAttendees.push({ + id: att.id, + email: att.email, + name: att.name || att.email, + noShow: att.absent === true || att.noShow === true, + }); + }); + } + setAttendees(updatedAttendees); + }} + transparentBackground={useGlassEffect} + /> + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/mark-no-show.tsx b/companion/app/mark-no-show.tsx similarity index 82% rename from companion/app/(tabs)/(bookings)/mark-no-show.tsx rename to companion/app/mark-no-show.tsx index 46b40756cfc116..641087fa7db07f 100644 --- a/companion/app/(tabs)/(bookings)/mark-no-show.tsx +++ b/companion/app/mark-no-show.tsx @@ -1,6 +1,8 @@ +import { Ionicons } from "@expo/vector-icons"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, Platform, View } from "react-native"; +import { AppPressable } from "@/components/AppPressable"; import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen"; import { type Booking, CalComAPIService } from "@/services/calcom"; @@ -58,6 +60,15 @@ export default function MarkNoShow() { } }, [uid, router]); + const renderHeaderLeft = useCallback( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + if (isLoading) { return ( <> @@ -68,9 +79,8 @@ export default function MarkNoShow() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Mark No-Show )} @@ -88,12 +98,18 @@ export default function MarkNoShow() { options={{ title: "Mark No-Show", headerBackButtonDisplayMode: "minimal", + headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined, }} /> - {/* iOS-only Stack.Header with native styling */} {Platform.OS === "ios" && ( - + + + router.back()}> + + + + Mark No-Show )} diff --git a/companion/app/meeting-session-details.ios.tsx b/companion/app/meeting-session-details.ios.tsx new file mode 100644 index 00000000000000..5a48ff3ef07ef1 --- /dev/null +++ b/companion/app/meeting-session-details.ios.tsx @@ -0,0 +1,98 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen"; +import { CalComAPIService } from "@/services/calcom"; +import type { ConferencingSession } from "@/services/types/bookings.types"; + +/** + * Get the presentation style for the meeting session details sheet + * - Uses formSheet on iPhone with liquid glass support + * - Uses modal on iPad or older iOS devices + */ +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function MeetingSessionDetailsIOS() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getConferencingSessions(uid) + .then((sessionsData) => { + setSessions(sessionsData); + }) + .catch(() => { + Alert.alert("Error", "Failed to load session details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + Session Details + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/meeting-session-details.tsx b/companion/app/meeting-session-details.tsx similarity index 73% rename from companion/app/(tabs)/(bookings)/meeting-session-details.tsx rename to companion/app/meeting-session-details.tsx index 95143f0fcd347e..ef30fb05f8571f 100644 --- a/companion/app/(tabs)/(bookings)/meeting-session-details.tsx +++ b/companion/app/meeting-session-details.tsx @@ -1,6 +1,8 @@ +import { Ionicons } from "@expo/vector-icons"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, Platform, View } from "react-native"; +import { AppPressable } from "@/components/AppPressable"; import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen"; import { CalComAPIService } from "@/services/calcom"; import type { ConferencingSession } from "@/services/types/bookings.types"; @@ -30,6 +32,15 @@ export default function MeetingSessionDetails() { } }, [uid, router]); + const renderHeaderLeft = useCallback( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + if (isLoading) { return ( <> @@ -40,9 +51,8 @@ export default function MeetingSessionDetails() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Session Details )} @@ -60,12 +70,18 @@ export default function MeetingSessionDetails() { options={{ title: "Session Details", headerBackButtonDisplayMode: "minimal", + headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined, }} /> - {/* iOS-only Stack.Header with native styling */} {Platform.OS === "ios" && ( - + + + router.back()}> + + + + Session Details )} diff --git a/companion/app/reschedule.ios.tsx b/companion/app/reschedule.ios.tsx new file mode 100644 index 00000000000000..7d2d5bee7d1ca4 --- /dev/null +++ b/companion/app/reschedule.ios.tsx @@ -0,0 +1,119 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen"; +import RescheduleScreenComponent from "@/components/screens/RescheduleScreen"; +import { type Booking, CalComAPIService } from "@/services/calcom"; + +// Semi-transparent background to prevent black flash while preserving glass effect +const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function RescheduleIOS() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [booking, setBooking] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const rescheduleScreenRef = useRef(null); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getBookingByUid(uid) + .then(setBooking) + .catch(() => { + Alert.alert("Error", "Failed to load booking details"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const handleSave = useCallback(() => { + rescheduleScreenRef.current?.submit(); + }, []); + + const handleRescheduleSuccess = useCallback(() => { + router.back(); + }, [router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + Reschedule + + + + + + + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/reschedule.tsx b/companion/app/reschedule.tsx similarity index 76% rename from companion/app/(tabs)/(bookings)/reschedule.tsx rename to companion/app/reschedule.tsx index b1228543e48495..6e671bf5c7fc73 100644 --- a/companion/app/(tabs)/(bookings)/reschedule.tsx +++ b/companion/app/reschedule.tsx @@ -1,6 +1,7 @@ +import { Ionicons } from "@expo/vector-icons"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Alert, Platform, Text, View } from "react-native"; +import { ActivityIndicator, Alert, Platform, View } from "react-native"; import { AppPressable } from "@/components/AppPressable"; import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen"; import RescheduleScreenComponent from "@/components/screens/RescheduleScreen"; @@ -40,14 +41,23 @@ export default function Reschedule() { router.back(); }, [router]); + const renderHeaderLeft = useCallback( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + const renderHeaderRight = useCallback( () => ( - Save + ), [handleSave, isSaving] @@ -64,7 +74,7 @@ export default function Reschedule() { /> {Platform.OS === "ios" && ( - + Reschedule )} @@ -82,17 +92,24 @@ export default function Reschedule() { options={{ title: "Reschedule", headerBackButtonDisplayMode: "minimal", + headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined, headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined, }} /> {Platform.OS === "ios" && ( - + + + router.back()}> + + + + Reschedule - Save + @@ -103,6 +120,7 @@ export default function Reschedule() { booking={booking} onSuccess={handleRescheduleSuccess} onSavingChange={setIsSaving} + useNativeHeader /> ); diff --git a/companion/app/view-recordings.ios.tsx b/companion/app/view-recordings.ios.tsx new file mode 100644 index 00000000000000..174df23b2c7d7e --- /dev/null +++ b/companion/app/view-recordings.ios.tsx @@ -0,0 +1,93 @@ +import { osName } from "expo-device"; +import { isLiquidGlassAvailable } from "expo-glass-effect"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Alert, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen"; +import { CalComAPIService } from "@/services/calcom"; +import type { BookingRecording } from "@/services/types/bookings.types"; +import { safeLogError } from "@/utils/safeLogger"; + +function getPresentationStyle(): "formSheet" | "modal" { + if (isLiquidGlassAvailable() && osName !== "iPadOS") { + return "formSheet"; + } + return "modal"; +} + +export default function ViewRecordingsIOS() { + const { uid } = useLocalSearchParams<{ uid: string }>(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [recordings, setRecordings] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (uid) { + setIsLoading(true); + CalComAPIService.getRecordings(uid) + .then(setRecordings) + .catch((error) => { + safeLogError("Failed to load recordings:", error); + Alert.alert("Error", "Failed to load recordings"); + router.back(); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + Alert.alert("Error", "Booking ID is missing"); + router.back(); + } + }, [uid, router]); + + const presentationStyle = getPresentationStyle(); + const useGlassEffect = isLiquidGlassAvailable(); + + return ( + <> + + + + + router.back()}> + + + + + Recordings + + + + {isLoading ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/companion/app/(tabs)/(bookings)/view-recordings.tsx b/companion/app/view-recordings.tsx similarity index 73% rename from companion/app/(tabs)/(bookings)/view-recordings.tsx rename to companion/app/view-recordings.tsx index db159845937695..a796e98ff3af98 100644 --- a/companion/app/(tabs)/(bookings)/view-recordings.tsx +++ b/companion/app/view-recordings.tsx @@ -1,6 +1,8 @@ +import { Ionicons } from "@expo/vector-icons"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, Platform, View } from "react-native"; +import { AppPressable } from "@/components/AppPressable"; import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen"; import { CalComAPIService } from "@/services/calcom"; import type { BookingRecording } from "@/services/types/bookings.types"; @@ -30,6 +32,15 @@ export default function ViewRecordings() { } }, [uid, router]); + const renderHeaderLeft = useCallback( + () => ( + router.back()} className="px-2 py-2"> + + + ), + [router] + ); + if (isLoading) { return ( <> @@ -40,9 +51,8 @@ export default function ViewRecordings() { }} /> - {/* iOS-only Stack.Header */} {Platform.OS === "ios" && ( - + Recordings )} @@ -60,12 +70,18 @@ export default function ViewRecordings() { options={{ title: "Recordings", headerBackButtonDisplayMode: "minimal", + headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined, }} /> - {/* iOS-only Stack.Header with native styling */} {Platform.OS === "ios" && ( - + + + router.back()}> + + + + Recordings )} diff --git a/companion/assets/adaptive-icon.png b/companion/assets/adaptive-icon.png index 03d6f6b6c67279..8185f1cba34f7f 100644 Binary files a/companion/assets/adaptive-icon.png and b/companion/assets/adaptive-icon.png differ diff --git a/companion/assets/cal-logo.icon/Assets/cal.svg b/companion/assets/cal-logo.icon/Assets/cal.svg new file mode 100644 index 00000000000000..93c6dc2d101db2 --- /dev/null +++ b/companion/assets/cal-logo.icon/Assets/cal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/companion/assets/cal-logo.icon/icon.json b/companion/assets/cal-logo.icon/icon.json new file mode 100644 index 00000000000000..324a13c59f5069 --- /dev/null +++ b/companion/assets/cal-logo.icon/icon.json @@ -0,0 +1,50 @@ +{ + "fill": "automatic", + "groups": [ + { + "layers": [ + { + "blend-mode-specializations": [ + { + "appearance": "tinted", + "value": "normal" + } + ], + "fill-specializations": [ + { + "appearance": "dark", + "value": { + "solid": "srgb:1.00000,1.00000,1.00000,1.00000" + } + }, + { + "appearance": "tinted", + "value": { + "solid": "srgb:1.00000,1.00000,1.00000,1.00000" + } + } + ], + "glass": false, + "image-name": "cal.svg", + "name": "cal", + "position": { + "scale": 19.57, + "translation-in-points": [-17, 2] + } + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/companion/assets/splash-icon.png b/companion/assets/splash-icon.png index 03d6f6b6c67279..dd85a83ed2e3c9 100644 Binary files a/companion/assets/splash-icon.png and b/companion/assets/splash-icon.png differ diff --git a/companion/biome.json b/companion/biome.json new file mode 100644 index 00000000000000..3cd11897065c6b --- /dev/null +++ b/companion/biome.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "root": false, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**/*.js", + "**/*.jsx", + "**/*.ts", + "**/*.tsx", + "**/*.json", + "!**/node_modules", + "!**/.expo", + "!**/dist", + "!**/.output", + "!**/*.d.ts", + "!**/bun.lock", + "!**/android", + "!**/ios" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "error", + "noArrayIndexKey": "error", + "useIterableCallbackReturn": "error", + "noImplicitAnyLet": "off" + }, + "correctness": { + "useExhaustiveDependencies": "error", + "useHookAtTopLevel": "error", + "noInvalidUseBeforeDeclaration": "off", + "noUnusedFunctionParameters": "error", + "noUnusedVariables": "error" + }, + "complexity": { + "noStaticOnlyClass": "error" + }, + "style": { + "noNonNullAssertion": "error" + }, + "a11y": { + "noStaticElementInteractions": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["extension/**/*.ts", "extension/**/*.tsx"], + "javascript": { + "globals": ["chrome", "browser"] + } + } + ] +} diff --git a/companion/bun.lock b/companion/bun.lock index 497f9789032b31..545bcd8a98b35f 100644 --- a/companion/bun.lock +++ b/companion/bun.lock @@ -25,13 +25,13 @@ "expo-linking": "8.0.12-canary-20251230-fc48ddc", "expo-router": "7.0.0-canary-20251230-fc48ddc", "expo-secure-store": "15.0.9-canary-20251230-fc48ddc", + "expo-splash-screen": "31.0.14-canary-20251230-fc48ddc", "expo-status-bar": "3.0.10-canary-20251230-fc48ddc", "expo-web-browser": "15.0.11-canary-20251230-fc48ddc", "nativewind": "4.2.1", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.83.1", - "react-native-context-menu-view": "1.20.0", "react-native-gesture-handler": "2.28.0", "react-native-reanimated": "4.2.0", "react-native-safe-area-context": "5.6.2", @@ -1075,6 +1075,8 @@ "expo-server": ["expo-server@1.0.6-canary-20251230-fc48ddc", "", {}, "sha512-YW0GxVbpfupc455TH/lfX9FRgnS+ZpTi9ydENYQ0EaB/2dbmnruD5w/FzixKk/zBNGipZp0ix0OYRo0SPEvaPQ=="], + "expo-splash-screen": ["expo-splash-screen@31.0.14-canary-20251230-fc48ddc", "", { "dependencies": { "@expo/prebuild-config": "54.0.9-canary-20251230-fc48ddc" }, "peerDependencies": { "expo": "55.0.0-canary-20251230-fc48ddc" } }, "sha512-t6aBi/2u4JjAJ2GlqwO2+OZzwEY3vgV/JVeff0M+cZTM7v7ezaBtbuR/f/43KAvBWduNh+26sPlQC1lMO/pu3w=="], + "expo-status-bar": ["expo-status-bar@3.0.10-canary-20251230-fc48ddc", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Lkbb3e6yyRtkmjpTfwIYbO9XDmhPeSipyJLBa5nADq8eiGf7kXZ1pmaLEelPl05azCPY3109bJzhvbonz86sIg=="], "expo-symbols": ["expo-symbols@1.1.0-canary-20251230-fc48ddc", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "55.0.0-canary-20251230-fc48ddc", "expo-font": "14.1.0-canary-20251230-fc48ddc", "react": "*", "react-native": "*" } }, "sha512-3cDn97a5NP6bBBHhN/U9/QKZrvqoSUOYtaTPbfH/WBDJC/kM6hzD8/BQBNe7Vt9ygcAh2iFx3w5DcFlxsMyKAw=="], @@ -1683,8 +1685,6 @@ "react-native": ["react-native@0.83.1", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.1", "@react-native/codegen": "0.83.1", "@react-native/community-cli-plugin": "0.83.1", "@react-native/gradle-plugin": "0.83.1", "@react-native/js-polyfills": "0.83.1", "@react-native/normalize-colors": "0.83.1", "@react-native/virtualized-lists": "0.83.1", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.0", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-mL1q5HPq5cWseVhWRLl+Fwvi5z1UO+3vGOpjr+sHFwcUletPRZ5Kv+d0tUfqHmvi73/53NjlQqX1Pyn4GguUfA=="], - "react-native-context-menu-view": ["react-native-context-menu-view@1.20.0", "", { "peerDependencies": { "react": "^16.8.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-native": ">=0.60.0-rc.0 <1.0.x" } }, "sha512-g1EYZiPaJWjeq7GWeL9TaYSnKmL9/hiDE7Arvj8r8fLip/l+BvS+BRtblX9qk9VpQdDWyd6L6A4yL2sz6+ohQw=="], - "react-native-css-interop": ["react-native-css-interop@0.2.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.22.15", "@babel/traverse": "^7.23.0", "@babel/types": "^7.23.0", "debug": "^4.3.7", "lightningcss": "~1.27.0", "semver": "^7.6.3" }, "peerDependencies": { "react": ">=18", "react-native": "*", "react-native-reanimated": ">=3.6.2", "tailwindcss": "~3" } }, "sha512-B88f5rIymJXmy1sNC/MhTkb3xxBej1KkuAt7TiT9iM7oXz3RM8Bn+7GUrfR02TvSgKm4cg2XiSuLEKYfKwNsjA=="], "react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="], diff --git a/companion/components/LogoutConfirmModal.ios.tsx b/companion/components/LogoutConfirmModal.ios.tsx new file mode 100644 index 00000000000000..d05b204050dcb5 --- /dev/null +++ b/companion/components/LogoutConfirmModal.ios.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; +import { Alert } from "react-native"; + +interface LogoutConfirmModalProps { + visible: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +export function LogoutConfirmModal({ visible, onConfirm, onCancel }: LogoutConfirmModalProps) { + // Track if alert has been shown for current visible=true state to prevent re-triggering + // when parent passes unstable callback references + const alertShownRef = useRef(false); + + useEffect(() => { + if (visible && !alertShownRef.current) { + alertShownRef.current = true; + Alert.alert( + "Sign Out", + "Are you sure you want to sign out?", + [ + { + text: "Cancel", + style: "cancel", + onPress: onCancel, + }, + { + text: "Sign Out", + style: "destructive", + onPress: onConfirm, + }, + ], + { cancelable: true, onDismiss: onCancel } + ); + } + + if (!visible) { + alertShownRef.current = false; + } + }, [visible, onConfirm, onCancel]); + + return null; +} diff --git a/companion/components/booking-list-screen/BookingListScreen.tsx b/companion/components/booking-list-screen/BookingListScreen.tsx index 176dd71588e359..d49bf7ad410379 100644 --- a/companion/components/booking-list-screen/BookingListScreen.tsx +++ b/companion/components/booking-list-screen/BookingListScreen.tsx @@ -123,7 +123,7 @@ export const BookingListScreen: React.FC = ({ const handleNavigateToReschedule = React.useCallback( (booking: Booking) => { router.push({ - pathname: "/(tabs)/(bookings)/reschedule", + pathname: "/reschedule", params: { uid: booking.uid }, }); }, @@ -134,7 +134,7 @@ export const BookingListScreen: React.FC = ({ const handleNavigateToEditLocation = React.useCallback( (booking: Booking) => { router.push({ - pathname: "/(tabs)/(bookings)/edit-location", + pathname: "/edit-location", params: { uid: booking.uid }, }); }, @@ -145,7 +145,7 @@ export const BookingListScreen: React.FC = ({ const handleNavigateToAddGuests = React.useCallback( (booking: Booking) => { router.push({ - pathname: "/(tabs)/(bookings)/add-guests", + pathname: "/add-guests", params: { uid: booking.uid }, }); }, @@ -156,7 +156,7 @@ export const BookingListScreen: React.FC = ({ const handleNavigateToMarkNoShow = React.useCallback( (booking: Booking) => { router.push({ - pathname: "/(tabs)/(bookings)/mark-no-show", + pathname: "/mark-no-show", params: { uid: booking.uid }, }); }, @@ -167,7 +167,7 @@ export const BookingListScreen: React.FC = ({ const handleNavigateToViewRecordings = React.useCallback( (booking: Booking) => { router.push({ - pathname: "/(tabs)/(bookings)/view-recordings", + pathname: "/view-recordings", params: { uid: booking.uid }, }); }, @@ -178,7 +178,7 @@ export const BookingListScreen: React.FC = ({ const handleNavigateToMeetingSessionDetails = React.useCallback( (booking: Booking) => { router.push({ - pathname: "/(tabs)/(bookings)/meeting-session-details", + pathname: "/meeting-session-details", params: { uid: booking.uid }, }); }, diff --git a/companion/components/booking-modals/BookingModals.ios.tsx b/companion/components/booking-modals/BookingModals.ios.tsx new file mode 100644 index 00000000000000..0a42400b7e9dba --- /dev/null +++ b/companion/components/booking-modals/BookingModals.ios.tsx @@ -0,0 +1,234 @@ +import { Ionicons } from "@expo/vector-icons"; +import type React from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { ActivityIndicator, Alert, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { BookingActionsModal } from "@/components/BookingActionsModal"; +import { FullScreenModal } from "@/components/FullScreenModal"; +import type { Booking, EventType } from "@/services/calcom"; +import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions"; + +const EMPTY_ACTIONS: BookingActionsResult = { + reschedule: { visible: false, enabled: false }, + rescheduleRequest: { visible: false, enabled: false }, + cancel: { visible: false, enabled: false }, + changeLocation: { visible: false, enabled: false }, + addGuests: { visible: false, enabled: false }, + viewRecordings: { visible: false, enabled: false }, + meetingSessionDetails: { visible: false, enabled: false }, + markNoShow: { visible: false, enabled: false }, +}; + +interface BookingModalsProps { + showRescheduleModal: boolean; + rescheduleBooking: Booking | null; + isRescheduling: boolean; + onRescheduleClose: () => void; + onRescheduleSubmit: (date: string, time: string, reason?: string) => Promise; + + showRejectModal: boolean; + rejectReason: string; + isDeclining: boolean; + onRejectClose: () => void; + onRejectSubmit: (reason?: string) => void; + onRejectReasonChange: (reason: string) => void; + + showFilterModal?: boolean; + eventTypes?: EventType[]; + eventTypesLoading?: boolean; + selectedEventTypeId?: number | null; + onFilterClose?: () => void; + onEventTypeSelect?: (id: number | null, label?: string | null) => void; + + showBookingActionsModal: boolean; + selectedBooking: Booking | null; + onActionsClose: () => void; + onReschedule: () => void; + onCancel: () => void; + + currentUserEmail?: string; + + onEditLocation?: (booking: Booking) => void; + onAddGuests?: (booking: Booking) => void; + onViewRecordings?: (booking: Booking) => void; + onMeetingSessionDetails?: (booking: Booking) => void; + onMarkNoShow?: (booking: Booking) => void; +} + +export const BookingModals: React.FC = ({ + showRescheduleModal: _showRescheduleModal, + rescheduleBooking: _rescheduleBooking, + isRescheduling: _isRescheduling, + onRescheduleClose: _onRescheduleClose, + onRescheduleSubmit: _onRescheduleSubmit, + showRejectModal, + rejectReason, + isDeclining, + onRejectClose, + onRejectSubmit, + + showFilterModal, + eventTypes, + eventTypesLoading, + selectedEventTypeId, + onFilterClose, + onEventTypeSelect, + showBookingActionsModal, + selectedBooking, + onActionsClose, + onReschedule, + onCancel, + currentUserEmail, + onEditLocation, + onAddGuests, + onViewRecordings, + onMeetingSessionDetails, + onMarkNoShow, +}) => { + const actions = useMemo(() => { + if (!selectedBooking) return EMPTY_ACTIONS; + return getBookingActions({ + booking: selectedBooking, + eventType: undefined, + currentUserId: undefined, + currentUserEmail: currentUserEmail, + isOnline: true, + }); + }, [selectedBooking, currentUserEmail]); + + const hasShownRejectAlert = useRef(false); + + useEffect(() => { + if (showRejectModal && !isDeclining && !hasShownRejectAlert.current) { + hasShownRejectAlert.current = true; + Alert.prompt( + "Reject the booking request?", + "Are you sure you want to reject the booking? We'll let the person who tried to book know. You can provide a reason below.", + [ + { + text: "Cancel", + style: "cancel", + onPress: () => { + hasShownRejectAlert.current = false; + onRejectClose(); + }, + }, + { + text: "Reject", + style: "destructive", + onPress: (reason?: string) => { + hasShownRejectAlert.current = false; + // Pass reason directly to avoid race condition with state updates + onRejectSubmit(reason); + }, + }, + ], + "plain-text", + rejectReason, + "default" + ); + } else if (!showRejectModal) { + hasShownRejectAlert.current = false; + } + }, [showRejectModal, isDeclining, onRejectClose, onRejectSubmit, rejectReason]); + + return ( + <> + {showFilterModal !== undefined && onFilterClose && onEventTypeSelect ? ( + + + e.stopPropagation()} + className="w-[85%] max-w-[350px] rounded-2xl bg-white p-5" + > + + Filter by Event Type + + + {eventTypesLoading ? ( + + + Loading event types... + + ) : ( + + {eventTypes?.map((eventType) => ( + onEventTypeSelect(eventType.id, eventType.title)} + > + + {eventType.title} + + {selectedEventTypeId === eventType.id ? ( + + ) : null} + + ))} + + {eventTypes?.length === 0 ? ( + + No event types found + + ) : null} + + )} + + + + ) : null} + + { + if (selectedBooking && onEditLocation) { + onEditLocation(selectedBooking); + } + }} + onAddGuests={() => { + if (selectedBooking && onAddGuests) { + onAddGuests(selectedBooking); + } + }} + onViewRecordings={() => { + if (selectedBooking && onViewRecordings) { + onViewRecordings(selectedBooking); + } + }} + onMeetingSessionDetails={() => { + if (selectedBooking && onMeetingSessionDetails) { + onMeetingSessionDetails(selectedBooking); + } + }} + onMarkNoShow={() => { + if (selectedBooking && onMarkNoShow) { + onMarkNoShow(selectedBooking); + } + }} + onReportBooking={() => { + Alert.alert("Report Booking", "Report booking functionality is not yet available"); + }} + onCancelBooking={onCancel} + /> + + ); +}; diff --git a/companion/components/booking-modals/BookingModals.tsx b/companion/components/booking-modals/BookingModals.tsx index 0846be12a841f0..37ed7385d07325 100644 --- a/companion/components/booking-modals/BookingModals.tsx +++ b/companion/components/booking-modals/BookingModals.tsx @@ -40,7 +40,7 @@ interface BookingModalsProps { rejectReason: string; isDeclining: boolean; onRejectClose: () => void; - onRejectSubmit: () => void; + onRejectSubmit: (reason?: string) => void; onRejectReasonChange: (reason: string) => void; // Filter modal props (optional for iOS) @@ -150,7 +150,9 @@ export const BookingModals: React.FC = ({ onPress={() => onEventTypeSelect(eventType.id, eventType.title)} > {eventType.title} @@ -272,7 +274,7 @@ export const BookingModals: React.FC = ({ {/* Reject Button */} onRejectSubmit()} disabled={isDeclining} style={{ opacity: isDeclining ? 0.5 : 1 }} > diff --git a/companion/components/screens/AddGuestsScreen.tsx b/companion/components/screens/AddGuestsScreen.tsx index a98f39bae633ad..e4d112b4b1a056 100644 --- a/companion/components/screens/AddGuestsScreen.tsx +++ b/companion/components/screens/AddGuestsScreen.tsx @@ -26,6 +26,8 @@ export interface AddGuestsScreenProps { booking: Booking | null; onSuccess: () => void; onSavingChange?: (isSaving: boolean) => void; + onGuestCountChange?: (count: number) => void; + transparentBackground?: boolean; } // Handle type for parent component to call submit @@ -39,8 +41,13 @@ function isValidEmail(email: string): boolean { } export const AddGuestsScreen = forwardRef( - function AddGuestsScreen({ booking, onSuccess, onSavingChange }, ref) { + function AddGuestsScreen( + { booking, onSuccess, onSavingChange, onGuestCountChange, transparentBackground = false }, + ref + ) { const insets = useSafeAreaInsets(); + const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]"; + const pillStyle = transparentBackground ? "bg-[#E8E8ED]/50" : "bg-[#E8E8ED]"; const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [guests, setGuests] = useState<{ email: string; name?: string }[]>([]); @@ -51,6 +58,11 @@ export const AddGuestsScreen = forwardRef { + onGuestCountChange?.(guests.length); + }, [guests.length, onGuestCountChange]); + const handleAddGuest = useCallback(() => { const trimmedEmail = email.trim(); if (!trimmedEmail) { @@ -111,7 +123,7 @@ export const AddGuestsScreen = forwardRef + No booking data ); @@ -120,62 +132,75 @@ export const AddGuestsScreen = forwardRef - {/* Info note */} - - - - Guests will receive an email notification about this booking. - - - - {/* Form Card */} - - {/* Email input */} - + {/* Email input */} + {transparentBackground && ( + Email * + )} + + {!transparentBackground && ( Email * - - + )} + + - {/* Name input */} - + {/* Name input */} + {transparentBackground && ( + Name (optional) + )} + + {!transparentBackground && ( Name (optional) - - + )} + {/* Add button */} - Add Guest + Add {/* Guest list */} @@ -184,15 +209,21 @@ export const AddGuestsScreen = forwardRef Guests to add ({guests.length}) - + {guests.map((guest, index) => ( - + diff --git a/companion/components/screens/AvailabilityDetailScreen.tsx b/companion/components/screens/AvailabilityDetailScreen.tsx index ac8b1c916ad82d..723e5e0f1cb59f 100644 --- a/companion/components/screens/AvailabilityDetailScreen.tsx +++ b/companion/components/screens/AvailabilityDetailScreen.tsx @@ -1,7 +1,7 @@ import { Ionicons } from "@expo/vector-icons"; import { GlassView } from "expo-glass-effect"; import { useRouter } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react"; import { ActivityIndicator, Alert, ScrollView, Switch, Text, TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppPressable } from "@/components/AppPressable"; @@ -13,6 +13,17 @@ import { showErrorAlert } from "@/utils/alerts"; const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +// Map day names to numbers - module scope for React Compiler optimization +const DAY_NAME_TO_NUMBER: Record = { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}; + // Convert 24-hour time to 12-hour format with AM/PM const formatTime12Hour = (time24: string): string => { const [hours, minutes] = time24.split(":"); @@ -76,10 +87,17 @@ const TIME_OPTIONS = generateTimeOptions(); export interface AvailabilityDetailScreenProps { id: string; + useNativeHeader?: boolean; +} + +export interface AvailabilityDetailScreenHandle { + save: () => void; } -export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) { - "use no memo"; +export const AvailabilityDetailScreen = forwardRef< + AvailabilityDetailScreenHandle, + AvailabilityDetailScreenProps +>(function AvailabilityDetailScreen({ id, useNativeHeader = false }, ref) { const router = useRouter(); const insets = useSafeAreaInsets(); @@ -129,98 +147,107 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) "UTC", ]; - const fetchSchedule = useCallback(async () => { - setLoading(true); - try { - const scheduleData = await CalComAPIService.getScheduleById(Number(id)); - - if (scheduleData) { - setSchedule(scheduleData); - setScheduleName(scheduleData.name || ""); - setTimeZone(scheduleData.timeZone || "UTC"); - setIsDefault(scheduleData.isDefault || false); - - // Convert availability array to day-indexed object - const availabilityMap: Record = {}; - - // Map day names to numbers - const dayNameToNumber: Record = { - Sunday: 0, - Monday: 1, - Tuesday: 2, - Wednesday: 3, - Thursday: 4, - Friday: 5, - Saturday: 6, - }; - - if (scheduleData.availability && Array.isArray(scheduleData.availability)) { - scheduleData.availability.forEach((slot) => { - // Handle both string day names and number day formats - let days: number[] = []; - if (Array.isArray(slot.days)) { - days = slot.days - .map((day) => { - // If it's a day name string (e.g., "Sunday", "Monday") - if (typeof day === "string" && dayNameToNumber[day] !== undefined) { - return dayNameToNumber[day]; - } - // If it's a number string (e.g., "0", "1") - if (typeof day === "string") { - const parsed = parseInt(day, 10); - if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { - return parsed; - } + const processScheduleData = useCallback( + (scheduleData: NonNullable>>) => { + const name = scheduleData.name ?? ""; + const tz = scheduleData.timeZone ?? "UTC"; + const isDefaultSchedule = scheduleData.isDefault ?? false; + + setSchedule(scheduleData); + setScheduleName(name); + setTimeZone(tz); + setIsDefault(isDefaultSchedule); + + // Convert availability array to day-indexed object + const availabilityMap: Record = {}; + + const availabilityArray = scheduleData.availability; + if (availabilityArray && Array.isArray(availabilityArray)) { + availabilityArray.forEach((slot) => { + // Handle both string day names and number day formats + let days: number[] = []; + if (Array.isArray(slot.days)) { + days = slot.days + .map((day) => { + // If it's a day name string (e.g., "Sunday", "Monday") + if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) { + return DAY_NAME_TO_NUMBER[day]; + } + // If it's a number string (e.g., "0", "1") + if (typeof day === "string") { + const parsed = parseInt(day, 10); + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) { + return parsed; } - // If it's already a number - if (typeof day === "number" && day >= 0 && day <= 6) { - return day; - } - return null; - }) - .filter((day): day is number => day !== null); - } + } + // If it's already a number + if (typeof day === "number" && day >= 0 && day <= 6) { + return day; + } + return null; + }) + .filter((day): day is number => day !== null); + } - days.forEach((day) => { - if (!availabilityMap[day]) { - availabilityMap[day] = []; - } - availabilityMap[day].push({ - days: [day.toString()], - startTime: slot.startTime || "09:00:00", - endTime: slot.endTime || "17:00:00", - }); + days.forEach((day) => { + if (!availabilityMap[day]) { + availabilityMap[day] = []; + } + const startTime = slot.startTime ?? "09:00:00"; + const endTime = slot.endTime ?? "17:00:00"; + availabilityMap[day].push({ + days: [day.toString()], + startTime, + endTime, }); }); - } - - setAvailability(availabilityMap); + }); + } - // Load overrides if they exist - if (scheduleData.overrides && Array.isArray(scheduleData.overrides)) { - const formattedOverrides = scheduleData.overrides.map((override) => ({ - date: override.date || "", - startTime: override.startTime || "00:00", - endTime: override.endTime || "00:00", - })); - setOverrides(formattedOverrides); - } else { - setOverrides([]); - } + setAvailability(availabilityMap); + + // Load overrides if they exist + const overridesArray = scheduleData.overrides; + if (overridesArray && Array.isArray(overridesArray)) { + const formattedOverrides = overridesArray.map((override) => { + const date = override.date ?? ""; + const startTime = override.startTime ?? "00:00"; + const endTime = override.endTime ?? "00:00"; + return { date, startTime, endTime }; + }); + setOverrides(formattedOverrides); + } else { + setOverrides([]); } - setLoading(false); + }, + [] + ); + + const fetchSchedule = useCallback(async () => { + setLoading(true); + let scheduleData: Awaited> = null; + try { + scheduleData = await CalComAPIService.getScheduleById(Number(id)); } catch (error) { // Avoid logging raw error objects from API calls (may contain sensitive response data). console.error("Error fetching schedule"); if (__DEV__) { const message = error instanceof Error ? error.message : String(error); - console.debug("[AvailabilityDetailScreen] fetchSchedule failed", { message }); + console.debug("[AvailabilityDetailScreen] fetchSchedule failed", { + message, + }); } showErrorAlert("Error", "Failed to load schedule. Please try again."); router.back(); setLoading(false); + return; + } + + if (scheduleData) { + processScheduleData(scheduleData); } - }, [id, router]); + setLoading(false); + }, [id, router, processScheduleData]); useEffect(() => { if (id) { @@ -354,6 +381,11 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) } }; + // Expose save method to parent via ref + useImperativeHandle(ref, () => ({ + save: handleSave, + })); + const handleSetAsDefault = async () => { try { await CalComAPIService.updateSchedule(Number(id), { @@ -464,52 +496,54 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) return ( - {/* Glass Header */} - - - router.back()} - > - - + {/* Glass Header - only show when not using native header */} + {!useNativeHeader && ( + + + router.back()} + > + + - - Edit Availability - + + Edit Availability + - - Save - - - + + Save + + + + )} @@ -551,7 +585,9 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) onValueChange={() => toggleDay(dayIndex)} trackColor={{ false: "#E5E5EA", true: "#34C759" }} thumbColor="#fff" - style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }} + style={{ + transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }], + }} /> - setShowTimePicker({ dayIndex, slotIndex: 0, type: "start" }) + setShowTimePicker({ + dayIndex, + slotIndex: 0, + type: "start", + }) } className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" style={{ width: 85 }} @@ -575,7 +615,11 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) - - setShowTimePicker({ dayIndex, slotIndex: 0, type: "end" }) + setShowTimePicker({ + dayIndex, + slotIndex: 0, + type: "end", + }) } className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1" style={{ width: 85 }} @@ -1017,4 +1061,4 @@ export function AvailabilityDetailScreen({ id }: AvailabilityDetailScreenProps) ); -} +}); diff --git a/companion/components/screens/BookingDetailScreen.ios.tsx b/companion/components/screens/BookingDetailScreen.ios.tsx index 966f1cd2cd730c..6b6732e9d08356 100644 --- a/companion/components/screens/BookingDetailScreen.ios.tsx +++ b/companion/components/screens/BookingDetailScreen.ios.tsx @@ -108,7 +108,9 @@ export function BookingDetailScreen({ console.error("Failed to cancel booking"); if (__DEV__) { const message = err instanceof Error ? err.message : String(err); - console.debug("[BookingDetailScreen.ios] cancelBooking failed", { message }); + console.debug("[BookingDetailScreen.ios] cancelBooking failed", { + message, + }); } showErrorAlert("Error", "Failed to cancel booking. Please try again."); setIsCancelling(false); @@ -151,7 +153,7 @@ export function BookingDetailScreen({ const openRescheduleModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/reschedule", + pathname: "/reschedule", params: { uid: booking.uid }, }); }, [booking, router]); @@ -159,7 +161,7 @@ export function BookingDetailScreen({ const openEditLocationModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/edit-location", + pathname: "/edit-location", params: { uid: booking.uid }, }); }, [booking, router]); @@ -167,7 +169,7 @@ export function BookingDetailScreen({ const openAddGuestsModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/add-guests", + pathname: "/add-guests", params: { uid: booking.uid }, }); }, [booking, router]); @@ -175,7 +177,7 @@ export function BookingDetailScreen({ const openMarkNoShowModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/mark-no-show", + pathname: "/mark-no-show", params: { uid: booking.uid }, }); }, [booking, router]); @@ -183,7 +185,7 @@ export function BookingDetailScreen({ const openViewRecordingsModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/view-recordings", + pathname: "/view-recordings", params: { uid: booking.uid }, }); }, [booking, router]); @@ -191,7 +193,7 @@ export function BookingDetailScreen({ const openMeetingSessionDetailsModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/meeting-session-details", + pathname: "/meeting-session-details", params: { uid: booking.uid }, }); }, [booking, router]); @@ -323,15 +325,27 @@ export function BookingDetailScreen({ const isNoShow = attendee.noShow || attendee.absent; if (isPastBooking && isNoShow) { - return { name: "close-circle" as const, color: "#FF3B30", label: "No-show" }; + return { + name: "close-circle" as const, + color: "#FF3B30", + label: "No-show", + }; } if (normalizedStatus === "pending") { - return { name: "help-circle" as const, color: "#8E8E93", label: "Pending" }; + return { + name: "help-circle" as const, + color: "#8E8E93", + label: "Pending", + }; } if (normalizedStatus === "cancelled" || normalizedStatus === "rejected") { - return { name: "close-circle-outline" as const, color: "#8E8E93", label: null }; + return { + name: "close-circle-outline" as const, + color: "#8E8E93", + label: null, + }; } return { name: "checkmark-circle" as const, color: "#34C759", label: null }; @@ -410,7 +424,9 @@ export function BookingDetailScreen({ ? booking.hosts.map((host, index) => ( 0 ? "border-t border-[#E5E5EA]" : ""}`} + className={`flex-row items-center py-2 ${ + index > 0 ? "border-t border-[#E5E5EA]" : "" + }`} > @@ -599,10 +615,13 @@ export function BookingDetailScreen({ { - router.push({ - pathname: "/(tabs)/(bookings)/booking-detail", - params: { uid: booking.rescheduledToUid }, - }); + const uid = booking.rescheduledToUid; + if (uid) { + router.push({ + pathname: "/(tabs)/(bookings)/booking-detail", + params: { uid }, + }); + } }} > Rescheduled to @@ -615,7 +634,11 @@ export function BookingDetailScreen({ {booking.reschedulingReason && ( Reason {booking.reschedulingReason} @@ -652,7 +675,11 @@ export function BookingDetailScreen({ {booking.absentHost && ( Host was absent diff --git a/companion/components/screens/BookingDetailScreen.tsx b/companion/components/screens/BookingDetailScreen.tsx index eb213d9175fab3..8ec52ec1adf622 100644 --- a/companion/components/screens/BookingDetailScreen.tsx +++ b/companion/components/screens/BookingDetailScreen.tsx @@ -268,7 +268,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen const openRescheduleModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/reschedule", + pathname: "/reschedule", params: { uid: booking.uid }, }); }, [booking, router]); @@ -277,7 +277,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen const openEditLocationModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/edit-location", + pathname: "/edit-location", params: { uid: booking.uid }, }); }, [booking, router]); @@ -286,7 +286,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen const openAddGuestsModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/add-guests", + pathname: "/add-guests", params: { uid: booking.uid }, }); }, [booking, router]); @@ -295,7 +295,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen const openMarkNoShowModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/mark-no-show", + pathname: "/mark-no-show", params: { uid: booking.uid }, }); }, [booking, router]); @@ -304,7 +304,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen const openViewRecordingsModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/view-recordings", + pathname: "/view-recordings", params: { uid: booking.uid }, }); }, [booking, router]); @@ -313,7 +313,7 @@ export function BookingDetailScreen({ uid, onActionsReady }: BookingDetailScreen const openMeetingSessionDetailsModal = useCallback(() => { if (!booking) return; router.push({ - pathname: "/(tabs)/(bookings)/meeting-session-details", + pathname: "/meeting-session-details", params: { uid: booking.uid }, }); }, [booking, router]); diff --git a/companion/components/screens/EditLocationScreen.ios.tsx b/companion/components/screens/EditLocationScreen.ios.tsx index 2541553cd9ee6c..eac46c72ca2003 100644 --- a/companion/components/screens/EditLocationScreen.ios.tsx +++ b/companion/components/screens/EditLocationScreen.ios.tsx @@ -56,6 +56,7 @@ export interface EditLocationScreenProps { booking: Booking | null; onSuccess: () => void; onSavingChange?: (isSaving: boolean) => void; + transparentBackground?: boolean; } // Handle type for parent component to call submit @@ -87,18 +88,22 @@ const detectLocationType = (location: string): LocationTypeId => { }; export const EditLocationScreen = forwardRef( - function EditLocationScreen({ booking, onSuccess, onSavingChange }, ref) { + function EditLocationScreen( + { booking, onSuccess, onSavingChange, transparentBackground = false }, + ref + ) { const insets = useSafeAreaInsets(); + const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]"; const [selectedType, setSelectedType] = useState("link"); const [inputValue, setInputValue] = useState(""); const [isSaving, setIsSaving] = useState(false); - // Pre-fill with current location + // Detect location type from current location but don't pre-fill the input useEffect(() => { if (booking?.location) { const detectedType = detectLocationType(booking.location); setSelectedType(detectedType); - setInputValue(booking.location); + // Don't pre-fill - keep input blank so user can enter new location } }, [booking?.location]); @@ -179,121 +184,199 @@ export const EditLocationScreen = forwardRef + No booking data ); } return ( - + - {/* Current Location Info */} - {booking.location && ( - - - + {transparentBackground ? ( + <> + {/* Location Type Selector with Native Context Menu - Glass UI */} + + + + + + + {selectedTypeConfig.label} + + + {selectedTypeConfig.description} + + + + {/* Native iOS Context Menu Button */} + + + +