feat(companion): Multi-browser extension support (Chrome, Firefox, Safari, Edge, Brave) and fix text node errors (#26063)
* feat(companion): add multi-browser OAuth support for extension
- Add browser-specific OAuth client ID and redirect URI configuration
- Support Chrome, Firefox, Safari, Edge, and Brave browsers
- Add build scripts for each browser target (ext:build-{browser})
- Fix Brave Shields compatibility for production builds
- Add EXTENSION_SETUP.md documentation
BREAKING CHANGE: None - backward compatible with existing Chrome config
* fix(companion): replace && conditional rendering with ternary operators
Replace all `{condition && (<Component />)}` patterns with explicit
`{condition ? (<Component />) : null}` syntax across the companion app.
This fixes "Unexpected text node: . A text node cannot be a child of
a <View>" errors that occur with react-native-css-interop when using
&& conditional rendering in React Native web builds.
Files updated:
- app/event-type-detail.tsx (16)
- app/booking-detail.tsx (10)
- app/(tabs)/bookings.tsx (8)
- app/(tabs)/availability.tsx (6)
- app/availability-detail.tsx (5)
- app/(tabs)/(event-types)/index.tsx (4)
- components/event-type-detail/tabs/LimitsTab.tsx (9)
- components/event-type-detail/tabs/BasicsTab.tsx (3)
- components/event-type-detail/tabs/AdvancedTab.tsx (3)
- components/event-type-detail/tabs/RecurringTab.tsx (1)
- components/event-type-detail/tabs/AvailabilityTab.tsx (1)
- components/event-type-list-item/EventTypeListItem.ios.tsx (3)
- components/BookingActionsModal.tsx (2)
- components/Tooltip.tsx (1)
- components/LocationsList.tsx (1)
- components/Header.tsx (1)
- components/EmptyScreen.tsx (1)
* correct command for edge
* dont break ci
* deslop ai
This commit is contained in:
@@ -3,6 +3,38 @@ EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here
|
||||
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here
|
||||
|
||||
# =============================================================================
|
||||
# OAUTH REDIRECT URL FORMATS BY BROWSER
|
||||
# =============================================================================
|
||||
#
|
||||
# | Browser | Redirect URL Format | Notes |
|
||||
# |---------|------------------------------------------------------------------|----------------------------------------------------|
|
||||
# | Chrome | https://<id>.chromiumapp.org/oauth/callback | Get ID from `chrome://extensions` |
|
||||
# | Brave | https://<id>.chromiumapp.org/oauth/callback | Same extension package as Chrome |
|
||||
# | Edge | https://<id>.chromiumapp.org/oauth/callback | Different ID from Chrome |
|
||||
# | Firefox | https://<uuid>.extensions.allizom.org/oauth/callback | UUID from `browser.identity.getRedirectURL()` |
|
||||
# | Safari | https://<team>.<bundle>.appextension/oauth/callback | Check Xcode project configuration |
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# FIREFOX OAUTH CONFIG
|
||||
# =============================================================================
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX=
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX=
|
||||
|
||||
# =============================================================================
|
||||
# SAFARI OAUTH CONFIG
|
||||
# =============================================================================
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI=
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI=
|
||||
|
||||
# =============================================================================
|
||||
# EDGE OAUTH CONFIG
|
||||
# =============================================================================
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE=
|
||||
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE=
|
||||
|
||||
# ===========================================
|
||||
# CACHE CONFIGURATION (all values in MINUTES)
|
||||
# ===========================================
|
||||
|
||||
@@ -690,17 +690,17 @@ export default function EventTypes() {
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedEventType && (
|
||||
{selectedEventType ? (
|
||||
<>
|
||||
<View className="border-b border-gray-200 p-6">
|
||||
<Text className="mb-2 text-lg font-semibold text-gray-900">
|
||||
{selectedEventType.title}
|
||||
</Text>
|
||||
{selectedEventType.description && (
|
||||
{selectedEventType.description ? (
|
||||
<Text className="text-sm text-gray-600">
|
||||
{normalizeMarkdown(selectedEventType.description)}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="p-2">
|
||||
@@ -759,7 +759,7 @@ export default function EventTypes() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</FullScreenModal>
|
||||
@@ -844,12 +844,12 @@ export default function EventTypes() {
|
||||
Delete Event Type
|
||||
</Text>
|
||||
<Text className="text-sm leading-5 text-gray-600">
|
||||
{eventTypeToDelete && (
|
||||
{eventTypeToDelete ? (
|
||||
<>
|
||||
This will permanently delete the "{eventTypeToDelete.title}" event type.
|
||||
This action cannot be undone.
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -881,13 +881,13 @@ export default function EventTypes() {
|
||||
</FullScreenModal>
|
||||
|
||||
{/* Toast for Web Platform */}
|
||||
{showToast && (
|
||||
{showToast ? (
|
||||
<View className="absolute bottom-8 left-1/2 z-50 -translate-x-1/2 transform">
|
||||
<View className="rounded-full bg-gray-800 px-6 py-3 shadow-lg">
|
||||
<Text className="text-sm font-medium text-white">{toastMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,11 +273,11 @@ export default function Availability() {
|
||||
<View className="mr-4 flex-1">
|
||||
<View className="mb-1 flex-row flex-wrap items-center">
|
||||
<Text className="text-base font-semibold text-[#333]">{schedule.name}</Text>
|
||||
{schedule.isDefault && (
|
||||
{schedule.isDefault ? (
|
||||
<View className="ml-2 rounded bg-[#666] px-2 py-0.5">
|
||||
<Text className="text-xs font-semibold text-white">Default</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{schedule.availability && schedule.availability.length > 0 ? (
|
||||
@@ -360,7 +360,7 @@ export default function Availability() {
|
||||
<Header />
|
||||
|
||||
{/* Empty state - no schedules */}
|
||||
{showEmptyState && (
|
||||
{showEmptyState ? (
|
||||
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -380,10 +380,10 @@ export default function Availability() {
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Search bar and content for non-empty states */}
|
||||
{!showEmptyState && (
|
||||
{!showEmptyState ? (
|
||||
<>
|
||||
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
|
||||
<TextInput
|
||||
@@ -406,7 +406,7 @@ export default function Availability() {
|
||||
</View>
|
||||
|
||||
{/* Search empty state */}
|
||||
{showSearchEmptyState && (
|
||||
{showSearchEmptyState ? (
|
||||
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -424,10 +424,10 @@ export default function Availability() {
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Schedules list */}
|
||||
{showList && (
|
||||
{showList ? (
|
||||
<View className="flex-1 px-2 pt-4 md:px-4">
|
||||
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<FlatList
|
||||
@@ -440,9 +440,9 @@ export default function Availability() {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Create Schedule Modal */}
|
||||
<FullScreenModal
|
||||
@@ -536,7 +536,7 @@ export default function Availability() {
|
||||
{/* Actions List */}
|
||||
<View className="p-2">
|
||||
{/* Set as Default - only show if not already default */}
|
||||
{selectedSchedule && !selectedSchedule.isDefault && (
|
||||
{selectedSchedule && !selectedSchedule.isDefault ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
@@ -553,7 +553,7 @@ export default function Availability() {
|
||||
|
||||
<View className="mx-4 my-2 h-px bg-gray-200" />
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Duplicate */}
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -429,7 +429,7 @@ export default function Bookings() {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{selectedEventTypeId !== null && (
|
||||
{selectedEventTypeId !== null ? (
|
||||
<View className="mt-2 flex-row items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Text className="flex-1 text-sm text-[#333]">
|
||||
Filtered by {selectedEventTypeLabel || "event type"}
|
||||
@@ -438,7 +438,7 @@ export default function Bookings() {
|
||||
<Text className="text-sm font-semibold text-[#007AFF]">Clear filter</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
@@ -933,6 +933,46 @@ export default function Bookings() {
|
||||
const isCancelled = item.status?.toUpperCase() === "CANCELLED";
|
||||
const isRejected = item.status?.toUpperCase() === "REJECTED";
|
||||
|
||||
const getHostAndAttendeesDisplay = () => {
|
||||
const hasHostOrAttendees =
|
||||
(item.hosts && item.hosts.length > 0) ||
|
||||
item.user ||
|
||||
(item.attendees && item.attendees.length > 0);
|
||||
|
||||
if (!hasHostOrAttendees) return null;
|
||||
|
||||
const currentUserEmail = userInfo?.email?.toLowerCase();
|
||||
const hostEmail = item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase();
|
||||
const isCurrentUserHost = currentUserEmail && hostEmail && currentUserEmail === hostEmail;
|
||||
|
||||
const hostName = isCurrentUserHost
|
||||
? "You"
|
||||
: item.hosts?.[0]?.name || item.hosts?.[0]?.email || item.user?.name || item.user?.email;
|
||||
|
||||
const attendeesDisplay =
|
||||
item.attendees && item.attendees.length > 0
|
||||
? item.attendees.length === 1
|
||||
? item.attendees[0].name || item.attendees[0].email
|
||||
: item.attendees
|
||||
.slice(0, 2)
|
||||
.map((att) => att.name || att.email)
|
||||
.join(", ") +
|
||||
(item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "")
|
||||
: null;
|
||||
|
||||
if (hostName && attendeesDisplay) {
|
||||
return `${hostName} and ${attendeesDisplay}`;
|
||||
} else if (hostName) {
|
||||
return hostName;
|
||||
} else if (attendeesDisplay) {
|
||||
return attendeesDisplay;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hostAndAttendeesDisplay = getHostAndAttendeesDisplay();
|
||||
const meetingInfo = getMeetingInfo(item.location);
|
||||
|
||||
return (
|
||||
<View className="border-b border-[#E5E5EA] bg-white">
|
||||
<TouchableOpacity
|
||||
@@ -953,16 +993,14 @@ export default function Bookings() {
|
||||
{formatTime(startTime)} - {formatTime(endTime)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Badges Row */}
|
||||
<View className="mb-3 flex-row flex-wrap items-center">
|
||||
{isPending && (
|
||||
{isPending ? (
|
||||
<View className="mb-1 mr-2 rounded bg-[#FF9500] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">Unconfirmed</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
className={`mb-2 text-lg font-medium leading-5 text-[#333] ${isCancelled || isRejected ? "line-through" : ""}`}
|
||||
@@ -970,109 +1008,52 @@ export default function Bookings() {
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
{item.description ? (
|
||||
<Text className="mb-2 text-sm leading-5 text-[#666]" numberOfLines={1}>
|
||||
"{item.description}"
|
||||
</Text>
|
||||
)}
|
||||
|
||||
) : null}
|
||||
{/* Host and Attendees */}
|
||||
{((item.hosts && item.hosts.length > 0) ||
|
||||
item.user ||
|
||||
(item.attendees && item.attendees.length > 0)) && (
|
||||
<Text className="mb-2 text-sm text-[#333]">
|
||||
{(() => {
|
||||
// Check if current user is the host
|
||||
const currentUserEmail = userInfo?.email?.toLowerCase();
|
||||
const hostEmail =
|
||||
item.hosts?.[0]?.email?.toLowerCase() || item.user?.email?.toLowerCase();
|
||||
const isCurrentUserHost =
|
||||
currentUserEmail && hostEmail && currentUserEmail === hostEmail;
|
||||
|
||||
// Get host display name
|
||||
const hostName = isCurrentUserHost
|
||||
? "You"
|
||||
: item.hosts?.[0]?.name ||
|
||||
item.hosts?.[0]?.email ||
|
||||
item.user?.name ||
|
||||
item.user?.email;
|
||||
|
||||
// Get attendees display
|
||||
const attendeesDisplay =
|
||||
item.attendees && item.attendees.length > 0
|
||||
? item.attendees.length === 1
|
||||
? item.attendees[0].name || item.attendees[0].email
|
||||
: item.attendees
|
||||
.slice(0, 2)
|
||||
.map((att) => att.name || att.email)
|
||||
.join(", ") +
|
||||
(item.attendees.length > 2 ? ` and ${item.attendees.length - 2} more` : "")
|
||||
: null;
|
||||
|
||||
// Combine host and attendees
|
||||
if (hostName && attendeesDisplay) {
|
||||
return `${hostName} and ${attendeesDisplay}`;
|
||||
} else if (hostName) {
|
||||
return hostName;
|
||||
} else if (attendeesDisplay) {
|
||||
return attendeesDisplay;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Meeting Link - only for video conferencing apps */}
|
||||
{(() => {
|
||||
const meetingInfo = getMeetingInfo(item.location);
|
||||
if (!meetingInfo) return null;
|
||||
|
||||
return (
|
||||
<View className="mb-1 flex-row">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
||||
onPress={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await Linking.openURL(meetingInfo.cleanUrl);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{meetingInfo.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={meetingInfo.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons
|
||||
name="videocam"
|
||||
size={16}
|
||||
color="#007AFF"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
)}
|
||||
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
{hostAndAttendeesDisplay ? (
|
||||
<Text className="mb-2 text-sm text-[#333]">{hostAndAttendeesDisplay}</Text>
|
||||
) : null}
|
||||
{/* Meeting Link */}
|
||||
{meetingInfo ? (
|
||||
<View className="mb-1 flex-row">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
||||
onPress={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await Linking.openURL(meetingInfo.cleanUrl);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to open meeting link. Please try again.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{meetingInfo.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={meetingInfo.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons name="videocam" size={16} color="#007AFF" style={{ marginRight: 6 }} />
|
||||
)}
|
||||
<Text className="text-sm font-medium text-[#007AFF]">{meetingInfo.label}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Action buttons row */}
|
||||
<View
|
||||
className="flex-row items-center justify-end"
|
||||
style={{ paddingHorizontal: 16, paddingBottom: 16, gap: 8 }}
|
||||
>
|
||||
{/* Confirm and Reject buttons for unconfirmed bookings */}
|
||||
{isPending && (
|
||||
{isPending ? (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-white"
|
||||
@@ -1119,9 +1100,7 @@ export default function Bookings() {
|
||||
<Text className="ml-1 text-sm font-medium text-[#3C3F44]">Reject</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Three dots button */}
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
|
||||
style={{ width: 32, height: 32 }}
|
||||
@@ -1193,7 +1172,7 @@ export default function Bookings() {
|
||||
{renderSegmentedControl()}
|
||||
|
||||
{/* Empty state - no bookings */}
|
||||
{showEmptyState && (
|
||||
{showEmptyState ? (
|
||||
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -1211,10 +1190,10 @@ export default function Bookings() {
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Search empty state */}
|
||||
{showSearchEmptyState && (
|
||||
{showSearchEmptyState ? (
|
||||
<View className="flex-1 bg-gray-50" style={{ paddingBottom: 100 }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
@@ -1232,10 +1211,10 @@ export default function Bookings() {
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Bookings list */}
|
||||
{showList && (
|
||||
{showList ? (
|
||||
<View className="flex-1 px-2 pt-4 md:px-4">
|
||||
<View className="flex-1 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
|
||||
<FlatList
|
||||
@@ -1248,7 +1227,7 @@ export default function Bookings() {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Filter Modal */}
|
||||
<FullScreenModal
|
||||
@@ -1290,17 +1269,17 @@ export default function Bookings() {
|
||||
>
|
||||
{eventType.title}
|
||||
</Text>
|
||||
{selectedEventTypeId === eventType.id && (
|
||||
{selectedEventTypeId === eventType.id ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
{eventTypes.length === 0 && (
|
||||
{eventTypes.length === 0 ? (
|
||||
<View className="items-center py-4">
|
||||
<Text className="text-sm text-[#666]">No event types found</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</ScrollView>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -1364,7 +1343,7 @@ export default function Bookings() {
|
||||
}}
|
||||
>
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{rescheduleBooking && (
|
||||
{rescheduleBooking ? (
|
||||
<>
|
||||
<Text className="mb-4 text-base text-gray-600">
|
||||
Reschedule "{rescheduleBooking.title}"
|
||||
@@ -1442,7 +1421,7 @@ export default function Bookings() {
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</FullScreenModal>
|
||||
|
||||
|
||||
@@ -537,7 +537,7 @@ export default function AvailabilityDetail() {
|
||||
>
|
||||
{DAY_ABBREVIATIONS[dayIndex]}
|
||||
</Text>
|
||||
{isEnabled && firstSlot && (
|
||||
{isEnabled && firstSlot ? (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
@@ -563,16 +563,16 @@ export default function AvailabilityDetail() {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{isEnabled && (
|
||||
{isEnabled ? (
|
||||
<TouchableOpacity onPress={() => addTimeSlot(dayIndex)} className="p-1">
|
||||
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{isEnabled && daySlots.length > 1 && (
|
||||
{isEnabled && daySlots.length > 1 ? (
|
||||
<View className="mt-2" style={{ marginLeft: 108 }}>
|
||||
{daySlots.slice(1).map((slot, slotIndex) => (
|
||||
<View key={slotIndex + 1} className="mb-2 flex-row items-center gap-2">
|
||||
@@ -616,7 +616,7 @@ export default function AvailabilityDetail() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
@@ -641,7 +641,7 @@ export default function AvailabilityDetail() {
|
||||
Add dates when your availability changes from your daily hours.
|
||||
</Text>
|
||||
|
||||
{overrides.length > 0 && (
|
||||
{overrides.length > 0 ? (
|
||||
<View className="mb-4">
|
||||
{overrides.map((override, index) => (
|
||||
<View
|
||||
@@ -675,7 +675,7 @@ export default function AvailabilityDetail() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddOverride}
|
||||
@@ -894,7 +894,7 @@ export default function AvailabilityDetail() {
|
||||
</View>
|
||||
|
||||
{/* Time Picker (only if not unavailable) */}
|
||||
{!isUnavailable && (
|
||||
{!isUnavailable ? (
|
||||
<View>
|
||||
<Text className="mb-3 text-base font-semibold text-[#333]">
|
||||
Which hours are you free?
|
||||
@@ -919,7 +919,7 @@ export default function AvailabilityDetail() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -365,7 +365,7 @@ export default function BookingDetail() {
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-4 text-base font-medium text-[#666]">Who</Text>
|
||||
{/* Show host from user field or hosts array */}
|
||||
{(booking.user || (booking.hosts && booking.hosts.length > 0)) && (
|
||||
{booking.user || (booking.hosts && booking.hosts.length > 0) ? (
|
||||
<View className="mb-4">
|
||||
{booking.user ? (
|
||||
<View className="flex-row items-start">
|
||||
@@ -412,8 +412,8 @@ export default function BookingDetail() {
|
||||
))
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
{booking.attendees && booking.attendees.length > 0 && (
|
||||
) : null}
|
||||
{booking.attendees && booking.attendees.length > 0 ? (
|
||||
<View>
|
||||
{booking.attendees.map((attendee, index) => (
|
||||
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
|
||||
@@ -431,11 +431,11 @@ export default function BookingDetail() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Where Section */}
|
||||
{locationProvider && (
|
||||
{locationProvider ? (
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-4 text-base font-medium text-[#666]">Where</Text>
|
||||
{locationProvider.url ? (
|
||||
@@ -443,14 +443,14 @@ export default function BookingDetail() {
|
||||
onPress={handleJoinMeeting}
|
||||
className="flex-row flex-wrap items-center"
|
||||
>
|
||||
{locationProvider.iconUrl && (
|
||||
{locationProvider.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
|
||||
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
|
||||
{locationProvider.url}
|
||||
@@ -458,55 +458,55 @@ export default function BookingDetail() {
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View className="flex-row items-center">
|
||||
{locationProvider.iconUrl && (
|
||||
{locationProvider.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Recurring Event Section */}
|
||||
{(booking.recurringEventId || booking.recurringBookingUid) && (
|
||||
{booking.recurringEventId || booking.recurringBookingUid ? (
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-2 text-base font-medium text-[#666]">Recurring Event</Text>
|
||||
<Text className="text-base text-[#666]">Every 2 weeks for 6 occurrences</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Description Section */}
|
||||
{booking.description && (
|
||||
{booking.description ? (
|
||||
<View className="mb-2 rounded-2xl bg-white p-6">
|
||||
<Text className="mb-2 text-base font-medium text-[#666]">Description</Text>
|
||||
<Text className="text-base leading-6 text-[#666]">{booking.description}</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Join Meeting Button */}
|
||||
{locationProvider?.url && (
|
||||
{locationProvider?.url ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleJoinMeeting}
|
||||
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
|
||||
>
|
||||
{locationProvider.iconUrl && (
|
||||
{locationProvider.iconUrl ? (
|
||||
<SvgImage
|
||||
uri={locationProvider.iconUrl}
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
<Text className="text-base font-semibold text-white">
|
||||
Join {locationProvider.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1157,7 +1157,7 @@ export default function EventTypeDetail() {
|
||||
}}
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 200 }}
|
||||
>
|
||||
{activeTab === "basics" && (
|
||||
{activeTab === "basics" ? (
|
||||
<BasicsTab
|
||||
eventTitle={eventTitle}
|
||||
setEventTitle={setEventTitle}
|
||||
@@ -1182,7 +1182,7 @@ export default function EventTypeDetail() {
|
||||
locationOptions={getLocationOptionsForDropdown()}
|
||||
conferencingLoading={conferencingLoading}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Duration Multi-Select Modal */}
|
||||
<Modal
|
||||
@@ -1213,9 +1213,9 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
{selectedDurations.includes(duration) && (
|
||||
{selectedDurations.includes(duration) ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -1260,9 +1260,9 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
{defaultDuration === duration && (
|
||||
{defaultDuration === duration ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -1302,15 +1302,15 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{schedule.name}
|
||||
</Text>
|
||||
{schedule.isDefault && (
|
||||
{schedule.isDefault ? (
|
||||
<Text className="rounded bg-[#E8F5E8] px-1.5 py-0.5 text-xs font-medium text-[#34C759]">
|
||||
Default
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{selectedSchedule?.id === schedule.id && (
|
||||
{selectedSchedule?.id === schedule.id ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -1368,10 +1368,10 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{tz}
|
||||
</Text>
|
||||
{(selectedTimezone === tz ||
|
||||
(selectedScheduleDetails?.timeZone === tz && !selectedTimezone)) && (
|
||||
{selectedTimezone === tz ||
|
||||
(selectedScheduleDetails?.timeZone === tz && !selectedTimezone) ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -1410,9 +1410,9 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
{beforeEventBuffer === option && (
|
||||
{beforeEventBuffer === option ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -1450,9 +1450,9 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
{afterEventBuffer === option && (
|
||||
{afterEventBuffer === option ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -1490,9 +1490,9 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
{minimumNoticeUnit === option && (
|
||||
{minimumNoticeUnit === option ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -1622,9 +1622,9 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
{slotInterval === option && (
|
||||
{slotInterval === option ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
@@ -1662,16 +1662,16 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
{option === "weekly" ? "week" : option === "monthly" ? "month" : "year"}
|
||||
</Text>
|
||||
{recurringFrequency === option && (
|
||||
{recurringFrequency === option ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
)}
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{activeTab === "availability" && (
|
||||
{activeTab === "availability" ? (
|
||||
<AvailabilityTab
|
||||
selectedSchedule={selectedSchedule}
|
||||
setShowScheduleDropdown={setShowScheduleDropdown}
|
||||
@@ -1682,9 +1682,9 @@ export default function EventTypeDetail() {
|
||||
formatTime={formatTime}
|
||||
selectedTimezone={selectedTimezone}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{activeTab === "limits" && (
|
||||
{activeTab === "limits" ? (
|
||||
<LimitsTab
|
||||
beforeEventBuffer={beforeEventBuffer}
|
||||
setShowBeforeBufferDropdown={setShowBeforeBufferDropdown}
|
||||
@@ -1733,9 +1733,9 @@ export default function EventTypeDetail() {
|
||||
rangeEndDate={rangeEndDate}
|
||||
setRangeEndDate={setRangeEndDate}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{activeTab === "advanced" && (
|
||||
{activeTab === "advanced" ? (
|
||||
<AdvancedTab
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
setRequiresConfirmation={setRequiresConfirmation}
|
||||
@@ -1779,9 +1779,9 @@ export default function EventTypeDetail() {
|
||||
// Event type ID for private links
|
||||
eventTypeId={id}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{activeTab === "recurring" && (
|
||||
{activeTab === "recurring" ? (
|
||||
<RecurringTab
|
||||
recurringEnabled={recurringEnabled}
|
||||
setRecurringEnabled={setRecurringEnabled}
|
||||
@@ -1793,9 +1793,9 @@ export default function EventTypeDetail() {
|
||||
setRecurringOccurrences={setRecurringOccurrences}
|
||||
setShowFrequencyDropdown={setShowRecurringFrequencyDropdown}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{activeTab === "other" && (
|
||||
{activeTab === "other" ? (
|
||||
<View className="rounded-2xl bg-white p-5 shadow-md">
|
||||
<Text className="mb-2 text-lg font-semibold text-[#333]">Additional Settings</Text>
|
||||
<Text className="mb-5 text-sm leading-5 text-[#666]">
|
||||
@@ -1884,7 +1884,7 @@ export default function EventTypeDetail() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Action Bar */}
|
||||
|
||||
@@ -163,7 +163,7 @@ export function BookingActionsModal({
|
||||
</View>
|
||||
|
||||
{/* View Recordings */}
|
||||
{hasLocationUrl && (
|
||||
{hasLocationUrl ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (afterEventActionsDisabled) return;
|
||||
@@ -184,10 +184,10 @@ export function BookingActionsModal({
|
||||
View Recordings
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Meeting Session Details */}
|
||||
{hasLocationUrl && (
|
||||
{hasLocationUrl ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (afterEventActionsDisabled) return;
|
||||
@@ -208,7 +208,7 @@ export function BookingActionsModal({
|
||||
Meeting Session Details
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Mark as No-Show - disabled for upcoming and unconfirmed bookings */}
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -36,7 +36,7 @@ export function EmptyScreen({
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{buttonText && onButtonPress && (
|
||||
{buttonText && onButtonPress ? (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center gap-1 rounded-lg bg-gray-900 px-4 py-2.5"
|
||||
onPress={onButtonPress}
|
||||
@@ -44,7 +44,7 @@ export function EmptyScreen({
|
||||
<Ionicons name="add" size={18} color="#fff" />
|
||||
<Text className="text-base font-medium text-white">{buttonText}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -209,9 +209,9 @@ export function Header() {
|
||||
<Text className="text-lg font-semibold text-gray-900">
|
||||
{userProfile?.name || "User"}
|
||||
</Text>
|
||||
{userProfile?.email && (
|
||||
{userProfile?.email ? (
|
||||
<Text className="text-sm text-gray-600">{userProfile.email}</Text>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -170,7 +170,7 @@ export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
{location.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
{!disabled && (
|
||||
{!disabled ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemove(location.id)}
|
||||
className="ml-2 p-1"
|
||||
@@ -178,7 +178,7 @@ export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
{renderLocationInput(location)}
|
||||
</View>
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Tooltip({ text, children }: TooltipProps) {
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{children}
|
||||
{showTooltip && (
|
||||
{showTooltip ? (
|
||||
<View
|
||||
// @ts-ignore - web-only styles
|
||||
style={{
|
||||
@@ -63,7 +63,7 @@ export function Tooltip({ text, children }: TooltipProps) {
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
|
||||
</View>
|
||||
|
||||
{/* Seats Configuration - shown when enabled */}
|
||||
{props.seatsEnabled && (
|
||||
{props.seatsEnabled ? (
|
||||
<View className="gap-4 border-t border-[#E5E5EA] p-5">
|
||||
{/* Number of seats per booking */}
|
||||
<View>
|
||||
@@ -342,14 +342,14 @@ export function AdvancedTab(props: AdvancedTabProps) {
|
||||
: "border-[#D1D5DB] bg-white"
|
||||
}`}
|
||||
>
|
||||
{props.showAvailabilityCount && (
|
||||
{props.showAvailabilityCount ? (
|
||||
<Ionicons name="checkmark" size={14} color="#fff" />
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
<Text className="text-sm text-[#333]">Show the number of available seats</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
@@ -397,7 +397,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
|
||||
</View>
|
||||
|
||||
{/* Timezone selector - shown when enabled */}
|
||||
{props.lockTimezone && (
|
||||
{props.lockTimezone ? (
|
||||
<View className="border-t border-[#E5E5EA] p-5">
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Timezone</Text>
|
||||
<TouchableOpacity
|
||||
@@ -423,7 +423,7 @@ export function AdvancedTab(props: AdvancedTabProps) {
|
||||
<Ionicons name="chevron-down" size={20} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<ConfigureOnWebCard
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AvailabilityTab(props: AvailabilityTabProps) {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{props.selectedSchedule && (
|
||||
{props.selectedSchedule ? (
|
||||
<>
|
||||
<View
|
||||
className="mt-5 pt-5"
|
||||
@@ -104,7 +104,7 @@ export function AvailabilityTab(props: AvailabilityTabProps) {
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function BasicsTab(props: BasicsTabProps) {
|
||||
|
||||
{/* Duration Card */}
|
||||
<View className="rounded-2xl bg-white p-5">
|
||||
{!props.allowMultipleDurations && (
|
||||
{!props.allowMultipleDurations ? (
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Duration</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
@@ -99,9 +99,9 @@ export function BasicsTab(props: BasicsTabProps) {
|
||||
<Text className="text-base text-[#666]">Minutes</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{props.allowMultipleDurations && (
|
||||
{props.allowMultipleDurations ? (
|
||||
<>
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">
|
||||
@@ -122,7 +122,7 @@ export function BasicsTab(props: BasicsTabProps) {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{props.selectedDurations.length > 0 && (
|
||||
{props.selectedDurations.length > 0 ? (
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Default duration</Text>
|
||||
<TouchableOpacity
|
||||
@@ -135,9 +135,9 @@ export function BasicsTab(props: BasicsTabProps) {
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<View className="flex-row items-start justify-between">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Allow multiple durations</Text>
|
||||
|
||||
@@ -183,7 +183,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.limitBookingFrequency && (
|
||||
{props.limitBookingFrequency ? (
|
||||
<>
|
||||
{props.frequencyLimits.map((limit) => (
|
||||
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
|
||||
@@ -208,14 +208,14 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
<Text className="text-base text-black">{limit.unit}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
{props.frequencyLimits.length > 1 && (
|
||||
{props.frequencyLimits.length > 1 ? (
|
||||
<TouchableOpacity
|
||||
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
|
||||
onPress={() => props.removeFrequencyLimit(limit.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
@@ -226,7 +226,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
<Text className="text-base font-medium text-black">Add Limit</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -282,7 +282,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.limitTotalDuration && (
|
||||
{props.limitTotalDuration ? (
|
||||
<>
|
||||
{props.durationLimits.map((limit) => (
|
||||
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
|
||||
@@ -310,14 +310,14 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
<Text className="text-base text-black">{limit.unit}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
{props.durationLimits.length > 1 && (
|
||||
{props.durationLimits.length > 1 ? (
|
||||
<TouchableOpacity
|
||||
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
|
||||
onPress={() => props.removeDurationLimit(limit.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
@@ -328,7 +328,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
<Text className="text-base font-medium text-black">Add Limit</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -350,7 +350,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
{props.maxActiveBookingsPerBooker && (
|
||||
{props.maxActiveBookingsPerBooker ? (
|
||||
<View className="mt-4 gap-3">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TextInput
|
||||
@@ -385,7 +385,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* 5. Limit Future Bookings Card */}
|
||||
@@ -404,7 +404,7 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
{props.limitFutureBookings && (
|
||||
{props.limitFutureBookings ? (
|
||||
<View className="mt-4 gap-3">
|
||||
{/* Rolling option */}
|
||||
<TouchableOpacity
|
||||
@@ -416,9 +416,9 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
props.futureBookingType === "rolling" ? "border-[#007AFF]" : "border-[#C7C7CC]"
|
||||
}`}
|
||||
>
|
||||
{props.futureBookingType === "rolling" && (
|
||||
{props.futureBookingType === "rolling" ? (
|
||||
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="flex-row flex-wrap items-center gap-2">
|
||||
@@ -460,13 +460,13 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
props.futureBookingType === "range" ? "border-[#007AFF]" : "border-[#C7C7CC]"
|
||||
}`}
|
||||
>
|
||||
{props.futureBookingType === "range" && (
|
||||
{props.futureBookingType === "range" ? (
|
||||
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-base text-[#333]">Within a date range</Text>
|
||||
{props.futureBookingType === "range" && (
|
||||
{props.futureBookingType === "range" ? (
|
||||
<View className="gap-2">
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
|
||||
@@ -483,11 +483,11 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ export function RecurringTab({
|
||||
</View>
|
||||
|
||||
{/* Recurring Configuration - shown when enabled */}
|
||||
{recurringEnabled && (
|
||||
{recurringEnabled ? (
|
||||
<View className="mt-4 gap-4 border-t border-[#E5E5EA] pt-4">
|
||||
{/* Repeats Every */}
|
||||
<View>
|
||||
@@ -126,7 +126,7 @@ export function RecurringTab({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -87,11 +87,11 @@ export const EventTypeListItem = ({
|
||||
<View className="mb-1 flex-row items-center">
|
||||
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title} </Text>
|
||||
</View>
|
||||
{item.description && (
|
||||
{item.description ? (
|
||||
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
|
||||
{normalizeMarkdown(item.description)}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
|
||||
<Ionicons name="time-outline" size={14} color="#000" />
|
||||
<Text className="ml-1.5 text-xs font-semibold text-black">
|
||||
@@ -100,17 +100,17 @@ export const EventTypeListItem = ({
|
||||
</View>
|
||||
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
|
||||
<View className="mt-2 flex-row items-center gap-3">
|
||||
{item.price != null && item.price > 0 && (
|
||||
{item.price != null && item.price > 0 ? (
|
||||
<Text className="text-sm font-medium text-[#34C759]">
|
||||
{item.currency || "$"}
|
||||
{item.price}
|
||||
</Text>
|
||||
)}
|
||||
{item.requiresConfirmation && (
|
||||
) : null}
|
||||
{item.requiresConfirmation ? (
|
||||
<View className="rounded bg-[#FF9500] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -45,11 +45,11 @@ export const EventTypeListItem = ({
|
||||
<View className="mb-1 flex-row items-center">
|
||||
<Text className="flex-1 text-base font-semibold text-[#333]">{item.title}</Text>
|
||||
</View>
|
||||
{item.description && (
|
||||
{item.description ? (
|
||||
<Text className="mb-2 mt-0.5 text-sm leading-5 text-[#666]" numberOfLines={2}>
|
||||
{normalizeMarkdown(item.description)}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
<View className="mt-2 flex-row items-center self-start rounded-lg border border-[#E5E5EA] bg-[#E5E5EA] px-2 py-1">
|
||||
<Ionicons name="time-outline" size={14} color="#000" />
|
||||
<Text className="ml-1.5 text-xs font-semibold text-black">
|
||||
@@ -58,17 +58,17 @@ export const EventTypeListItem = ({
|
||||
</View>
|
||||
{(item.price != null && item.price > 0) || item.requiresConfirmation ? (
|
||||
<View className="mt-2 flex-row items-center gap-3">
|
||||
{item.price != null && item.price > 0 && (
|
||||
{item.price != null && item.price > 0 ? (
|
||||
<Text className="text-sm font-medium text-[#34C759]">
|
||||
{item.currency || "$"}
|
||||
{item.price}
|
||||
</Text>
|
||||
)}
|
||||
{item.requiresConfirmation && (
|
||||
) : null}
|
||||
{item.requiresConfirmation ? (
|
||||
<View className="rounded bg-[#FF9500] px-2 py-0.5">
|
||||
<Text className="text-xs font-medium text-white">Requires Confirmation</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OAuthTokens } from "../../../services/oauthService";
|
||||
|
||||
const DEV_API_KEY = import.meta.env.EXPO_PUBLIC_CAL_API_KEY as string | undefined;
|
||||
const IS_DEV_MODE = Boolean(DEV_API_KEY && DEV_API_KEY.length > 0);
|
||||
const BROWSER_TARGET = import.meta.env.BROWSER_TARGET || "chrome";
|
||||
|
||||
const devLog = {
|
||||
log: (...args: unknown[]) => IS_DEV_MODE && console.log("[Cal.com]", ...args),
|
||||
@@ -11,14 +12,136 @@ const devLog = {
|
||||
error: (...args: unknown[]) => console.error("[Cal.com]", ...args),
|
||||
};
|
||||
|
||||
/**
|
||||
* Browser type enum (inlined to avoid import issues in service worker)
|
||||
*/
|
||||
enum BrowserType {
|
||||
Chrome = "chrome",
|
||||
Firefox = "firefox",
|
||||
Safari = "safari",
|
||||
Edge = "edge",
|
||||
Brave = "brave",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect browser type based on user agent and APIs
|
||||
*/
|
||||
function detectBrowser(): BrowserType {
|
||||
if (typeof navigator === "undefined") {
|
||||
return BrowserType.Unknown;
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
// Check for Brave
|
||||
// @ts-ignore - Brave adds this to navigator
|
||||
if (navigator.brave && typeof navigator.brave.isBrave === "function") {
|
||||
return BrowserType.Brave;
|
||||
}
|
||||
|
||||
// Check for Edge
|
||||
if (userAgent.includes("edg/")) {
|
||||
return BrowserType.Edge;
|
||||
}
|
||||
|
||||
// Check for Firefox
|
||||
if (userAgent.includes("firefox")) {
|
||||
return BrowserType.Firefox;
|
||||
}
|
||||
|
||||
// Check for Safari
|
||||
if (
|
||||
userAgent.includes("safari") &&
|
||||
!userAgent.includes("chrome") &&
|
||||
!userAgent.includes("chromium")
|
||||
) {
|
||||
return BrowserType.Safari;
|
||||
}
|
||||
|
||||
// Default to Chrome
|
||||
if (userAgent.includes("chrome") || userAgent.includes("chromium")) {
|
||||
return BrowserType.Chrome;
|
||||
}
|
||||
|
||||
return BrowserType.Unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser display name for logging
|
||||
*/
|
||||
function getBrowserDisplayName(): string {
|
||||
const browser = detectBrowser();
|
||||
switch (browser) {
|
||||
case BrowserType.Chrome:
|
||||
return "Chrome";
|
||||
case BrowserType.Firefox:
|
||||
return "Firefox";
|
||||
case BrowserType.Safari:
|
||||
return "Safari";
|
||||
case BrowserType.Edge:
|
||||
return "Microsoft Edge";
|
||||
case BrowserType.Brave:
|
||||
return "Brave";
|
||||
default:
|
||||
return "Unknown Browser";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-browser API helpers
|
||||
* These provide unified access to browser extension APIs across Chrome, Firefox, Safari, and Edge
|
||||
*/
|
||||
|
||||
// Get the appropriate browser API namespace
|
||||
function getBrowserAPI(): typeof chrome {
|
||||
// @ts-ignore - Firefox/Safari use browser namespace
|
||||
if (typeof browser !== "undefined" && browser.runtime) {
|
||||
// @ts-ignore
|
||||
return browser;
|
||||
}
|
||||
return chrome;
|
||||
}
|
||||
|
||||
// Get identity API with cross-browser support
|
||||
function getIdentityAPI(): typeof chrome.identity | null {
|
||||
const api = getBrowserAPI();
|
||||
return api?.identity || null;
|
||||
}
|
||||
|
||||
// Get storage API with cross-browser support
|
||||
function getStorageAPI(): typeof chrome.storage | null {
|
||||
const api = getBrowserAPI();
|
||||
return api?.storage || null;
|
||||
}
|
||||
|
||||
// Get runtime API with cross-browser support
|
||||
function getRuntimeAPI(): typeof chrome.runtime | null {
|
||||
const api = getBrowserAPI();
|
||||
return api?.runtime || null;
|
||||
}
|
||||
|
||||
// Get tabs API with cross-browser support
|
||||
function getTabsAPI(): typeof chrome.tabs | null {
|
||||
const api = getBrowserAPI();
|
||||
return api?.tabs || null;
|
||||
}
|
||||
|
||||
// Get action API with cross-browser support
|
||||
function getActionAPI(): typeof chrome.action | null {
|
||||
const api = getBrowserAPI();
|
||||
// @ts-ignore - Some browsers use browserAction instead of action
|
||||
return api?.action || api?.browserAction || null;
|
||||
}
|
||||
|
||||
// Check if the URL is a restricted page where content scripts can't run
|
||||
function isRestrictedUrl(url: string | undefined): boolean {
|
||||
if (!url) return true;
|
||||
|
||||
// List of restricted URL patterns
|
||||
// List of restricted URL patterns for all supported browsers
|
||||
const restrictedPatterns = [
|
||||
/^chrome:\/\//i, // Chrome internal pages (newtab, settings, extensions, etc.)
|
||||
/^chrome-extension:\/\//i, // Other extension pages
|
||||
/^chrome-extension:\/\//i, // Chrome extension pages
|
||||
/^edge:\/\//i, // Edge internal pages
|
||||
/^about:/i, // about:blank, about:newtab, etc.
|
||||
/^brave:\/\//i, // Brave internal pages
|
||||
@@ -29,6 +152,9 @@ function isRestrictedUrl(url: string | undefined): boolean {
|
||||
/^devtools:\/\//i, // DevTools pages
|
||||
/^data:/i, // Data URLs
|
||||
/^blob:/i, // Blob URLs
|
||||
/^moz-extension:\/\//i, // Firefox extension pages
|
||||
/^safari-extension:\/\//i, // Safari extension pages
|
||||
/^safari-web-extension:\/\//i, // Safari web extension pages
|
||||
];
|
||||
|
||||
return restrictedPatterns.some((pattern) => pattern.test(url));
|
||||
@@ -36,160 +162,217 @@ function isRestrictedUrl(url: string | undefined): boolean {
|
||||
|
||||
// Open cal.com/app (Framer marketing page) in a new tab with auto-open parameter
|
||||
function openAppPage(): void {
|
||||
chrome.tabs.create({ url: "https://cal.com/app?openExtension=true" });
|
||||
const tabsAPI = getTabsAPI();
|
||||
if (tabsAPI) {
|
||||
tabsAPI.create({ url: "https://cal.com/app?openExtension=true" });
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore - WXT provides this globally
|
||||
export default defineBackground(() => {
|
||||
const browserType = detectBrowser();
|
||||
const browserName = getBrowserDisplayName();
|
||||
|
||||
if (IS_DEV_MODE) {
|
||||
devLog.log("DEV MODE - API Key authentication enabled for testing");
|
||||
devLog.log(`Browser: ${browserName}, Target: ${BROWSER_TARGET}`);
|
||||
}
|
||||
|
||||
chrome.action.onClicked.addListener((tab) => {
|
||||
// Check if this is a restricted URL where content scripts can't run
|
||||
if (isRestrictedUrl(tab.url)) {
|
||||
devLog.log("Restricted URL detected, opening app page:", tab.url);
|
||||
openAppPage();
|
||||
return;
|
||||
}
|
||||
const actionAPI = getActionAPI();
|
||||
const runtimeAPI = getRuntimeAPI();
|
||||
const tabsAPI = getTabsAPI();
|
||||
const storageAPI = getStorageAPI();
|
||||
|
||||
if (tab.id) {
|
||||
chrome.tabs.sendMessage(tab.id, { action: "icon-clicked" }, () => {
|
||||
// Ignore errors - expected on pages where content script hasn't loaded yet
|
||||
// The restricted URL check above handles pages where content scripts can't run
|
||||
void chrome.runtime.lastError;
|
||||
});
|
||||
}
|
||||
});
|
||||
if (actionAPI) {
|
||||
actionAPI.onClicked.addListener((tab) => {
|
||||
// Check if this is a restricted URL where content scripts can't run
|
||||
if (isRestrictedUrl(tab.url)) {
|
||||
devLog.log("Restricted URL detected, opening app page:", tab.url);
|
||||
openAppPage();
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === "fetch-event-types") {
|
||||
fetchEventTypes()
|
||||
.then((eventTypes) => sendResponse({ data: eventTypes }))
|
||||
.catch((error) => sendResponse({ error: error.message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === "start-extension-oauth") {
|
||||
const authUrl = message.authUrl as string;
|
||||
const state = new URL(authUrl).searchParams.get("state");
|
||||
|
||||
if (state) {
|
||||
chrome.storage.local.set({ oauth_state: state }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
devLog.warn("Failed to store OAuth state:", chrome.runtime.lastError.message);
|
||||
}
|
||||
if (tab.id && tabsAPI) {
|
||||
tabsAPI.sendMessage(tab.id, { action: "icon-clicked" }, () => {
|
||||
// Ignore errors - expected on pages where content script hasn't loaded yet
|
||||
// The restricted URL check above handles pages where content scripts can't run
|
||||
const runtime = getRuntimeAPI();
|
||||
if (runtime) void runtime.lastError;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleExtensionOAuth(authUrl)
|
||||
.then((responseUrl) => sendResponse({ success: true, responseUrl }))
|
||||
.catch((error) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === "exchange-oauth-tokens") {
|
||||
handleTokenExchange(message.tokenRequest, message.tokenEndpoint, message.state)
|
||||
.then((tokens) => sendResponse({ success: true, tokens }))
|
||||
.catch((error) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === "sync-oauth-tokens") {
|
||||
const tokens = message.tokens as OAuthTokens | null;
|
||||
|
||||
if (isRateLimited()) {
|
||||
devLog.warn("Token sync rate limited");
|
||||
sendResponse({ success: false, error: "Rate limited. Please try again later." });
|
||||
if (runtimeAPI) {
|
||||
runtimeAPI.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === "fetch-event-types") {
|
||||
fetchEventTypes()
|
||||
.then((eventTypes) => sendResponse({ data: eventTypes }))
|
||||
.catch((error) => sendResponse({ error: error.message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!tokens || !tokens.accessToken) {
|
||||
sendResponse({ success: false, error: "No valid tokens provided" });
|
||||
if (message.action === "start-extension-oauth") {
|
||||
const authUrl = message.authUrl as string;
|
||||
const state = new URL(authUrl).searchParams.get("state");
|
||||
|
||||
if (state && storageAPI?.local) {
|
||||
storageAPI.local.set({ oauth_state: state }, () => {
|
||||
const runtime = getRuntimeAPI();
|
||||
if (runtime?.lastError) {
|
||||
devLog.warn("Failed to store OAuth state:", runtime.lastError.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleExtensionOAuth(authUrl)
|
||||
.then((responseUrl) => sendResponse({ success: true, responseUrl }))
|
||||
.catch((error) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
validateTokens(tokens)
|
||||
.then((isValid) => {
|
||||
if (!isValid) {
|
||||
devLog.warn("Token sync rejected: invalid tokens");
|
||||
sendResponse({ success: false, error: "Invalid tokens" });
|
||||
return;
|
||||
}
|
||||
if (message.action === "exchange-oauth-tokens") {
|
||||
handleTokenExchange(message.tokenRequest, message.tokenEndpoint, message.state)
|
||||
.then((tokens) => sendResponse({ success: true, tokens }))
|
||||
.catch((error) => sendResponse({ success: false, error: error.message }));
|
||||
return true;
|
||||
}
|
||||
|
||||
recordTokenOperation();
|
||||
chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
devLog.error("Failed to sync OAuth tokens:", chrome.runtime.lastError.message);
|
||||
sendResponse({ success: false, error: chrome.runtime.lastError.message });
|
||||
if (message.action === "sync-oauth-tokens") {
|
||||
const tokens = message.tokens as OAuthTokens | null;
|
||||
|
||||
if (isRateLimited()) {
|
||||
devLog.warn("Token sync rate limited");
|
||||
sendResponse({ success: false, error: "Rate limited. Please try again later." });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!tokens || !tokens.accessToken) {
|
||||
sendResponse({ success: false, error: "No valid tokens provided" });
|
||||
return true;
|
||||
}
|
||||
|
||||
validateTokens(tokens)
|
||||
.then((isValid) => {
|
||||
if (!isValid) {
|
||||
devLog.warn("Token sync rejected: invalid tokens");
|
||||
sendResponse({ success: false, error: "Invalid tokens" });
|
||||
return;
|
||||
}
|
||||
|
||||
recordTokenOperation();
|
||||
if (storageAPI?.local) {
|
||||
storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) }, () => {
|
||||
const runtime = getRuntimeAPI();
|
||||
if (runtime?.lastError) {
|
||||
devLog.error("Failed to sync OAuth tokens:", runtime.lastError.message);
|
||||
sendResponse({ success: false, error: runtime.lastError.message });
|
||||
} else {
|
||||
devLog.log("OAuth tokens synced to storage (validated)");
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
devLog.error("Token validation failed:", error);
|
||||
sendResponse({ success: false, error: "Token validation failed" });
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === "clear-oauth-tokens") {
|
||||
if (isRateLimited()) {
|
||||
devLog.warn("Token clear rate limited");
|
||||
sendResponse({ success: false, error: "Rate limited. Please try again later." });
|
||||
return true;
|
||||
}
|
||||
|
||||
recordTokenOperation();
|
||||
if (storageAPI?.local) {
|
||||
storageAPI.local.remove(["cal_oauth_tokens", "oauth_state"], () => {
|
||||
const runtime = getRuntimeAPI();
|
||||
if (runtime?.lastError) {
|
||||
devLog.error("Failed to clear OAuth tokens:", runtime.lastError.message);
|
||||
sendResponse({ success: false, error: runtime.lastError.message });
|
||||
} else {
|
||||
devLog.log("OAuth tokens synced to chrome.storage.local (validated)");
|
||||
devLog.log("OAuth tokens cleared from storage");
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
devLog.error("Token validation failed:", error);
|
||||
sendResponse({ success: false, error: "Token validation failed" });
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.action === "clear-oauth-tokens") {
|
||||
if (isRateLimited()) {
|
||||
devLog.warn("Token clear rate limited");
|
||||
sendResponse({ success: false, error: "Rate limited. Please try again later." });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
recordTokenOperation();
|
||||
chrome.storage.local.remove(["cal_oauth_tokens", "oauth_state"], () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
devLog.error("Failed to clear OAuth tokens:", chrome.runtime.lastError.message);
|
||||
sendResponse({ success: false, error: chrome.runtime.lastError.message });
|
||||
} else {
|
||||
devLog.log("OAuth tokens cleared from chrome.storage.local");
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function handleExtensionOAuth(authUrl: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!chrome.identity) {
|
||||
reject(new Error("Chrome identity API not available"));
|
||||
return;
|
||||
const identityAPI = getIdentityAPI();
|
||||
const browserType = detectBrowser();
|
||||
|
||||
if (!identityAPI) {
|
||||
throw new Error(`Identity API not available in ${getBrowserDisplayName()}`);
|
||||
}
|
||||
|
||||
if (IS_DEV_MODE && identityAPI.getRedirectURL) {
|
||||
const expectedRedirectUrl = identityAPI.getRedirectURL();
|
||||
const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
|
||||
|
||||
devLog.log("Starting OAuth flow");
|
||||
devLog.log("Browser:", getBrowserDisplayName());
|
||||
devLog.log("Expected redirect URL:", expectedRedirectUrl);
|
||||
devLog.log("Auth URL redirect_uri:", redirectUri);
|
||||
|
||||
if (redirectUri && !redirectUri.startsWith(expectedRedirectUrl.replace(/\/$/, ""))) {
|
||||
devLog.warn(
|
||||
"MISMATCH! redirect_uri does not match expected URL.",
|
||||
"\nExpected:",
|
||||
expectedRedirectUrl,
|
||||
"\nGot:",
|
||||
redirectUri
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_DEV_MODE) {
|
||||
const expectedRedirectUrl = chrome.identity.getRedirectURL();
|
||||
const redirectUri = new URL(authUrl).searchParams.get("redirect_uri");
|
||||
return new Promise((resolve, reject) => {
|
||||
// Firefox and Safari use Promise-based API
|
||||
if (browserType === BrowserType.Firefox || browserType === BrowserType.Safari) {
|
||||
try {
|
||||
// @ts-ignore - Firefox/Safari return Promises
|
||||
const result = identityAPI.launchWebAuthFlow({ url: authUrl, interactive: true });
|
||||
|
||||
devLog.log("Starting OAuth flow");
|
||||
devLog.log("Expected redirect URL:", expectedRedirectUrl);
|
||||
devLog.log("Auth URL redirect_uri:", redirectUri);
|
||||
|
||||
if (redirectUri && !redirectUri.startsWith(expectedRedirectUrl.replace(/\/$/, ""))) {
|
||||
devLog.warn(
|
||||
"MISMATCH! redirect_uri does not match Chrome's expected URL.",
|
||||
"\nExpected:",
|
||||
expectedRedirectUrl,
|
||||
"\nGot:",
|
||||
redirectUri
|
||||
);
|
||||
if (result && typeof result.then === "function") {
|
||||
result
|
||||
.then((responseUrl: string | undefined) => {
|
||||
if (responseUrl) {
|
||||
devLog.log("OAuth successful");
|
||||
resolve(responseUrl);
|
||||
} else {
|
||||
devLog.error("OAuth flow returned no response URL");
|
||||
reject(new Error("OAuth flow cancelled or failed"));
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
devLog.error("OAuth flow failed:", error.message);
|
||||
reject(new Error(`OAuth flow failed: ${error.message}`));
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to callback-based API
|
||||
}
|
||||
}
|
||||
|
||||
chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
devLog.error("OAuth flow failed:", chrome.runtime.lastError.message);
|
||||
reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`));
|
||||
// Chrome/Edge use callback-based API
|
||||
identityAPI.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
|
||||
const runtimeAPI = getRuntimeAPI();
|
||||
if (runtimeAPI?.lastError) {
|
||||
devLog.error("OAuth flow failed:", runtimeAPI.lastError.message);
|
||||
reject(new Error(`OAuth flow failed: ${runtimeAPI.lastError.message}`));
|
||||
} else if (responseUrl) {
|
||||
devLog.log("OAuth successful");
|
||||
resolve(responseUrl);
|
||||
@@ -238,8 +421,11 @@ async function handleTokenExchange(
|
||||
};
|
||||
|
||||
try {
|
||||
await chrome.storage.local.set({ cal_oauth_tokens: JSON.stringify(tokens) });
|
||||
devLog.log("OAuth tokens stored in chrome.storage.local");
|
||||
const storageAPI = getStorageAPI();
|
||||
if (storageAPI?.local) {
|
||||
await storageAPI.local.set({ cal_oauth_tokens: JSON.stringify(tokens) });
|
||||
devLog.log("OAuth tokens stored in storage");
|
||||
}
|
||||
} catch (storageError) {
|
||||
devLog.error("Failed to store OAuth tokens:", storageError);
|
||||
}
|
||||
@@ -248,18 +434,24 @@ async function handleTokenExchange(
|
||||
}
|
||||
|
||||
async function validateOAuthState(state: string): Promise<void> {
|
||||
const storageAPI = getStorageAPI();
|
||||
if (!storageAPI?.local) {
|
||||
devLog.warn("Storage API not available for state validation");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await chrome.storage.local.get(["oauth_state"]);
|
||||
const result = await storageAPI.local.get(["oauth_state"]);
|
||||
const storedState = result.oauth_state as string | undefined;
|
||||
|
||||
if (storedState && storedState !== state) {
|
||||
await chrome.storage.local.remove("oauth_state");
|
||||
await storageAPI.local.remove("oauth_state");
|
||||
devLog.error("State parameter mismatch - possible CSRF attack");
|
||||
throw new Error("Invalid state parameter - possible CSRF attack");
|
||||
}
|
||||
|
||||
if (storedState === state) {
|
||||
await chrome.storage.local.remove("oauth_state");
|
||||
await storageAPI.local.remove("oauth_state");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("Invalid state parameter")) {
|
||||
@@ -318,13 +510,17 @@ async function validateTokens(tokens: OAuthTokens): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function getAuthHeader(): Promise<string> {
|
||||
const result = await chrome.storage.local.get(["cal_oauth_tokens"]);
|
||||
const oauthTokens = result.cal_oauth_tokens
|
||||
? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens)
|
||||
: null;
|
||||
const storageAPI = getStorageAPI();
|
||||
|
||||
if (oauthTokens?.accessToken) {
|
||||
return `Bearer ${oauthTokens.accessToken}`;
|
||||
if (storageAPI?.local) {
|
||||
const result = await storageAPI.local.get(["cal_oauth_tokens"]);
|
||||
const oauthTokens = result.cal_oauth_tokens
|
||||
? (JSON.parse(result.cal_oauth_tokens as string) as OAuthTokens)
|
||||
: null;
|
||||
|
||||
if (oauthTokens?.accessToken) {
|
||||
return `Bearer ${oauthTokens.accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_DEV_MODE && DEV_API_KEY) {
|
||||
|
||||
@@ -47,13 +47,14 @@ export default defineContentScript({
|
||||
sidebarContainer.style.transform = "translateX(100%)";
|
||||
sidebarContainer.style.display = "none";
|
||||
|
||||
// Create iframe container with max width
|
||||
// Create iframe container - positioned to right side
|
||||
const iframeContainer = document.createElement("div");
|
||||
iframeContainer.style.width = "100%";
|
||||
iframeContainer.style.height = "100%";
|
||||
iframeContainer.style.display = "flex";
|
||||
iframeContainer.style.justifyContent = "flex-end";
|
||||
iframeContainer.style.pointerEvents = "none";
|
||||
iframeContainer.style.position = "absolute";
|
||||
iframeContainer.style.top = "0";
|
||||
iframeContainer.style.right = "0";
|
||||
iframeContainer.style.bottom = "0";
|
||||
iframeContainer.style.width = "400px";
|
||||
iframeContainer.style.overflow = "hidden";
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
// URL is determined at build time:
|
||||
@@ -62,13 +63,18 @@ export default defineContentScript({
|
||||
const COMPANION_URL =
|
||||
(import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL as string) || "https://companion.cal.com";
|
||||
iframe.src = COMPANION_URL;
|
||||
iframe.style.width = "400px";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.borderRadius = "0";
|
||||
iframe.style.backgroundColor = "transparent";
|
||||
iframe.style.pointerEvents = "auto";
|
||||
iframe.style.transition = "none";
|
||||
// Use explicit dimensions - Brave has issues with percentage-based sizing
|
||||
iframe.style.cssText = `
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 400px !important;
|
||||
height: 100vh !important;
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: auto !important;
|
||||
display: block !important;
|
||||
`;
|
||||
|
||||
iframeContainer.appendChild(iframe);
|
||||
|
||||
@@ -108,15 +114,17 @@ export default defineContentScript({
|
||||
if (event.data.type === "cal-companion-expand") {
|
||||
// Disable transition for instant expansion
|
||||
iframe.style.transition = "none";
|
||||
iframe.style.width = "100%";
|
||||
iframeContainer.style.pointerEvents = "auto";
|
||||
iframeContainer.style.justifyContent = "center";
|
||||
iframe.style.width = "100vw";
|
||||
iframeContainer.style.width = "100%";
|
||||
iframeContainer.style.left = "0";
|
||||
iframeContainer.style.right = "0";
|
||||
} else if (event.data.type === "cal-companion-collapse") {
|
||||
// Disable transition for instant collapse
|
||||
iframe.style.transition = "none";
|
||||
iframe.style.width = "400px";
|
||||
iframeContainer.style.pointerEvents = "none";
|
||||
iframeContainer.style.justifyContent = "flex-end";
|
||||
iframeContainer.style.width = "400px";
|
||||
iframeContainer.style.left = "auto";
|
||||
iframeContainer.style.right = "0";
|
||||
} else if (event.data.type === "cal-extension-oauth-request") {
|
||||
// Handle OAuth request from iframe
|
||||
handleOAuthRequest(event.data.authUrl, iframe.contentWindow);
|
||||
@@ -521,6 +529,7 @@ export default defineContentScript({
|
||||
|
||||
// Listen for extension icon click
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
// Skip debug-log messages to avoid infinite loop
|
||||
if (message.action === "icon-clicked") {
|
||||
if (isClosed) {
|
||||
openSidebar();
|
||||
|
||||
Vendored
+17
@@ -3,8 +3,25 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly EXPO_PUBLIC_CAL_API_KEY?: string;
|
||||
readonly EXPO_PUBLIC_COMPANION_DEV_URL?: string;
|
||||
|
||||
// Default OAuth config (Chrome/Brave)
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID?: string;
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI?: string;
|
||||
|
||||
// Firefox-specific OAuth config
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX?: string;
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX?: string;
|
||||
|
||||
// Safari-specific OAuth config
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI?: string;
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI?: string;
|
||||
|
||||
// Edge-specific OAuth config
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE?: string;
|
||||
readonly EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE?: string;
|
||||
|
||||
// Browser target set during build
|
||||
readonly BROWSER_TARGET?: "chrome" | "firefox" | "safari" | "edge";
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
+20
-6
@@ -1,22 +1,36 @@
|
||||
{
|
||||
"name": "cal-companion",
|
||||
"displayName": "Cal.com Companion",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.4",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"ext": "wxt",
|
||||
"ext:build": "wxt build",
|
||||
"ext:build-prod": "BUILD_FOR_STORE=true wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"build:firefox-prod": "BUILD_FOR_STORE=true wxt build -b firefox",
|
||||
"build:safari": "wxt build -b safari",
|
||||
"build:safari-prod": "BUILD_FOR_STORE=true wxt build -b safari",
|
||||
"ext": "wxt",
|
||||
"ext:zip": "wxt zip",
|
||||
"ext:zip-prod": "BUILD_FOR_STORE=true wxt zip",
|
||||
"ext:build-chrome": "BROWSER_TARGET=chrome wxt build -b chrome",
|
||||
"ext:build-chrome-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=chrome wxt build -b chrome",
|
||||
"ext:zip-chrome": "BROWSER_TARGET=chrome wxt zip -b chrome",
|
||||
"ext:zip-chrome-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=chrome wxt zip -b chrome",
|
||||
"ext:build-firefox": "BROWSER_TARGET=firefox wxt build -b firefox",
|
||||
"ext:build-firefox-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=firefox wxt build -b firefox",
|
||||
"ext:zip-firefox": "BROWSER_TARGET=firefox wxt zip -b firefox",
|
||||
"ext:zip-firefox-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=firefox wxt zip -b firefox",
|
||||
"ext:build-safari": "BROWSER_TARGET=safari wxt build -b safari",
|
||||
"ext:build-safari-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=safari wxt build -b safari",
|
||||
"ext:zip-safari": "BROWSER_TARGET=safari wxt zip -b safari",
|
||||
"ext:zip-safari-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=safari wxt zip -b safari",
|
||||
"ext:build-edge": "BROWSER_TARGET=edge wxt build -b edge",
|
||||
"ext:build-edge-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=edge wxt build -b edge",
|
||||
"ext:zip-edge": "BROWSER_TARGET=edge wxt zip -b edge",
|
||||
"ext:zip-edge-prod": "BUILD_FOR_STORE=true BROWSER_TARGET=edge wxt zip -b edge",
|
||||
"ext:build-all": "npm run ext:build && npm run ext:build-firefox && npm run ext:build-safari && npm run ext:build-edge",
|
||||
"ext:build-all-prod": "npm run ext:build-prod && npm run ext:build-firefox-prod && npm run ext:build-safari-prod && npm run ext:build-edge-prod",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
|
||||
"prepare": "cd .. && husky companion/.husky"
|
||||
|
||||
@@ -238,6 +238,7 @@ export class CalComOAuthService {
|
||||
|
||||
private async launchExtensionAuthFlow(authUrl: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Try Chrome/Chromium identity API first
|
||||
if (typeof chrome !== "undefined" && chrome.identity) {
|
||||
chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true }, (responseUrl) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
@@ -251,6 +252,29 @@ export class CalComOAuthService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try Firefox/Safari browser.identity API (Promise-based)
|
||||
// @ts-ignore - Firefox/Safari use browser namespace
|
||||
if (typeof browser !== "undefined" && browser.identity) {
|
||||
try {
|
||||
// @ts-ignore - Firefox/Safari browser.identity returns Promise
|
||||
browser.identity
|
||||
.launchWebAuthFlow({ url: authUrl, interactive: true })
|
||||
.then((responseUrl: string | undefined) => {
|
||||
if (responseUrl) {
|
||||
resolve(responseUrl);
|
||||
} else {
|
||||
reject(new Error("OAuth cancelled"));
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
reject(new Error(`OAuth flow failed: ${error.message}`));
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to iframe-based flow
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isRunningInIframe()) {
|
||||
this.requestOAuthViaPostMessage(authUrl, resolve, reject);
|
||||
return;
|
||||
@@ -511,10 +535,108 @@ export class CalComOAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser type for OAuth configuration selection.
|
||||
* Used to determine which OAuth client credentials to use.
|
||||
*/
|
||||
type BrowserType = "chrome" | "firefox" | "safari" | "edge" | "brave" | "unknown";
|
||||
|
||||
/**
|
||||
* Detects the current browser type for OAuth configuration.
|
||||
* This is used in web/extension context to select the appropriate OAuth credentials.
|
||||
*/
|
||||
function detectBrowserType(): BrowserType {
|
||||
if (Platform.OS !== "web" || typeof navigator === "undefined") {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
|
||||
// Check for Brave first (it identifies as Chrome but has Brave-specific properties)
|
||||
// @ts-ignore - Brave adds this to navigator
|
||||
if (navigator.brave && typeof navigator.brave.isBrave === "function") {
|
||||
return "brave";
|
||||
}
|
||||
|
||||
// Check for Edge (Chromium-based Edge includes "Edg/" in user agent)
|
||||
if (userAgent.includes("edg/")) {
|
||||
return "edge";
|
||||
}
|
||||
|
||||
// Check for Firefox
|
||||
if (userAgent.includes("firefox")) {
|
||||
return "firefox";
|
||||
}
|
||||
|
||||
// Check for Safari (must check after Chrome since Chrome also includes "safari")
|
||||
if (
|
||||
userAgent.includes("safari") &&
|
||||
!userAgent.includes("chrome") &&
|
||||
!userAgent.includes("chromium")
|
||||
) {
|
||||
return "safari";
|
||||
}
|
||||
|
||||
// Check for Chrome (or other Chromium-based browsers)
|
||||
if (userAgent.includes("chrome") || userAgent.includes("chromium")) {
|
||||
return "chrome";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets browser-specific OAuth configuration.
|
||||
* Falls back to default (Chrome) config if browser-specific config is not available.
|
||||
*/
|
||||
function getBrowserSpecificOAuthConfig(): { clientId: string; redirectUri: string } {
|
||||
// Default values (Chrome/Brave)
|
||||
const defaultClientId = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "";
|
||||
const defaultRedirectUri = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "";
|
||||
|
||||
// For mobile apps, always use default config
|
||||
if (Platform.OS !== "web") {
|
||||
return { clientId: defaultClientId, redirectUri: defaultRedirectUri };
|
||||
}
|
||||
|
||||
const browserType = detectBrowserType();
|
||||
|
||||
switch (browserType) {
|
||||
case "firefox":
|
||||
return {
|
||||
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId,
|
||||
redirectUri:
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || defaultRedirectUri,
|
||||
};
|
||||
|
||||
case "safari":
|
||||
return {
|
||||
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI || defaultClientId,
|
||||
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI || defaultRedirectUri,
|
||||
};
|
||||
|
||||
case "edge":
|
||||
return {
|
||||
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE || defaultClientId,
|
||||
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE || defaultRedirectUri,
|
||||
};
|
||||
|
||||
case "chrome":
|
||||
case "brave":
|
||||
case "unknown":
|
||||
default:
|
||||
// Chrome, Brave, and unknown browsers use the default configuration
|
||||
return { clientId: defaultClientId, redirectUri: defaultRedirectUri };
|
||||
}
|
||||
}
|
||||
|
||||
export function createCalComOAuthService(overrides: Partial<OAuthConfig> = {}): CalComOAuthService {
|
||||
// Get browser-specific OAuth config
|
||||
const browserConfig = getBrowserSpecificOAuthConfig();
|
||||
|
||||
const config: OAuthConfig = {
|
||||
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "",
|
||||
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "",
|
||||
clientId: browserConfig.clientId,
|
||||
redirectUri: browserConfig.redirectUri,
|
||||
calcomBaseUrl: "https://app.cal.com",
|
||||
...overrides,
|
||||
};
|
||||
|
||||
+75
-1
@@ -4,6 +4,44 @@ import { defineConfig } from "wxt";
|
||||
// Forces production URL (https://companion.cal.com) and excludes localhost permissions
|
||||
const isBuildForStore = process.env.BUILD_FOR_STORE === "true";
|
||||
|
||||
// BROWSER_TARGET is set during build to determine which OAuth credentials to use
|
||||
// Values: "chrome" (default), "firefox", "safari", "edge"
|
||||
const browserTarget = process.env.BROWSER_TARGET || "chrome";
|
||||
|
||||
/**
|
||||
* Get browser-specific OAuth configuration.
|
||||
* Falls back to default (Chrome) config if browser-specific config is not set.
|
||||
*/
|
||||
function getOAuthConfig() {
|
||||
const defaultClientId = process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "";
|
||||
const defaultRedirectUri = process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI || "";
|
||||
|
||||
switch (browserTarget) {
|
||||
case "firefox":
|
||||
return {
|
||||
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX || defaultClientId,
|
||||
redirectUri:
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX || defaultRedirectUri,
|
||||
};
|
||||
case "safari":
|
||||
return {
|
||||
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI || defaultClientId,
|
||||
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI || defaultRedirectUri,
|
||||
};
|
||||
case "edge":
|
||||
return {
|
||||
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE || defaultClientId,
|
||||
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE || defaultRedirectUri,
|
||||
};
|
||||
case "chrome":
|
||||
default:
|
||||
return {
|
||||
clientId: defaultClientId,
|
||||
redirectUri: defaultRedirectUri,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
hooks: {
|
||||
"build:manifestGenerated": (_wxt, manifest) => {
|
||||
@@ -20,7 +58,7 @@ export default defineConfig({
|
||||
outDir: ".output",
|
||||
manifest: {
|
||||
name: "Cal.com Companion",
|
||||
version: "1.7.3",
|
||||
version: "1.7.4",
|
||||
description: "Your calendar companion for quick booking and scheduling",
|
||||
permissions: ["activeTab", "storage", "identity"],
|
||||
host_permissions: [
|
||||
@@ -55,12 +93,16 @@ export default defineConfig({
|
||||
const devUrl = isBuildForStore ? "" : process.env.EXPO_PUBLIC_COMPANION_DEV_URL || "";
|
||||
const isLocalDev = Boolean(devUrl && devUrl.includes("localhost"));
|
||||
|
||||
// Get OAuth config for the target browser
|
||||
const oauthConfig = getOAuthConfig();
|
||||
|
||||
// Log build mode for clarity
|
||||
if (isBuildForStore) {
|
||||
console.log("\n🏪 STORE BUILD: Using https://companion.cal.com\n");
|
||||
} else if (isLocalDev) {
|
||||
console.log(`\n🔧 DEV BUILD: Using ${devUrl}\n`);
|
||||
}
|
||||
console.log(`🌐 Browser Target: ${browserTarget}\n`);
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
@@ -71,12 +113,44 @@ export default defineConfig({
|
||||
define: {
|
||||
global: "globalThis",
|
||||
__DEV__: JSON.stringify(false),
|
||||
|
||||
// Browser target for runtime detection
|
||||
"import.meta.env.BROWSER_TARGET": JSON.stringify(browserTarget),
|
||||
|
||||
// Default OAuth config (Chrome/Brave) - always available for fallback
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID
|
||||
),
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI
|
||||
),
|
||||
|
||||
// Browser-specific OAuth config (resolved based on BROWSER_TARGET)
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX
|
||||
),
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX
|
||||
),
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI
|
||||
),
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI
|
||||
),
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE
|
||||
),
|
||||
"import.meta.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE": JSON.stringify(
|
||||
process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE
|
||||
),
|
||||
|
||||
// Pre-resolved OAuth config for the build target (for convenience)
|
||||
"process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID": JSON.stringify(oauthConfig.clientId),
|
||||
"process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI": JSON.stringify(
|
||||
oauthConfig.redirectUri
|
||||
),
|
||||
|
||||
// Use devUrl which respects BUILD_FOR_STORE flag
|
||||
"import.meta.env.EXPO_PUBLIC_COMPANION_DEV_URL": JSON.stringify(devUrl),
|
||||
"import.meta.env.EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES": JSON.stringify(
|
||||
|
||||
Reference in New Issue
Block a user