feat: companion expo and chrome (#25022)

* feat: companion expo and chrome

* fix: api-v2-fetch-bookings (#25023)

* migrated to app expo router

* feat(companion): added availability screen and fix event type fetch (#25025)

* fix(companion): add host check in bookings (#25036)

* using liquid glass bottom nav experimental

* added action sheet

* added icons to event type tabs

* added action bar for event-type details

* long press for edit event types

* added limits

* added more limits

* added more limits

* added username to event types

* feat(companion): config to set and show all the available meeting options a user has stalled (#25081)

* feat: Add tailwind support using nativewind. (#25028)

* init nativewind

* replace styles with tailwind classes

* changes

* feat(companion): all locations options, icons, and apply nativewind to remaining files (#25143)

* feat: all locations options and icons and apply nativwind to reamaining files

* icon bug fix

* cal video icon

* other location frontend

* other location api

* address cubics comment about correctly detects SVGs

* feat(companion): edit availability (#25149)

* feat: companion-edit-availability

* date override and use correct config for patch api endpoint

* Android Alert supports a maximum of 3 buttons

* feat: companion-ui-release-1 (#25150)

* feat(companion): Add event type & schedule creation flows with API expansion (#25187)

* feat(companion): event schedule creation and apis

* profile section

* limits, advanced, recurring tab

* Clean Folder Structure

* addressed cubics comments

* fix cubiics comments

* replaced text with cal.com svg logo

* added correct more button for event-types

* added copy link button

* added extension popup

* added chrome extension sidebar

* achieved liquid glass buttons

* nit

* more progress

* fixed ios bug, added icon

* added cal icon to gmail

* nit

* nit

* added dialog for one-off meeting

* added one-time dialog

* feat(companion): gmail plugin (#25327)

* Update Cal.com Companion to version 1.7.0 with enhanced background and content scripts. The background script now handles fetching event types from the Cal.com API and improved error handling. The content script has been updated to manage sidebar visibility and respond to messages more effectively. Removed deprecated dev background script and updated manifest permissions for better functionality.

* chore: update companion .gitignore to exclude build outputs and env files

* feat: insert Cal.com event link directly at cursor position in Gmail compose

- Add insertTextAtCursor function to insert booking link where user is typing
- Automatically positions cursor after inserted link for seamless typing
- Fallback to clipboard copy if compose field not found
- Improved UX: no need to manually paste the link

* feat(companion): improve notification UI and add event types caching

- Update notification to Cal.com brand style (black bg, check icon, smooth fade)
- Change notification text to 'Link inserted' for subtlety
- Move notification to bottom-right position (matching Cal.com toasts)
- Add event types caching (5min duration) to reduce API calls
- Cache automatically refreshes on page reload
- Fix TypeScript error with composeBody.focus() type assertion

* Notification toast  border

* conditional description

* conditional description

* feat(companion): Enhance UI with action buttons, tooltips, and Gmail extension improvements (#25353)

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat: companion-and-gmail-ui-upgrade

* feat(companion): one-off links (#25355)

* added one off prototype

* resolve merge conflicts

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>

* fixed sidebar iframe

* fix: enable pointer events on sidebar while maintaining click-through on transparent area

- Added pointerEvents: 'auto' to sidebar View component in _layout.tsx
- Changed iframe width from 100% to 400px to match sidebar width
- Changed iframe pointerEvents from 'none' to 'auto' to enable clicking
- This allows sidebar items to be clickable while transparent area remains click-through

* feat(companion): center all modals on full screen in browser extension (#25380)

* fix: companion-full-width-iframe

* center the popups on extension view

* feat(companion): center all modals on full screen in browser extension

- Created FullScreenModal component that expands iframe to full width
- Updated content script to handle instant iframe expansion/collapse
- Converted 17 modals across 6 files to use FullScreenModal
- All modals now appear centered on full browser viewport
- Removed animations for instant modal appearance
- Mobile app behavior remains unchanged

Files updated:
- companion/components/FullScreenModal.tsx (new)
- companion/extension/entrypoints/content.ts
- companion/app/(tabs)/event-types.tsx (5 modals)
- companion/app/(tabs)/bookings.tsx (2 modals)
- companion/app/(tabs)/availability.tsx (3 modals)
- companion/app/booking-detail.tsx (2 modals)
- companion/app/availability-detail.tsx (4 modals)
- companion/components/Header.tsx (1 modal)

* security(companion): validate postMessage origin in content script

Add origin and source validation to postMessage listener to prevent
malicious scripts on host pages from manipulating the companion iframe.

- Verify event.source matches iframe.contentWindow
- Verify event.origin matches iframe origin (localhost:8081)
- Reject all unauthorized messages before processing

This prevents external scripts from:
- Forcing full-screen overlays
- Intercepting pointer events
- Manipulating the companion UI

* fix(companion): center 'Add new event type' modal on full screen

Replace KeyboardAvoidingView with TouchableOpacity pattern in the
create event type modal to ensure it appears centered on the full
screen like all other modals.

This was the last remaining modal that wasn't properly centered.

* fix(companion): resolve modal transition timing issue

Fixed the 'Add new event type' modal not centering properly by:

- Added 100ms delay when transitioning between modals
- Prevents conflicting expand/collapse messages
- Removed redundant postMessage calls from modal handlers
- Added max-height constraint to modal content
- Cleaned up debug console logs

The issue was that when clicking 'New Event Type' from the 'New' menu,
both modals were transitioning simultaneously, causing the create modal
to collapse immediately. The delay ensures proper sequencing.

All 17 modals now work correctly and appear centered on full screen.

* added working date range

* removed all of one-off event code

* nit

* feat(companion): Suggest `Cal.com` links and Embed using Gemini Ai in Gmail (#25406)

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

* add companion build on CI

* feat: oauth companion (#25526)

* first implemenation of oauth

* fix logout for web

* revert app name

* remove isUsingOAuth

* clean up log out button

* remove logs

* clean up authcontext

* simplify code generation

* code clean up

* clean up oauthService.ts

* oauth browser extension

* add redirect uri to .env.example

* remove api key support

* clean up _layout file

* add identity

* fix + clean up

* improvements to oauth flow

* code clean up

* remove comments

* clean up comments

* add log out dialog

* add confirm modal

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

---------

Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Co-authored-by: Abhijeet Singh <asingh9829@gmail.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Peer Richelsen
2025-12-03 08:46:04 -03:00
committed by GitHub
co-authored by CarinaWolli Dhairyashil Shinde Abhijeet Singh Dhairyashil Volnei Munhoz Carina Wollendorfer
parent ba3838ec0f
commit 88ce7868e8
73 changed files with 32839 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Companion Builds
on:
workflow_call:
permissions:
contents: read
env:
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
jobs:
build:
name: Build Companion App & Extension
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: companion/package-lock.json
- name: Install dependencies
working-directory: companion
run: npm ci
- name: Build Expo web app
working-directory: companion
env:
EXPO_NO_TELEMETRY: 1
CI: true
run: npx expo export --platform web
- name: Build browser extension
working-directory: companion
run: npm run ext:build
+8
View File
@@ -146,6 +146,13 @@ jobs:
uses: ./.github/workflows/docs-build.yml
secrets: inherit
build-companion:
name: Companion builds
needs: [changes, check-label, deps]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/companion-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes, check-label, deps]
@@ -244,6 +251,7 @@ jobs:
build-api-v2,
build-atoms,
build-docs,
build-companion,
e2e,
e2e-api-v2,
e2e-embed,
+5
View File
@@ -0,0 +1,5 @@
# Cal.com OAuth Configuration
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID=your_oauth_client_id_here
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI=your_oauth_redirect_uri_here
+67
View File
@@ -0,0 +1,67 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
.yarn/
.pnp.*
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
*.log
# macOS
.DS_Store
*.pem
# local env files
.env*.local
.env
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
# WXT Chrome Extension build outputs
.output/
.wxt/
dev/
# Extension builds (generated at build time)
*.zip
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
coverage/
.nyc_output/
# Misc
*.code-workspace
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd companion
npx lint-staged
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
semi: true,
trailingComma: "es5",
singleQuote: false,
printWidth: 100,
tabWidth: 2,
useTabs: false,
arrowParens: "always",
plugins: ["prettier-plugin-tailwindcss"],
};
+32
View File
@@ -0,0 +1,32 @@
{
"expo": {
"name": "expo-wxt-app",
"slug": "expo-wxt-app",
"scheme": "expo-wxt-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"deploymentTarget": "18.0"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true
},
"web": {
"favicon": "./assets/favicon.png",
"bundler": "metro"
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Tabs } from "expo-router";
import { NativeTabs, Icon, Label } from "expo-router/unstable-native-tabs";
import { Platform } from "react-native";
export default function TabLayout() {
// Check if liquid glass effect is available
const supportsLiquidGlass = isLiquidGlassAvailable();
// Use NativeTabs for iOS 15+ (liquid glass support), fallback to standard Tabs
if (supportsLiquidGlass) {
return (
<NativeTabs>
<NativeTabs.Trigger name="event-types">
<Icon sf="link" />
<Label>Event Types</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="bookings">
<Icon sf="calendar" />
<Label>Bookings</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="availability">
<Icon sf="clock" />
<Label>Availability</Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="more">
<Icon sf="ellipsis" />
<Label>More</Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
// Fallback to standard Expo Router Tabs for older iOS and Android
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#000000",
tabBarInactiveTintColor: "#8E8E93",
tabBarStyle: {
backgroundColor: "#ffffff",
borderTopWidth: 0.5,
borderTopColor: "#C6C6C8",
paddingBottom: 4,
paddingTop: 4,
height: Platform.OS === "android" ? 74 : 66,
},
}}
>
<Tabs.Screen
name="event-types"
options={{
title: "Event Types",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "link" : "link-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="bookings"
options={{
title: "Bookings",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "calendar" : "calendar-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="availability"
options={{
title: "Availability",
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "time" : "time-outline"} size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="more"
options={{
title: "More",
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? "ellipsis-horizontal" : "ellipsis-horizontal-outline"}
size={24}
color={color}
/>
),
}}
/>
</Tabs>
);
}
+685
View File
@@ -0,0 +1,685 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter, useFocusEffect } from "expo-router";
import React, { useState, useEffect, useCallback } from "react";
import {
View,
Text,
FlatList,
ActivityIndicator,
RefreshControl,
TouchableOpacity,
ActionSheetIOS,
Alert,
Platform,
Modal,
TextInput,
KeyboardAvoidingView,
} from "react-native";
import { CalComAPIService, Schedule } from "../../services/calcom";
import { Header } from "../../components/Header";
import { FullScreenModal } from "../../components/FullScreenModal";
export default function Availability() {
const router = useRouter();
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [filteredSchedules, setFilteredSchedules] = useState<Schedule[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newScheduleName, setNewScheduleName] = useState("");
const [creating, setCreating] = useState(false);
const [showActionsModal, setShowActionsModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<Schedule | null>(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchSchedules = async () => {
try {
setError(null);
// Fetch all schedules
const allSchedules = await CalComAPIService.getSchedules();
// Sort schedules: default first, then by name
const sortedSchedules = allSchedules.sort((a, b) => {
// Default schedule first
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
// Then sort by name alphabetically
return a.name.localeCompare(b.name);
});
setSchedules(sortedSchedules);
setFilteredSchedules(sortedSchedules);
} catch (err) {
setError("Failed to load availability. Please check your API key and try again.");
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchSchedules();
}, []);
// Refresh schedules when screen comes into focus
useFocusEffect(
useCallback(() => {
// Only refresh if not currently loading (to avoid duplicate calls)
if (!loading && !refreshing) {
fetchSchedules();
}
}, [loading, refreshing])
);
const onRefresh = () => {
setRefreshing(true);
fetchSchedules();
};
const handleSearch = (query: string) => {
setSearchQuery(query);
if (query.trim() === "") {
setFilteredSchedules(schedules);
} else {
const filtered = schedules.filter((schedule) =>
schedule.name.toLowerCase().includes(query.toLowerCase())
);
setFilteredSchedules(filtered);
}
};
const handleScheduleLongPress = (schedule: Schedule) => {
if (Platform.OS !== "ios") {
// Fallback for non-iOS platforms (Android Alert supports max 3 buttons)
const options = [];
if (!schedule.isDefault) {
options.push({ text: "⭐ Set as default", onPress: () => handleSetAsDefault(schedule) });
}
options.push(
{ text: "📋 Duplicate", onPress: () => handleDuplicate(schedule) },
{ text: "🗑️ Delete", style: "destructive", onPress: () => handleDelete(schedule) }
);
// Android Alert automatically adds cancel, so we don't need to include it explicitly
Alert.alert(schedule.name, "", options);
return;
}
const options = ["Cancel"];
if (!schedule.isDefault) {
options.push("⭐ Set as default");
}
options.push("📋 Duplicate", "🗑️ Delete");
const destructiveButtonIndex = options.length - 1; // Delete button
const cancelButtonIndex = 0;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex,
cancelButtonIndex,
title: schedule.name,
},
(buttonIndex) => {
if (buttonIndex === cancelButtonIndex) {
return;
}
if (!schedule.isDefault) {
// Options: ["Cancel", "⭐ Set as default", "📋 Duplicate", "🗑️ Delete"]
if (buttonIndex === 1) {
handleSetAsDefault(schedule);
} else if (buttonIndex === 2) {
handleDuplicate(schedule);
} else if (buttonIndex === 3) {
handleDelete(schedule);
}
} else {
// Options: ["Cancel", "📋 Duplicate", "🗑️ Delete"]
if (buttonIndex === 1) {
handleDuplicate(schedule);
} else if (buttonIndex === 2) {
handleDelete(schedule);
}
}
}
);
};
const handleSetAsDefault = async (schedule: Schedule) => {
try {
await CalComAPIService.updateSchedule(schedule.id, { isDefault: true });
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
}
};
const handleDuplicate = async (schedule: Schedule) => {
try {
await CalComAPIService.duplicateSchedule(schedule.id);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to duplicate schedule. Please try again.");
}
};
const handleDelete = (schedule: Schedule) => {
if (Platform.OS === "web") {
// Use custom modal for web
setShowDeleteModal(true);
} else {
// Use native Alert for iOS/Android
Alert.alert("Delete Schedule", `Are you sure you want to delete "${schedule.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await CalComAPIService.deleteSchedule(schedule.id);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
}
},
},
]);
}
};
const confirmDelete = async () => {
if (!selectedSchedule) return;
try {
setDeleting(true);
await CalComAPIService.deleteSchedule(selectedSchedule.id);
setShowDeleteModal(false);
setSelectedSchedule(null);
await fetchSchedules();
} catch (err) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
setDeleting(false);
}
};
const handleSchedulePress = (schedule: Schedule) => {
router.push({
pathname: "/availability-detail",
params: { id: schedule.id.toString() },
});
};
const handleCreateNew = () => {
setNewScheduleName("");
setShowCreateModal(true);
};
const handleCreateSchedule = async () => {
if (!newScheduleName.trim()) {
Alert.alert("Error", "Please enter a schedule name");
return;
}
try {
setCreating(true);
// Get user's timezone (default to America/New_York if not available)
let userTimezone = "America/New_York";
try {
const userProfile = await CalComAPIService.getUserProfile();
if (userProfile.timeZone) {
userTimezone = userProfile.timeZone;
}
} catch (error) {
console.log("Could not get user timezone, using default");
}
// Create schedule with Monday-Friday 9 AM - 5 PM default
const newSchedule = await CalComAPIService.createSchedule({
name: newScheduleName.trim(),
timeZone: userTimezone,
isDefault: false,
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
});
setShowCreateModal(false);
setNewScheduleName("");
// Navigate to edit the newly created schedule
router.push({
pathname: "/availability-detail",
params: {
id: newSchedule.id.toString(),
},
});
// Refresh the list
fetchSchedules();
} catch (error) {
console.error("Failed to create schedule:", error);
Alert.alert("Error", "Failed to create schedule. Please try again.");
} finally {
setCreating(false);
}
};
const renderSchedule = ({ item: schedule, index }: { item: Schedule; index: number }) => {
return (
<TouchableOpacity
className="border-b border-[#E5E5EA] bg-white active:bg-[#F8F9FA]"
onPress={() => handleSchedulePress(schedule)}
onLongPress={() => handleScheduleLongPress(schedule)}
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<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 && (
<View className="ml-2 rounded bg-[#666] px-2 py-0.5">
<Text className="text-xs font-semibold text-white">Default</Text>
</View>
)}
</View>
{schedule.availability && schedule.availability.length > 0 ? (
<View>
{schedule.availability.map((slot, slotIndex) => (
<View
key={`${schedule.id}-${slot.days.join("-")}-${slotIndex}`}
className={slotIndex > 0 ? "mt-2" : ""}
>
<Text className="text-sm text-[#666]">
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
</Text>
</View>
))}
</View>
) : (
<Text className="text-sm text-[#666]">No availability set</Text>
)}
<View className="mt-2 flex-row items-center">
<Ionicons name="globe-outline" size={14} color="#666" />
<Text className="ml-1.5 text-sm text-[#666]">{schedule.timeZone}</Text>
</View>
</View>
{/* Three dots button - vertically centered on the right */}
<TouchableOpacity
className="items-center justify-center rounded-lg border border-[#E5E5EA]"
style={{ width: 32, height: 32 }}
onPress={(e) => {
e.stopPropagation();
setSelectedSchedule(schedule);
setShowActionsModal(true);
}}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</TouchableOpacity>
</View>
</TouchableOpacity>
);
};
if (loading) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-[#666]">Loading availability...</Text>
</View>
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<View className="flex-1 items-center justify-center p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-[#333]">
Unable to load availability
</Text>
<Text className="mb-6 text-center text-base text-[#666]">{error}</Text>
<TouchableOpacity className="rounded-lg bg-black px-6 py-3" onPress={fetchSchedules}>
<Text className="text-base font-semibold text-white">Retry</Text>
</TouchableOpacity>
</View>
</View>
);
}
if (schedules.length === 0 && !loading) {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="calendar-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No schedules found</Text>
<Text className="text-center text-base text-[#666]">
Create your availability schedule in Cal.com
</Text>
</View>
</View>
);
}
if (filteredSchedules.length === 0 && searchQuery.trim() !== "") {
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<View className="flex-1 items-center justify-center bg-gray-50 p-5">
<Ionicons name="search-outline" size={64} color="#666" />
<Text className="mb-2 mt-4 text-xl font-bold text-[#333]">No results found</Text>
<Text className="text-center text-base text-[#666]">
Try searching with different keywords
</Text>
</View>
</View>
);
}
return (
<View className="flex-1 bg-gray-100">
<Header />
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2">
<TextInput
className="flex-1 rounded-lg border border-gray-200 bg-white px-3 py-2 text-[17px] text-black focus:border-black focus:ring-2 focus:ring-black"
placeholder="Search schedules"
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2"
onPress={handleCreateNew}
>
<Ionicons name="add" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">New</Text>
</TouchableOpacity>
</View>
<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
data={filteredSchedules}
keyExtractor={(item) => item.id.toString()}
renderItem={renderSchedule}
contentContainerStyle={{ paddingBottom: 90 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
/>
</View>
</View>
{/* Create Schedule Modal */}
<FullScreenModal
visible={showCreateModal}
animationType="fade"
onRequestClose={() => setShowCreateModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowCreateModal(false)}
>
<TouchableOpacity
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={{
shadowColor: "#000",
shadowOffset: { width: 0, height: 20 },
shadowOpacity: 0.25,
shadowRadius: 25,
elevation: 24,
}}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<Text className="text-2xl font-semibold text-[#111827]">Add a new schedule</Text>
</View>
{/* Content */}
<View className="px-8 pb-6">
<View className="mb-1">
<Text className="mb-2 text-sm font-medium text-[#374151]">Name</Text>
<TextInput
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
placeholder="Working Hours"
placeholderTextColor="#9CA3AF"
value={newScheduleName}
onChangeText={setNewScheduleName}
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreateSchedule}
/>
</View>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowCreateModal(false);
setNewScheduleName("");
}}
disabled={creating}
>
<Text className="text-base font-medium text-[#374151]">Close</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${creating ? "opacity-60" : ""}`}
onPress={handleCreateSchedule}
disabled={creating}
>
<Text className="text-base font-medium text-white">Continue</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Schedule Actions Modal */}
<FullScreenModal
visible={showActionsModal}
animationType="fade"
onRequestClose={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-b border-gray-200 p-6">
<Text className="text-center text-xl font-semibold text-gray-900">
Schedule Actions
</Text>
</View>
{/* Actions List */}
<View className="p-2">
{/* Set as Default - only show if not already default */}
{selectedSchedule && !selectedSchedule.isDefault && (
<>
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
handleSetAsDefault(selectedSchedule);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="star-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Set as Default</Text>
</TouchableOpacity>
<View className="mx-4 my-2 h-px bg-gray-200" />
</>
)}
{/* Duplicate */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
setTimeout(() => {
handleDuplicate(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="copy-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Duplicate</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Delete */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
if (selectedSchedule) {
// Small delay to ensure modal closes before alert shows
setTimeout(() => {
handleDelete(selectedSchedule);
}, 100);
}
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="trash-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Delete</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowActionsModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Delete Confirmation Modal */}
<FullScreenModal
visible={showDeleteModal}
animationType="fade"
onRequestClose={() => !deleting && setShowDeleteModal(false)}
>
<View className="flex-1 items-center justify-center bg-black/50 p-4">
<View className="w-full max-w-sm rounded-2xl bg-white p-6">
{/* Icon */}
<View className="mb-4 items-center">
<View className="h-12 w-12 items-center justify-center rounded-full bg-red-100">
<Ionicons name="trash-outline" size={24} color="#EF4444" />
</View>
</View>
{/* Title */}
<Text className="mb-2 text-center text-xl font-semibold text-gray-900">
Delete Schedule
</Text>
{/* Description */}
<Text className="mb-6 text-center text-base text-gray-600">
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be
undone.
</Text>
{/* Buttons */}
<View className="flex-row gap-3">
<TouchableOpacity
className="flex-1 rounded-lg bg-gray-100 px-4 py-3"
onPress={() => setShowDeleteModal(false)}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-gray-700">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 rounded-lg bg-gray-900 px-4 py-3 ${deleting ? "opacity-50" : ""}`}
onPress={confirmDelete}
disabled={deleting}
>
<Text className="text-center text-base font-semibold text-white">Delete</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
</View>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
import React from "react";
import { View, Text, TouchableOpacity, ScrollView, Linking, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { Header } from "../../components/Header";
import { LogoutButton } from "../../components/LogoutButton";
interface MoreMenuItem {
name: string;
icon: keyof typeof Ionicons.glyphMap;
isExternal?: boolean;
href?: string;
onPress?: () => void;
}
export default function More() {
const router = useRouter();
const openExternalLink = async (url: string, fallbackMessage: string) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`);
}
} catch (error) {
console.error(`Failed to open ${url}:`, error);
Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`);
}
};
const menuItems: MoreMenuItem[] = [
{
name: "Profile",
icon: "person-outline",
isExternal: true,
onPress: () =>
openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page"),
},
{
name: "Apps",
icon: "grid-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/apps", "Apps page"),
},
{
name: "Routing",
icon: "git-branch-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/routing", "Routing page"),
},
{
name: "Workflows",
icon: "flash-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/workflows", "Workflows page"),
},
{
name: "Insights",
icon: "bar-chart-outline",
isExternal: true,
onPress: () => openExternalLink("https://app.cal.com/insights", "Insights page"),
},
{
name: "Support",
icon: "help-circle-outline",
isExternal: true,
onPress: () => openExternalLink("https://go.cal.com/support", "Support"),
},
];
return (
<View className="flex-1 bg-[#f8f9fa]">
<Header />
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 90 }}
showsVerticalScrollIndicator={false}
>
<View className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white">
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.name}
onPress={item.href ? () => router.push(item.href!) : item.onPress}
className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA] ${
index < menuItems.length - 1 ? "border-b border-[#E5E5EA]" : ""
}`}
>
<View className="flex-1 flex-row items-center">
<Ionicons name={item.icon} size={20} color="#333" />
<Text className="ml-3 text-base font-semibold text-[#333]">{item.name}</Text>
</View>
{item.isExternal ? (
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
) : (
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
)}
</TouchableOpacity>
))}
</View>
{/* Authentication Info and Logout */}
<View className="py-4">
<LogoutButton className="w-full bg-transparent text-gray-900" />
</View>
<Text className="mt-4 text-sm text-gray-500">
We view the companion as an extension of the web application. If you are performing any
complicated actions, please refer back to the web application.
</Text>
</ScrollView>
</View>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Stack } from "expo-router";
import { Platform, View, StatusBar } from "react-native";
import { AuthProvider, useAuth } from "../contexts/AuthContext";
import LoginScreen from "../components/LoginScreen";
import "../global.css";
function RootLayoutContent() {
const { isAuthenticated } = useAuth();
const content = isAuthenticated ? (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
) : (
<LoginScreen />
);
const containerClass =
Platform.OS === "web"
? "w-[400px] flex-1 flex-col self-end border-l border-gray-200 bg-white"
: "bg-white";
const containerStyle =
Platform.OS === "web"
? {
width: 400,
flex: 1,
display: "flex",
flexDirection: "column",
pointerEvents: "auto",
}
: { flex: 1 };
return (
<View style={containerStyle} className={containerClass}>
<StatusBar barStyle="dark-content" backgroundColor="#ffffff" />
{content}
</View>
);
}
export default function RootLayout() {
return (
<AuthProvider>
<RootLayoutContent />
</AuthProvider>
);
}
+994
View File
@@ -0,0 +1,994 @@
import { Ionicons } from "@expo/vector-icons";
import { GlassView } from "expo-glass-effect";
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
import React, { useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
Switch,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, Schedule } from "../services/calcom";
import { ScheduleAvailability } from "../services/types";
import { FullScreenModal } from "../components/FullScreenModal";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const DAY_ABBREVIATIONS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Convert 24-hour time to 12-hour format with AM/PM
const formatTime12Hour = (time24: string): string => {
const [hours, minutes] = time24.split(":");
const hour = parseInt(hours, 10);
const min = minutes || "00";
const period = hour >= 12 ? "PM" : "AM";
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
// Pad single-digit hours with leading zero for consistent width
const hour12Padded = hour12.toString().padStart(2, "0");
return `${hour12Padded}:${min} ${period}`;
};
// Format availability for display
const formatAvailabilityDisplay = (
availability: Record<number, ScheduleAvailability[]>
): string[] => {
// Group slots by time range
const timeRangeMap: Record<string, number[]> = {};
Object.keys(availability).forEach((dayIndexStr) => {
const dayIndex = Number(dayIndexStr);
const slots = availability[dayIndex];
if (slots && slots.length > 0) {
slots.forEach((slot) => {
const timeKey = `${slot.startTime}-${slot.endTime}`;
if (!timeRangeMap[timeKey]) {
timeRangeMap[timeKey] = [];
}
timeRangeMap[timeKey].push(dayIndex);
});
}
});
// Format each time range group
const formatted: string[] = [];
Object.keys(timeRangeMap).forEach((timeKey) => {
const days = timeRangeMap[timeKey].sort((a, b) => a - b);
const [startTime, endTime] = timeKey.split("-");
const dayNames = days.map((day) => DAY_ABBREVIATIONS[day]).join(", ");
const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
formatted.push(`${dayNames}, ${timeRange}`);
});
return formatted;
};
// Generate time options (15-minute intervals)
const generateTimeOptions = () => {
const options: string[] = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const h = hour.toString().padStart(2, "0");
const m = minute.toString().padStart(2, "0");
options.push(`${h}:${m}`);
}
}
return options;
};
const TIME_OPTIONS = generateTimeOptions();
export default function AvailabilityDetail() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [scheduleName, setScheduleName] = useState("");
const [timeZone, setTimeZone] = useState("");
const [isDefault, setIsDefault] = useState(false);
const [availability, setAvailability] = useState<Record<number, ScheduleAvailability[]>>({});
const [showTimezoneModal, setShowTimezoneModal] = useState(false);
const [showTimePicker, setShowTimePicker] = useState<{
dayIndex: number;
slotIndex: number;
type: "start" | "end";
} | null>(null);
const [overrides, setOverrides] = useState<
Array<{
date: string; // Format: "2024-05-20"
startTime: string; // Format: "12:00" or "00:00" for unavailable
endTime: string; // Format: "14:00" or "00:00" for unavailable
}>
>([]);
const [showOverrideModal, setShowOverrideModal] = useState(false);
const [editingOverride, setEditingOverride] = useState<number | null>(null);
const [overrideDate, setOverrideDate] = useState("");
const [overrideStartTime, setOverrideStartTime] = useState("09:00");
const [overrideEndTime, setOverrideEndTime] = useState("17:00");
const [isUnavailable, setIsUnavailable] = useState(false);
const [showOverrideTimePicker, setShowOverrideTimePicker] = useState<{
type: "start" | "end";
} | null>(null);
// Common timezones
const timezones = [
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Kolkata",
"Australia/Sydney",
"UTC",
];
useEffect(() => {
if (id) {
fetchSchedule();
}
}, [id]);
const fetchSchedule = async () => {
try {
setLoading(true);
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<number, ScheduleAvailability[]> = {};
// Map day names to numbers
const dayNameToNumber: Record<string, number> = {
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 (!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);
}
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",
});
});
});
}
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([]);
}
}
} catch (error) {
console.error("Error fetching schedule:", error);
Alert.alert("Error", "Failed to load schedule. Please try again.");
router.back();
} finally {
setLoading(false);
}
};
const toggleDay = (dayIndex: number) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (newAvailability[dayIndex] && newAvailability[dayIndex].length > 0) {
// Disable day - remove availability
delete newAvailability[dayIndex];
} else {
// Enable day - add default time slot (9 AM - 5 PM)
newAvailability[dayIndex] = [
{
days: [dayIndex.toString()],
startTime: "09:00:00",
endTime: "17:00:00",
},
];
}
return newAvailability;
});
};
const addTimeSlot = (dayIndex: number) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (!newAvailability[dayIndex]) {
newAvailability[dayIndex] = [];
}
newAvailability[dayIndex].push({
days: [dayIndex.toString()],
startTime: "09:00:00",
endTime: "17:00:00",
});
return newAvailability;
});
};
const removeTimeSlot = (dayIndex: number, slotIndex: number) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (newAvailability[dayIndex]) {
newAvailability[dayIndex] = newAvailability[dayIndex].filter((_, i) => i !== slotIndex);
if (newAvailability[dayIndex].length === 0) {
delete newAvailability[dayIndex];
}
}
return newAvailability;
});
};
const updateTimeSlot = (
dayIndex: number,
slotIndex: number,
type: "start" | "end",
time: string
) => {
setAvailability((prev) => {
const newAvailability = { ...prev };
if (newAvailability[dayIndex] && newAvailability[dayIndex][slotIndex]) {
const slot = { ...newAvailability[dayIndex][slotIndex] };
if (type === "start") {
slot.startTime = `${time}:00`;
} else {
slot.endTime = `${time}:00`;
}
newAvailability[dayIndex][slotIndex] = slot;
}
return newAvailability;
});
};
const handleSave = async () => {
try {
setSaving(true);
// Convert availability object back to array format with day names
const availabilityArray: Array<{
days: string[]; // Day names like "Monday", "Tuesday"
startTime: string; // Format: "09:00"
endTime: string; // Format: "10:00"
}> = [];
Object.keys(availability).forEach((dayIndexStr) => {
const dayIndex = Number(dayIndexStr);
const slots = availability[dayIndex];
if (slots && slots.length > 0) {
slots.forEach((slot) => {
// Convert day number to day name
const dayName = DAYS[dayIndex];
// Format time from "09:00:00" to "09:00" (remove seconds)
const formatTimeForAPI = (time: string): string => {
return time.substring(0, 5); // Take only HH:MM
};
availabilityArray.push({
days: [dayName],
startTime: formatTimeForAPI(slot.startTime),
endTime: formatTimeForAPI(slot.endTime),
});
});
}
});
// Format overrides for API (ensure times are in HH:MM format)
const formattedOverrides = overrides.map((override) => ({
date: override.date,
startTime: override.startTime.substring(0, 5), // Ensure HH:MM format
endTime: override.endTime.substring(0, 5), // Ensure HH:MM format
}));
await CalComAPIService.updateSchedule(Number(id), {
name: scheduleName,
timeZone,
availability: availabilityArray,
isDefault: isDefault,
overrides: formattedOverrides,
});
Alert.alert("Success", "Schedule updated successfully", [
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) {
Alert.alert("Error", "Failed to update schedule. Please try again.");
} finally {
setSaving(false);
}
};
const handleSetAsDefault = async () => {
try {
await CalComAPIService.updateSchedule(Number(id), {
isDefault: true,
});
setIsDefault(true);
Alert.alert("Success", "Schedule set as default successfully");
} catch (error) {
Alert.alert("Error", "Failed to set schedule as default. Please try again.");
}
};
const handleDelete = () => {
Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
await CalComAPIService.deleteSchedule(Number(id));
Alert.alert("Success", "Schedule deleted successfully", [
{ text: "OK", onPress: () => router.back() },
]);
} catch (error) {
Alert.alert("Error", "Failed to delete schedule. Please try again.");
}
},
},
]);
};
const handleAddOverride = () => {
setEditingOverride(null);
setOverrideDate("");
setOverrideStartTime("09:00");
setOverrideEndTime("17:00");
setIsUnavailable(false);
setShowOverrideModal(true);
};
const handleEditOverride = (index: number) => {
const override = overrides[index];
setEditingOverride(index);
setOverrideDate(override.date);
setOverrideStartTime(override.startTime);
setOverrideEndTime(override.endTime);
setIsUnavailable(override.startTime === "00:00" && override.endTime === "00:00");
setShowOverrideModal(true);
};
const handleDeleteOverride = (index: number) => {
Alert.alert("Delete Override", "Are you sure you want to delete this date override?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
setOverrides(overrides.filter((_, i) => i !== index));
},
},
]);
};
const handleSaveOverride = () => {
if (!overrideDate) {
Alert.alert("Error", "Please select a date");
return;
}
const newOverride = {
date: overrideDate,
startTime: isUnavailable ? "00:00" : overrideStartTime,
endTime: isUnavailable ? "00:00" : overrideEndTime,
};
if (editingOverride !== null) {
// Update existing override
const updated = [...overrides];
updated[editingOverride] = newOverride;
setOverrides(updated);
} else {
// Add new override
setOverrides([...overrides, newOverride]);
}
setShowOverrideModal(false);
};
const formatDateForDisplay = (dateStr: string): string => {
if (!dateStr) return "";
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-[#666]">Loading schedule...</Text>
</View>
);
}
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerTitle: () => (
<Text style={{ fontSize: 20, fontWeight: "700" }}>Edit Availability</Text>
),
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()} className="ml-4">
<Ionicons name="chevron-back" size={24} color="#000" />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
className={`mr-4 min-w-[60px] items-center rounded-[10px] bg-black px-2 py-2 md:px-4 ${saving ? "opacity-60" : ""}`}
onPress={handleSave}
disabled={saving}
>
<Text className="text-base font-semibold text-white">Save</Text>
</TouchableOpacity>
),
}}
/>
<View className="flex-1">
<ScrollView
className="flex-1 bg-[#f8f9fa]"
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
>
<View className="gap-4">
{/* Schedule Name and Working Hours Display */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-3 text-xl font-bold text-[#333]">{scheduleName}</Text>
{Object.keys(availability).length > 0 ? (
<View>
{formatAvailabilityDisplay(availability).map((line, index) => (
<Text key={index} className="mb-1 text-base text-[#666]">
{line}
</Text>
))}
</View>
) : (
<Text className="text-base text-[#999]">No availability set</Text>
)}
</View>
{/* Availability Schedule */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-4 text-xl font-bold text-[#333]">Availability</Text>
{DAYS.map((day, dayIndex) => {
const daySlots = availability[dayIndex] || [];
const isEnabled = daySlots.length > 0;
const firstSlot = daySlots[0];
return (
<View
key={dayIndex}
className={`mb-3 border-b border-[#E5E5EA] pb-3 ${dayIndex === DAYS.length - 1 ? "mb-0 border-b-0 pb-0" : ""}`}
>
<View className="flex-row items-center">
<View className="flex-1 flex-row items-center">
<Switch
value={isEnabled}
onValueChange={() => toggleDay(dayIndex)}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#fff"
style={{ transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }] }}
/>
<Text
className="ml-1 text-base font-medium text-[#333]"
style={{ width: 40 }}
>
{DAY_ABBREVIATIONS[dayIndex]}
</Text>
{isEnabled && firstSlot && (
<View className="flex-row items-center gap-2">
<TouchableOpacity
onPress={() =>
setShowTimePicker({ dayIndex, slotIndex: 0, type: "start" })
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(firstSlot.startTime)}
</Text>
</TouchableOpacity>
<Text className="text-base text-[#666]">-</Text>
<TouchableOpacity
onPress={() =>
setShowTimePicker({ dayIndex, slotIndex: 0, type: "end" })
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(firstSlot.endTime)}
</Text>
</TouchableOpacity>
</View>
)}
</View>
{isEnabled && (
<TouchableOpacity onPress={() => addTimeSlot(dayIndex)} className="p-1">
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
</TouchableOpacity>
)}
</View>
{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">
<TouchableOpacity
onPress={() =>
setShowTimePicker({
dayIndex,
slotIndex: slotIndex + 1,
type: "start",
})
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(slot.startTime)}
</Text>
</TouchableOpacity>
<Text className="text-base text-[#666]">-</Text>
<TouchableOpacity
onPress={() =>
setShowTimePicker({
dayIndex,
slotIndex: slotIndex + 1,
type: "end",
})
}
className="rounded-lg border border-[#E5E5EA] bg-white px-2 py-1"
style={{ width: 85 }}
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(slot.endTime)}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => removeTimeSlot(dayIndex, slotIndex + 1)}
className="p-1"
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</TouchableOpacity>
</View>
))}
</View>
)}
</View>
);
})}
</View>
{/* Timezone */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-3 text-xl font-bold text-[#333]">Timezone</Text>
<TouchableOpacity
onPress={() => setShowTimezoneModal(true)}
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
>
<Text className="text-base text-[#333]">{timeZone}</Text>
<Ionicons name="chevron-down" size={20} color="#666" />
</TouchableOpacity>
</View>
{/* Date Overrides */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-2 text-xl font-bold text-[#333]">Date overrides</Text>
<Text className="mb-4 text-base text-[#666]">
Add dates when your availability changes from your daily hours.
</Text>
{overrides.length > 0 && (
<View className="mb-4">
{overrides.map((override, index) => (
<View
key={index}
className="mb-2 flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] p-3"
>
<View className="flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
{formatDateForDisplay(override.date)}
</Text>
{override.startTime === "00:00" && override.endTime === "00:00" ? (
<Text className="text-sm text-[#666]">Unavailable (All day)</Text>
) : (
<Text className="text-sm text-[#666]">
{formatTime12Hour(override.startTime + ":00")} -{" "}
{formatTime12Hour(override.endTime + ":00")}
</Text>
)}
</View>
<View className="flex-row items-center gap-2">
<TouchableOpacity onPress={() => handleEditOverride(index)} className="p-2">
<Ionicons name="pencil-outline" size={20} color="#007AFF" />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleDeleteOverride(index)}
className="p-2"
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</TouchableOpacity>
</View>
</View>
))}
</View>
)}
<TouchableOpacity
onPress={handleAddOverride}
className="flex-row items-center justify-center rounded-lg border border-dashed border-[#E5E5EA] py-3"
>
<Ionicons name="add-circle-outline" size={20} color="#007AFF" />
<Text className="ml-2 text-base font-medium text-[#007AFF]">Add an override</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* Bottom Action Bar */}
<GlassView
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
paddingTop: 16,
paddingHorizontal: 20,
backgroundColor: "#f8f9fa",
borderTopWidth: 0.5,
borderTopColor: "#E5E5EA",
paddingBottom: insets.bottom + 12,
}}
glassEffectStyle="clear"
>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<Text className="text-base font-medium text-[#333]">Set as Default</Text>
<Switch
value={isDefault}
onValueChange={handleSetAsDefault}
disabled={isDefault}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
<View className="flex-row items-center gap-3">
<GlassView
className="overflow-hidden rounded-full bg-[rgba(255,255,255,0.1)]"
glassEffectStyle="clear"
>
<TouchableOpacity
className="h-11 w-11 items-center justify-center"
onPress={handleDelete}
>
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</TouchableOpacity>
</GlassView>
</View>
</View>
</GlassView>
</View>
{/* Timezone Modal */}
<FullScreenModal
visible={showTimezoneModal}
animationType="fade"
onRequestClose={() => setShowTimezoneModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowTimezoneModal(false)}
>
<TouchableOpacity
className="max-h-[80%] w-[90%] max-w-[500px] rounded-2xl bg-white p-4"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">Select Timezone</Text>
<TouchableOpacity onPress={() => setShowTimezoneModal(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
{timezones.map((tz) => (
<TouchableOpacity
key={tz}
onPress={() => {
setTimeZone(tz);
setShowTimezoneModal(false);
}}
className="border-b border-[#E5E5EA] py-3"
>
<Text className="text-base text-[#333]">{tz}</Text>
</TouchableOpacity>
))}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Time Picker Modal */}
<FullScreenModal
visible={!!showTimePicker}
animationType="fade"
onRequestClose={() => setShowTimePicker(null)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowTimePicker(null)}
>
<TouchableOpacity
className="max-h-[80%] w-[90%] max-w-[500px] rounded-2xl bg-white p-4"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">
Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
</Text>
<TouchableOpacity onPress={() => setShowTimePicker(null)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
{TIME_OPTIONS.map((time) => {
const currentTime =
showTimePicker?.type === "start"
? availability[showTimePicker.dayIndex]?.[
showTimePicker.slotIndex
]?.startTime.substring(0, 5)
: availability[showTimePicker?.dayIndex || 0]?.[
showTimePicker?.slotIndex || 0
]?.endTime.substring(0, 5);
const isSelected = currentTime === time;
return (
<TouchableOpacity
key={time}
onPress={() => {
if (showTimePicker) {
updateTimeSlot(
showTimePicker.dayIndex,
showTimePicker.slotIndex,
showTimePicker.type,
time
);
setShowTimePicker(null);
}
}}
className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#E3F2FD]" : ""}`}
>
<Text
className={`text-base ${isSelected ? "font-semibold text-[#007AFF]" : "text-[#333]"}`}
>
{time}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Override Modal */}
<FullScreenModal
visible={showOverrideModal}
animationType="fade"
onRequestClose={() => setShowOverrideModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowOverrideModal(false)}
>
<TouchableOpacity
className="max-h-[90%] w-[90%] max-w-[500px] rounded-2xl bg-white p-6"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">
{editingOverride !== null ? "Edit Override" : "Add Override"}
</Text>
<TouchableOpacity onPress={() => setShowOverrideModal(false)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
<View className="gap-4">
{/* Date Picker */}
<View>
<Text className="mb-2 text-base font-semibold text-[#333]">
Select the dates to override
</Text>
<TextInput
value={overrideDate}
onChangeText={setOverrideDate}
placeholder="YYYY-MM-DD (e.g., 2024-05-20)"
placeholderTextColor="#999"
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-[#333]"
/>
<Text className="mt-1 text-sm text-[#666]">Enter date in YYYY-MM-DD format</Text>
</View>
{/* Unavailable Toggle */}
<View className="flex-row items-center justify-between">
<Text className="text-base font-semibold text-[#333]">
Mark unavailable (All day)
</Text>
<Switch
value={isUnavailable}
onValueChange={setIsUnavailable}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{/* Time Picker (only if not unavailable) */}
{!isUnavailable && (
<View>
<Text className="mb-3 text-base font-semibold text-[#333]">
Which hours are you free?
</Text>
<View className="flex-row items-center gap-2">
<TouchableOpacity
onPress={() => setShowOverrideTimePicker({ type: "start" })}
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3"
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(overrideStartTime + ":00")}
</Text>
</TouchableOpacity>
<Text className="text-base text-[#666]">-</Text>
<TouchableOpacity
onPress={() => setShowOverrideTimePicker({ type: "end" })}
className="flex-1 rounded-lg border border-[#E5E5EA] bg-white px-3 py-3"
>
<Text className="text-center text-base text-[#333]">
{formatTime12Hour(overrideEndTime + ":00")}
</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Save Button */}
<TouchableOpacity
onPress={handleSaveOverride}
className="mt-4 rounded-lg bg-black py-3"
>
<Text className="text-center text-base font-semibold text-white">
Save Override
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Override Time Picker Modal */}
<FullScreenModal
visible={!!showOverrideTimePicker}
animationType="fade"
onRequestClose={() => setShowOverrideTimePicker(null)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowOverrideTimePicker(null)}
>
<TouchableOpacity
className="max-h-[60%] w-[90%] max-w-[500px] rounded-2xl bg-white p-6"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-xl font-bold text-[#333]">
Select {showOverrideTimePicker?.type === "start" ? "Start" : "End"} Time
</Text>
<TouchableOpacity onPress={() => setShowOverrideTimePicker(null)}>
<Ionicons name="close" size={24} color="#666" />
</TouchableOpacity>
</View>
<ScrollView>
{TIME_OPTIONS.map((time) => {
const currentTime =
showOverrideTimePicker?.type === "start" ? overrideStartTime : overrideEndTime;
const isSelected = currentTime === time;
return (
<TouchableOpacity
key={time}
onPress={() => {
if (showOverrideTimePicker?.type === "start") {
setOverrideStartTime(time);
} else {
setOverrideEndTime(time);
}
setShowOverrideTimePicker(null);
}}
className={`border-b border-[#E5E5EA] py-3 ${isSelected ? "bg-[#F0F9FF]" : ""}`}
>
<Text
className={`text-base ${isSelected ? "font-semibold text-[#007AFF]" : "text-[#333]"}`}
>
{formatTime12Hour(time + ":00")}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
</>
);
}
+747
View File
@@ -0,0 +1,747 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter, useLocalSearchParams, Stack } from "expo-router";
import React, { useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
TouchableOpacity,
Alert,
ActivityIndicator,
Linking,
TextInput,
Platform,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, Booking } from "../services/calcom";
import { SvgImage } from "../components/SvgImage";
import { FullScreenModal } from "../components/FullScreenModal";
import { getAppIconUrl } from "../utils/getAppIconUrl";
import { getDefaultLocationIconUrl, defaultLocations } from "../utils/defaultLocations";
// Format date: "Tuesday, November 25, 2025"
const formatDateFull = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
} catch (error) {
return "";
}
};
// Format time: "9:40pm - 10:00pm"
const formatTime12Hour = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const hours = date.getHours();
const minutes = date.getMinutes();
const period = hours >= 12 ? "pm" : "am";
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const minStr = minutes.toString().padStart(2, "0");
return `${hour12}:${minStr}${period}`;
} catch (error) {
return "";
}
};
// Get timezone from date string
const getTimezone = (dateString: string): string => {
if (!dateString) return "";
try {
const date = new Date(dateString);
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return timeZone;
} catch (error) {
return "";
}
};
// Get initials from a name (e.g., "Keith Williams" -> "KW", "Dhairyashil Shinde" -> "DS")
const getInitials = (name: string): string => {
if (!name) return "";
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return "";
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
// Get first letter of first name and first letter of last name
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
};
// Get location provider info
const getLocationProvider = (location: string | undefined, metadata?: Record<string, any>) => {
// Check metadata for videoCallUrl first
const videoCallUrl = metadata?.videoCallUrl;
const locationToCheck = videoCallUrl || location;
if (!locationToCheck) return null;
// Check if it's a video call URL
if (typeof locationToCheck === "string" && locationToCheck.startsWith("http")) {
// Try to detect provider from URL
if (locationToCheck.includes("cal.com/video") || locationToCheck.includes("cal-video")) {
const iconUrl = getAppIconUrl("daily_video", "cal-video");
return {
label: "Cal Video",
iconUrl: iconUrl || "https://app.cal.com/app-store/dailyvideo/icon.svg",
url: locationToCheck,
};
}
// Check for other video providers by URL pattern
const videoProviders = [
{ pattern: /zoom\.us/, label: "Zoom", type: "zoom_video", appId: "zoom" },
{
pattern: /meet\.google\.com/,
label: "Google Meet",
type: "google_video",
appId: "google-meet",
},
{
pattern: /teams\.microsoft\.com/,
label: "Microsoft Teams",
type: "office365_video",
appId: "msteams",
},
];
for (const provider of videoProviders) {
if (provider.pattern.test(locationToCheck)) {
const iconUrl = getAppIconUrl(provider.type, provider.appId);
return {
label: provider.label,
iconUrl: iconUrl,
url: locationToCheck,
};
}
}
// Generic link meeting
const linkIconUrl = getDefaultLocationIconUrl("link") || "https://app.cal.com/link.svg";
return {
label: "Link Meeting",
iconUrl: linkIconUrl,
url: locationToCheck,
};
}
// Check if it's an integration location (e.g., "integrations:zoom", "integrations:cal-video")
if (typeof locationToCheck === "string" && locationToCheck.startsWith("integrations:")) {
const appId = locationToCheck.replace("integrations:", "");
const iconUrl = getAppIconUrl("", appId);
if (iconUrl) {
// Format appId to display name (e.g., "cal-video" -> "Cal Video")
const formatAppIdToDisplayName = (id: string): string => {
return id
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
return {
label: formatAppIdToDisplayName(appId),
iconUrl: iconUrl,
url: null,
};
}
}
// Check if it's a default location type
const defaultLocation = defaultLocations.find((loc) => loc.type === locationToCheck);
if (defaultLocation) {
return {
label: defaultLocation.label,
iconUrl: defaultLocation.iconUrl,
url: null,
};
}
// Fallback: return as plain text location
return {
label: locationToCheck,
iconUrl: null,
url: null,
};
};
export default function BookingDetail() {
const router = useRouter();
const { uid } = useLocalSearchParams<{ uid: string }>();
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(true);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [showActionsModal, setShowActionsModal] = useState(false);
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
const [rescheduleReason, setRescheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
useEffect(() => {
if (uid) {
fetchBooking();
}
}, [uid]);
const fetchBooking = async () => {
try {
setLoading(true);
setError(null);
const bookingData = await CalComAPIService.getBookingByUid(uid);
console.log("Booking data received:", JSON.stringify(bookingData, null, 2));
console.log("User:", bookingData.user);
console.log("Hosts:", bookingData.hosts);
console.log("Attendees:", bookingData.attendees);
console.log("Recurring fields:", {
recurringEventId: bookingData.recurringEventId,
recurringBookingUid: (bookingData as any).recurringBookingUid,
metadata: (bookingData as any).metadata,
});
setBooking(bookingData);
} catch (err) {
console.error("Error fetching booking:", err);
setError("Failed to load booking. Please try again.");
Alert.alert("Error", "Failed to load booking. Please try again.", [
{ text: "OK", onPress: () => router.back() },
]);
} finally {
setLoading(false);
}
};
const handleJoinMeeting = () => {
if (!booking?.location) return;
const provider = getLocationProvider(booking.location);
if (provider?.url) {
Linking.openURL(provider.url);
}
};
const handleReschedule = async () => {
if (!booking) return;
setRescheduling(true);
try {
// For now, we'll use the current start time
// In a full implementation, you'd show a date/time picker
await CalComAPIService.rescheduleBooking(booking.uid, {
start: booking.startTime,
reschedulingReason: rescheduleReason.trim() || undefined,
});
Alert.alert("Success", "Reschedule request sent successfully");
setShowRescheduleModal(false);
setRescheduleReason("");
// Refresh booking data
await fetchBooking();
} catch (error) {
console.error("Failed to reschedule booking:", error);
Alert.alert("Error", "Failed to send reschedule request. Please try again.");
} finally {
setRescheduling(false);
}
};
if (loading) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-gray-500">Loading booking...</Text>
</View>
);
}
if (error || !booking) {
return (
<View className="flex-1 items-center justify-center bg-[#f8f9fa] p-5">
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800">
{error || "Booking not found"}
</Text>
<TouchableOpacity
className="mt-6 rounded-lg bg-black px-6 py-3"
onPress={() => router.back()}
>
<Text className="text-base font-semibold text-white">Go Back</Text>
</TouchableOpacity>
</View>
);
}
const startTime = booking.start || booking.startTime || "";
const endTime = booking.end || booking.endTime || "";
const dateFormatted = formatDateFull(startTime);
const timeFormatted = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
const timezone = getTimezone(startTime);
const locationProvider = getLocationProvider(booking.location, booking.responses);
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerTitle: booking?.title || "Booking Details",
headerLeft: () => (
<TouchableOpacity
onPress={() => router.back()}
className="items-center justify-center rounded-full"
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
>
<Ionicons name="chevron-back" size={24} color="#000" />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => setShowActionsModal(true)}
className="items-center justify-center rounded-full"
style={{ width: 40, height: 40, backgroundColor: "#F0F0F0" }}
>
<Ionicons name="ellipsis-horizontal" size={24} color="#000" />
</TouchableOpacity>
),
}}
/>
<View className="flex-1 bg-[#f8f9fa]">
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, paddingBottom: 100 }}>
{/* Title */}
<View className="mb-3">
<Text className="mb-2 text-2xl font-semibold text-[#333]">{booking.title}</Text>
<Text className="text-base text-[#666]">
{dateFormatted} {timeFormatted} ({timezone})
</Text>
</View>
{/* Who Section */}
<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)) && (
<View className="mb-4">
{booking.user ? (
<View className="flex-row items-start">
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(booking.user.name)}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{booking.user.name}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
<Text className="text-sm text-[#666]">{booking.user.email}</Text>
</View>
</View>
) : booking.hosts && booking.hosts.length > 0 ? (
booking.hosts.map((host, hostIndex) => (
<View
key={hostIndex}
className={`flex-row items-start ${hostIndex > 0 ? "mt-4" : ""}`}
>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(host.name || "Host")}
</Text>
</View>
<View className="flex-1">
<View className="mb-1 flex-row flex-wrap items-center">
<Text className="text-base font-medium text-[#333]">
{host.name || "Host"}
</Text>
<View className="ml-2 rounded bg-[#007AFF] px-2 py-0.5">
<Text className="text-xs font-medium text-white">host</Text>
</View>
</View>
{host.email && <Text className="text-sm text-[#666]">{host.email}</Text>}
</View>
</View>
))
) : null}
</View>
)}
{booking.attendees && booking.attendees.length > 0 && (
<View>
{booking.attendees.map((attendee, index) => (
<View key={index} className={`flex-row items-start ${index > 0 ? "mt-4" : ""}`}>
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
<Text className="text-base font-semibold text-white">
{getInitials(attendee.name)}
</Text>
</View>
<View className="flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
{attendee.name}
</Text>
<Text className="text-sm text-[#666]">{attendee.email}</Text>
</View>
</View>
))}
</View>
)}
</View>
{/* Where Section */}
{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 ? (
<TouchableOpacity
onPress={handleJoinMeeting}
className="flex-row flex-wrap items-center"
>
{locationProvider.iconUrl && (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base text-[#007AFF]">{locationProvider.label}: </Text>
<Text className="flex-1 text-base text-[#007AFF]" numberOfLines={1}>
{locationProvider.url}
</Text>
</TouchableOpacity>
) : (
<View className="flex-row items-center">
{locationProvider.iconUrl && (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base text-[#333]">{locationProvider.label}</Text>
</View>
)}
</View>
)}
{/* Recurring Event Section */}
{(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>
)}
{/* Description Section */}
{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>
)}
{/* Join Meeting Button */}
{locationProvider?.url && (
<TouchableOpacity
onPress={handleJoinMeeting}
className="mb-2 flex-row items-center justify-center rounded-lg bg-black px-6 py-4"
>
{locationProvider.iconUrl && (
<SvgImage
uri={locationProvider.iconUrl}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base font-semibold text-white">
Join {locationProvider.label}
</Text>
</TouchableOpacity>
)}
</ScrollView>
</View>
{/* Booking Actions Modal */}
<FullScreenModal
visible={showActionsModal}
animationType="fade"
onRequestClose={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowActionsModal(false)}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
<View className="border-b border-gray-200 p-6">
<Text className="text-center text-xl font-semibold text-gray-900">
Booking Actions
</Text>
</View>
{/* Actions List */}
<View className="p-2">
{/* View Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Navigate to booking view page
console.log("View booking");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="eye-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">View Booking</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Edit event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">Edit event</Text>
</View>
{/* Request Reschedule */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
setShowRescheduleModal(true);
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="send-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Send Reschedule Request</Text>
</TouchableOpacity>
{/* Edit Location */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open edit location dialog
console.log("Edit location");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="location-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Edit Location</Text>
</TouchableOpacity>
{/* Add Guests */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open add guests dialog
console.log("Add guests");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="person-add-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Add Guests</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* After event label */}
<View className="px-4 py-1">
<Text className="text-xs font-medium text-gray-500">After event</Text>
</View>
{/* View Recordings */}
{locationProvider?.url && (
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open view recordings dialog
console.log("View recordings");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="videocam-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">View Recordings</Text>
</TouchableOpacity>
)}
{/* Meeting Session Details */}
{locationProvider?.url && (
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open session details dialog
console.log("Meeting session details");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="information-circle-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Meeting Session Details</Text>
</TouchableOpacity>
)}
{/* Mark as No-Show */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Mark as no-show
console.log("Mark as no-show");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="eye-off-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Mark as No-Show</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Report Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
// TODO: Open report booking dialog
console.log("Report booking");
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="flag-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Report Booking</Text>
</TouchableOpacity>
{/* Separator */}
<View className="mx-4 my-2 h-px bg-gray-200" />
{/* Cancel Booking */}
<TouchableOpacity
onPress={() => {
setShowActionsModal(false);
Alert.alert("Cancel Booking", "Are you sure you want to cancel this booking?", [
{ text: "No", style: "cancel" },
{
text: "Yes, Cancel",
style: "destructive",
onPress: () => {
// TODO: Cancel booking
console.log("Cancel booking");
},
},
]);
}}
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
>
<Ionicons name="close-circle-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Cancel Booking</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowActionsModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
{/* Reschedule Modal */}
<FullScreenModal
visible={showRescheduleModal}
animationType="fade"
onRequestClose={() => setShowRescheduleModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowRescheduleModal(false)}
>
<TouchableOpacity
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={{
shadowColor: "#000",
shadowOffset: { width: 0, height: 20 },
shadowOpacity: 0.25,
shadowRadius: 25,
elevation: 24,
}}
>
{/* Header */}
<View className="px-8 pb-4 pt-6">
<View className="flex-row items-start space-x-3">
<View className="h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#F3F4F6]">
<Ionicons name="time-outline" size={24} color="#111827" />
</View>
<View className="flex-1 pt-1">
<Text className="mb-2 text-2xl font-semibold text-[#111827]">
Reschedule request
</Text>
<Text className="text-sm text-[#6B7280]">
Send a reschedule request to the organizer of this booking.
</Text>
</View>
</View>
</View>
{/* Content */}
<View className="px-8 pb-6">
<Text className="mb-2 text-sm font-bold text-[#111827]">
Reason for reschedule request
<Text className="font-normal text-[#6B7280]"> (Optional)</Text>
</Text>
<TextInput
className="min-h-[100px] rounded-md border border-[#D1D5DB] bg-white px-3 py-3 text-base text-[#111827]"
placeholder="Please let us know why you need to reschedule..."
placeholderTextColor="#9CA3AF"
value={rescheduleReason}
onChangeText={setRescheduleReason}
multiline
numberOfLines={4}
textAlignVertical="top"
editable={!rescheduling}
/>
</View>
{/* Footer */}
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
<View className="flex-row justify-end gap-2 space-x-2">
<TouchableOpacity
className="rounded-xl border border-[#D1D5DB] bg-white px-2 py-2 md:px-4"
onPress={() => {
setShowRescheduleModal(false);
setRescheduleReason("");
}}
disabled={rescheduling}
>
<Text className="text-base font-medium text-[#374151]">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${rescheduling ? "opacity-60" : ""}`}
onPress={handleReschedule}
disabled={rescheduling}
>
<Text className="text-base font-medium text-white">Reschedule request</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
// Constants for Event Type Detail screen
export const bufferTimeOptions = [
"No buffer time",
"5 Minutes",
"10 Minutes",
"15 Minutes",
"20 Minutes",
"30 Minutes",
"45 Minutes",
"60 Minutes",
"90 Minutes",
"120 Minutes",
];
export const timeUnitOptions = ["Minutes", "Hours", "Days"];
export const frequencyUnitOptions = ["Per day", "Per Month", "Per year"];
export const durationUnitOptions = ["Per day", "Per week", "Per month"];
export const slotIntervalOptions = [
"Default",
"5 Minutes",
"10 Minutes",
"15 Minutes",
"20 Minutes",
"30 Minutes",
"45 Minutes",
"60 Minutes",
"75 Minutes",
"90 Minutes",
"105 Minutes",
"120 Minutes",
];
export const availableDurations = [
"5 mins",
"10 mins",
"15 mins",
"20 mins",
"25 mins",
"30 mins",
"45 mins",
"50 mins",
"60 mins",
"75 mins",
"80 mins",
"90 mins",
"120 mins",
"150 mins",
"180 mins",
"240 mins",
"300 mins",
"360 mins",
"420 mins",
"480 mins",
];
export const defaultLocations = [
{ label: "Attendee Address", type: "attendeeInPerson" as const },
{ label: "Organizer Address", type: "inPerson" as const },
{ label: "Link Meeting", type: "link" as const },
{ label: "Attendee Phone Number", type: "phone" as const },
{ label: "Organizer Phone Number", type: "userPhone" as const },
];
export const tabs = [
{ id: "basics", label: "Basics" },
{ id: "availability", label: "Availability" },
{ id: "limits", label: "Limits" },
{ id: "advanced", label: "Advanced" },
{ id: "recurring", label: "Recurring" },
{ id: "apps", label: "Apps" },
{ id: "webhooks", label: "Webhooks" },
{ id: "private-links", label: "Private Links" },
{ id: "seats", label: "Seats" },
{ id: "colors", label: "Colors" },
];
@@ -0,0 +1,521 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface AdvancedTabProps {
// Calendar event name
calendarEventName: string;
setCalendarEventName: (value: string) => void;
// Add to calendar email
addToCalendarEmail: string;
setAddToCalendarEmail: (value: string) => void;
// Layout
selectedLayouts: string[];
setSelectedLayouts: (layouts: string[]) => void;
defaultLayout: string;
setDefaultLayout: (layout: string) => void;
// Confirmation settings
requiresConfirmation: boolean;
setRequiresConfirmation: (value: boolean) => void;
disableCancelling: boolean;
setDisableCancelling: (value: boolean) => void;
disableRescheduling: boolean;
setDisableRescheduling: (value: boolean) => void;
// Additional settings
sendCalVideoTranscription: boolean;
setSendCalVideoTranscription: (value: boolean) => void;
autoTranslate: boolean;
setAutoTranslate: (value: boolean) => void;
requiresBookerEmailVerification: boolean;
setRequiresBookerEmailVerification: (value: boolean) => void;
hideCalendarNotes: boolean;
setHideCalendarNotes: (value: boolean) => void;
hideCalendarEventDetails: boolean;
setHideCalendarEventDetails: (value: boolean) => void;
hideOrganizerEmail: boolean;
setHideOrganizerEmail: (value: boolean) => void;
lockTimezone: boolean;
setLockTimezone: (value: boolean) => void;
allowReschedulingPastEvents: boolean;
setAllowReschedulingPastEvents: (value: boolean) => void;
allowBookingThroughRescheduleLink: boolean;
setAllowBookingThroughRescheduleLink: (value: boolean) => void;
// Redirect on booking
successRedirectUrl: string;
setSuccessRedirectUrl: (value: string) => void;
forwardParamsSuccessRedirect: boolean;
setForwardParamsSuccessRedirect: (value: boolean) => void;
// Custom reply-to email
customReplyToEmail: string;
setCustomReplyToEmail: (value: string) => void;
// Event type colors
eventTypeColorLight: string;
setEventTypeColorLight: (value: string) => void;
eventTypeColorDark: string;
setEventTypeColorDark: (value: string) => void;
}
export function AdvancedTab(props: AdvancedTabProps) {
const layoutOptions = [
{ id: "MONTH_VIEW", label: "Month", icon: "calendar-outline" },
{ id: "WEEK_VIEW", label: "Weekly", icon: "calendar-outline" },
{ id: "COLUMN_VIEW", label: "Column", icon: "list-outline" },
];
return (
<View className="gap-3">
{/* Calendar Event Name Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Calendar event name</Text>
<TextInput
className="mb-2 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.calendarEventName}
onChangeText={props.setCalendarEventName}
placeholder="30min between Pro Example and {Scheduler}"
placeholderTextColor="#8E8E93"
/>
<Text className="text-xs text-[#666]">
Use variables like {"{"}Scheduler{"}"} for booker name, {"{"}Organizer{"}"} for your name
</Text>
</View>
{/* Add to Calendar Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Add to calendar</Text>
<Text className="mb-3 text-sm text-[#666]">
We'll display this email address as the organizer, and send confirmation emails here.
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.addToCalendarEmail}
onChangeText={props.setAddToCalendarEmail}
placeholder="pro@example.com"
placeholderTextColor="#8E8E93"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* Layout Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Layout</Text>
<Text className="mb-3 text-sm text-[#666]">
You can select multiple and your bookers can switch views.
</Text>
{/* Layout Options */}
<View className="mb-4 gap-2">
{layoutOptions.map((layout) => (
<TouchableOpacity
key={layout.id}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
props.selectedLayouts.includes(layout.id)
? "border-black bg-[#F0F0F0]"
: "border-[#E5E5EA]"
}`}
onPress={() => {
if (props.selectedLayouts.includes(layout.id)) {
// Don't allow deselecting if it's the only one
if (props.selectedLayouts.length > 1) {
props.setSelectedLayouts(props.selectedLayouts.filter((l) => l !== layout.id));
// If removing the default, set a new default
if (props.defaultLayout === layout.id) {
const remaining = props.selectedLayouts.filter((l) => l !== layout.id);
props.setDefaultLayout(remaining[0]);
}
}
} else {
props.setSelectedLayouts([...props.selectedLayouts, layout.id]);
}
}}
>
<View className="flex-row items-center gap-2">
<Ionicons name={layout.icon as any} size={20} color="#333" />
<Text className="text-base text-[#333]">{layout.label}</Text>
</View>
{props.selectedLayouts.includes(layout.id) && (
<Ionicons name="checkmark-circle" size={20} color="#000" />
)}
</TouchableOpacity>
))}
</View>
{/* Default View */}
{props.selectedLayouts.length > 1 && (
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Default view</Text>
<View className="gap-2">
{props.selectedLayouts.map((layoutId) => {
const layout = layoutOptions.find((l) => l.id === layoutId);
if (!layout) return null;
return (
<TouchableOpacity
key={layout.id}
className={`flex-row items-center justify-between rounded-lg border p-3 ${
props.defaultLayout === layout.id
? "border-black bg-[#F0F0F0]"
: "border-[#E5E5EA]"
}`}
onPress={() => props.setDefaultLayout(layout.id)}
>
<Text className="text-base text-[#333]">{layout.label}</Text>
{props.defaultLayout === layout.id && (
<Ionicons name="checkmark-circle" size={20} color="#000" />
)}
</TouchableOpacity>
);
})}
</View>
</View>
)}
</View>
{/* Booking Questions Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Booking questions</Text>
<Text className="mb-3 text-sm text-[#666]">
Customize the questions asked on the booking page.
</Text>
<TouchableOpacity
className="flex-row items-center justify-center rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 md:px-4"
onPress={() =>
Alert.alert("Coming Soon", "Booking questions customization will be available soon.")
}
>
<Ionicons name="add-circle-outline" size={20} color="#666" />
<Text className="ml-2 text-base text-[#666]">Manage booking questions</Text>
</TouchableOpacity>
</View>
{/* Requires confirmation */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Requires confirmation</Text>
<Text className="text-sm text-[#666]">
The booking needs to be manually confirmed before it is pushed to your calendar
</Text>
</View>
<Switch
value={props.requiresConfirmation}
onValueChange={props.setRequiresConfirmation}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Disable Cancelling */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Disable Cancelling</Text>
<Text className="text-sm text-[#666]">
Guests can no longer cancel the event with calendar invite or email
</Text>
</View>
<Switch
value={props.disableCancelling}
onValueChange={props.setDisableCancelling}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Disable Rescheduling */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Disable Rescheduling</Text>
<Text className="text-sm text-[#666]">
Guests can no longer reschedule the event with calendar invite or email
</Text>
</View>
<Switch
value={props.disableRescheduling}
onValueChange={props.setDisableRescheduling}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Send Cal Video Transcription Emails */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Send Cal Video Transcription Emails
</Text>
<Text className="text-sm text-[#666]">
Send emails with the transcription of the Cal Video after the meeting ends
</Text>
</View>
<Switch
value={props.sendCalVideoTranscription}
onValueChange={props.setSendCalVideoTranscription}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Auto translate */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Auto translate title and description
</Text>
<Text className="text-sm text-[#666]">
Automatically translate titles and descriptions to the visitor's browser language
using AI
</Text>
</View>
<Switch
value={props.autoTranslate}
onValueChange={props.setAutoTranslate}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Requires booker email verification */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Requires booker email verification
</Text>
<Text className="text-sm text-[#666]">
To ensure booker's email verification before scheduling events
</Text>
</View>
<Switch
value={props.requiresBookerEmailVerification}
onValueChange={props.setRequiresBookerEmailVerification}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide notes in calendar */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide notes in calendar</Text>
<Text className="text-sm text-[#666]">
For privacy reasons, additional inputs and notes will be hidden in the calendar entry
</Text>
</View>
<Switch
value={props.hideCalendarNotes}
onValueChange={props.setHideCalendarNotes}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide calendar event details */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Hide calendar event details on shared calendars
</Text>
<Text className="text-sm text-[#666]">
When a calendar is shared, events are visible but details are hidden from those
without write access
</Text>
</View>
<Switch
value={props.hideCalendarEventDetails}
onValueChange={props.setHideCalendarEventDetails}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Hide organizer's email */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Hide organizer's email</Text>
<Text className="text-sm text-[#666]">
Hide organizer's email address from the booking screen, email notifications, and
calendar events
</Text>
</View>
<Switch
value={props.hideOrganizerEmail}
onValueChange={props.setHideOrganizerEmail}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Lock timezone */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Lock timezone on booking page
</Text>
<Text className="text-sm text-[#666]">
To lock the timezone on booking page, useful for in-person events
</Text>
</View>
<Switch
value={props.lockTimezone}
onValueChange={props.setLockTimezone}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Allow rescheduling past events */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Allow rescheduling past events
</Text>
<Text className="text-sm text-[#666]">
Enabling this option allows for past events to be rescheduled
</Text>
</View>
<Switch
value={props.allowReschedulingPastEvents}
onValueChange={props.setAllowReschedulingPastEvents}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Allow booking through reschedule link */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Allow booking through reschedule link
</Text>
<Text className="text-sm text-[#666]">
When enabled, users will be able to create a new booking when trying to reschedule a
cancelled booking
</Text>
</View>
<Switch
value={props.allowBookingThroughRescheduleLink}
onValueChange={props.setAllowBookingThroughRescheduleLink}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Redirect on booking Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Redirect on booking</Text>
<Text className="mb-3 text-sm text-[#666]">
Redirect to a custom URL after a successful booking
</Text>
<TextInput
className="mb-3 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.successRedirectUrl}
onChangeText={props.setSuccessRedirectUrl}
placeholder="https://example.com/thank-you"
placeholderTextColor="#8E8E93"
keyboardType="url"
autoCapitalize="none"
/>
<View className="flex-row items-center justify-between">
<Text className="text-sm text-[#333]">Forward query parameters</Text>
<Switch
value={props.forwardParamsSuccessRedirect}
onValueChange={props.setForwardParamsSuccessRedirect}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Custom Reply-To Email Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Custom 'Reply-To' email</Text>
<Text className="mb-3 text-sm text-[#666]">
Use a different email address as the replyTo for confirmation emails instead of the
organizer's email
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.customReplyToEmail}
onChangeText={props.setCustomReplyToEmail}
placeholder="reply@example.com"
placeholderTextColor="#8E8E93"
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
{/* Event Type Color Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Event type color</Text>
<Text className="mb-3 text-sm text-[#666]">
This is only used for event type & booking differentiation within the app. It is not
displayed to bookers.
</Text>
<View className="gap-3">
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Light theme color</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{ backgroundColor: props.eventTypeColorLight }}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorLight}
onChangeText={props.setEventTypeColorLight}
placeholder="#292929"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Dark theme color</Text>
<View className="flex-row items-center gap-3">
<View
className="h-12 w-12 rounded-lg border border-[#E5E5EA]"
style={{ backgroundColor: props.eventTypeColorDark }}
/>
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTypeColorDark}
onChangeText={props.setEventTypeColorDark}
placeholder="#FAFAFA"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
/>
</View>
</View>
</View>
</View>
</View>
);
}
@@ -0,0 +1,110 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Schedule } from "../../../services/calcom";
interface DaySchedule {
day: string;
available: boolean;
startTime?: string;
endTime?: string;
}
interface AvailabilityTabProps {
selectedSchedule: Schedule | null;
setShowScheduleDropdown: (show: boolean) => void;
schedulesLoading: boolean;
scheduleDetailsLoading: boolean;
selectedScheduleDetails: any;
getDaySchedule: () => DaySchedule[];
formatTime: (time: string) => string;
selectedTimezone: string;
}
export function AvailabilityTab(props: AvailabilityTabProps) {
return (
<View className="rounded-2xl bg-white p-5">
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Availability</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowScheduleDropdown(true)}
disabled={props.schedulesLoading}
>
<Text className="text-base text-black">
{props.schedulesLoading
? "Loading schedules..."
: props.selectedSchedule
? props.selectedSchedule.name
: "Select schedule"}
</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
{props.selectedSchedule && (
<>
<View
className="mt-5 pt-5"
style={{
borderTopWidth: 1,
borderTopColor: "#E5E5EA",
marginLeft: -20,
marginRight: -20,
paddingLeft: 20,
paddingRight: 20,
}}
>
{props.scheduleDetailsLoading ? (
<View className="items-center py-4">
<Text className="text-sm italic text-[#8E8E93]">Loading schedule details...</Text>
</View>
) : props.selectedScheduleDetails ? (
props.getDaySchedule().map((dayInfo, index) => (
<View key={index} className="flex-row items-center justify-between py-4">
<Text
className={`ml-2 flex-1 text-[15px] font-medium text-[#333] ${
!dayInfo.available ? "text-[#8E8E93] line-through" : ""
}`}
>
{dayInfo.day}
</Text>
<Text className="mr-4 text-right text-[15px] text-[#666]">
{dayInfo.available && dayInfo.startTime && dayInfo.endTime
? `${props.formatTime(dayInfo.startTime)} - ${props.formatTime(dayInfo.endTime)}`
: "Unavailable"}
</Text>
</View>
))
) : (
<View className="items-center py-4">
<Text className="text-sm italic text-[#8E8E93]">
Failed to load schedule details
</Text>
</View>
)}
</View>
<View
className="mt-5 pt-5"
style={{
borderTopWidth: 1,
borderTopColor: "#E5E5EA",
marginLeft: -20,
marginRight: -20,
paddingLeft: 20,
paddingRight: 20,
}}
>
<Text className="mb-1.5 text-base font-semibold text-[#333]">Timezone</Text>
<View className="items-center rounded-lg bg-[#F8F9FA] px-3 py-3">
<Text className="text-center text-base text-[#666]">
{props.selectedTimezone || props.selectedScheduleDetails?.timeZone || "No timezone"}
</Text>
</View>
</View>
</>
)}
</View>
);
}
@@ -0,0 +1,257 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { SvgImage } from "../../../components/SvgImage";
import { defaultLocations, DefaultLocationType } from "../../../utils/defaultLocations";
import { slugify } from "../../../utils/slugify";
interface BasicsTabProps {
// Title, description, URL
eventTitle: string;
setEventTitle: (value: string) => void;
eventDescription: string;
setEventDescription: (value: string) => void;
username: string;
eventSlug: string;
setEventSlug: (value: string) => void;
// Duration
allowMultipleDurations: boolean;
setAllowMultipleDurations: (value: boolean) => void;
eventDuration: string;
setEventDuration: (value: string) => void;
selectedDurations: string[];
setShowDurationDropdown: (show: boolean) => void;
defaultDuration: string;
setShowDefaultDurationDropdown: (show: boolean) => void;
// Location
selectedLocation: string;
setShowLocationDropdown: (show: boolean) => void;
conferencingLoading: boolean;
getSelectedLocationIconUrl: () => string | null;
locationAddress: string;
setLocationAddress: (value: string) => void;
locationLink: string;
setLocationLink: (value: string) => void;
locationPhone: string;
setLocationPhone: (value: string) => void;
}
export function BasicsTab(props: BasicsTabProps) {
return (
<View className="gap-3">
{/* Title and Description Card */}
<View className="rounded-2xl bg-white p-5">
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Title</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.eventTitle}
onChangeText={props.setEventTitle}
placeholder="Enter event title"
placeholderTextColor="#8E8E93"
/>
</View>
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Description</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
style={{ height: 100, textAlignVertical: "top" }}
value={props.eventDescription}
onChangeText={props.setEventDescription}
placeholder="Enter event description"
placeholderTextColor="#8E8E93"
multiline
numberOfLines={4}
/>
</View>
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">URL</Text>
<View className="flex-row items-center overflow-hidden rounded-lg border border-[#E5E5EA] bg-[#F8F9FA]">
<Text className="rounded-bl-lg rounded-tl-lg bg-[#E5E5EA] px-3 py-3 text-base text-[#666]">
cal.com/{props.username}/
</Text>
<TextInput
className="flex-1 px-3 py-3 text-base text-black"
value={props.eventSlug}
onChangeText={(text) => props.setEventSlug(slugify(text, true))}
placeholder="event-slug"
placeholderTextColor="#8E8E93"
/>
</View>
</View>
</View>
{/* Duration Card */}
<View className="rounded-2xl bg-white p-5">
{!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">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={props.eventDuration}
onChangeText={props.setEventDuration}
placeholder="30"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<Text className="text-base text-[#666]">Minutes</Text>
</View>
</View>
)}
{props.allowMultipleDurations && (
<>
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">
Available durations
</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowDurationDropdown(true)}
>
<Text className="text-base text-black">
{props.selectedDurations.length > 0
? `${props.selectedDurations.length} duration${
props.selectedDurations.length > 1 ? "s" : ""
} selected`
: "Select durations"}
</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
{props.selectedDurations.length > 0 && (
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Default duration</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowDefaultDurationDropdown(true)}
>
<Text className="text-base text-black">
{props.defaultDuration || "Select default duration"}
</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
)}
</>
)}
<View className="flex-row items-start justify-between">
<Text className="mb-1 text-base font-medium text-[#333]">Allow multiple durations</Text>
<Switch
value={props.allowMultipleDurations}
onValueChange={props.setAllowMultipleDurations}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Location Card */}
<View className="rounded-2xl bg-white p-5">
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Location</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowLocationDropdown(true)}
disabled={props.conferencingLoading}
>
<View className="flex-1 flex-row items-center">
{!props.conferencingLoading &&
props.selectedLocation &&
props.getSelectedLocationIconUrl() && (
<SvgImage
uri={props.getSelectedLocationIconUrl()!}
width={20}
height={20}
style={{ marginRight: 8 }}
/>
)}
<Text className="text-base text-black">
{props.conferencingLoading
? "Loading locations..."
: props.selectedLocation || "Select location"}
</Text>
</View>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
{/* Location Input Fields - shown conditionally based on selected location type */}
{(() => {
const currentLocation = defaultLocations.find(
(loc) => loc.label === props.selectedLocation
);
if (!currentLocation || !currentLocation.organizerInputType) {
return null;
}
if (currentLocation.organizerInputType === "text") {
// Text input for address or link
const isAddress = currentLocation.type === "inPerson";
const isLink = currentLocation.type === "link";
return (
<View className="mt-3">
<Text className="mb-1.5 text-sm font-medium text-[#333]">
{currentLocation.organizerInputLabel ||
(isAddress ? "Address" : "Meeting Link")}
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 text-base text-[#333] md:px-4"
placeholder={currentLocation.organizerInputPlaceholder || ""}
value={isAddress ? props.locationAddress : props.locationLink}
onChangeText={(text) => {
if (isAddress) {
props.setLocationAddress(text);
} else {
props.setLocationLink(text);
}
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType={isLink ? "url" : "default"}
/>
{currentLocation.messageForOrganizer && (
<Text className="mt-2 text-xs text-[#666]">
{currentLocation.messageForOrganizer}
</Text>
)}
</View>
);
} else if (currentLocation.organizerInputType === "phone") {
// Phone input
return (
<View className="mt-3">
<Text className="mb-1.5 text-sm font-medium text-[#333]">
{currentLocation.organizerInputLabel || "Phone Number"}
</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-3 text-base text-[#333] md:px-4"
placeholder={currentLocation.organizerInputPlaceholder || "Enter phone number"}
value={props.locationPhone}
onChangeText={props.setLocationPhone}
autoCapitalize="none"
autoCorrect={false}
keyboardType="phone-pad"
/>
{currentLocation.messageForOrganizer && (
<Text className="mt-2 text-xs text-[#666]">
{currentLocation.messageForOrganizer}
</Text>
)}
</View>
);
}
return null;
})()}
</View>
</View>
</View>
);
}
@@ -0,0 +1,500 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Animated } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import {
bufferTimeOptions,
timeUnitOptions,
frequencyUnitOptions,
durationUnitOptions,
slotIntervalOptions,
} from "../constants";
interface FrequencyLimit {
id: number;
value: string;
unit: string;
}
interface DurationLimit {
id: number;
value: string;
unit: string;
}
interface LimitsTabProps {
// Buffer times
beforeEventBuffer: string;
setShowBeforeBufferDropdown: (show: boolean) => void;
afterEventBuffer: string;
setShowAfterBufferDropdown: (show: boolean) => void;
// Minimum notice
minimumNoticeValue: string;
setMinimumNoticeValue: (value: string) => void;
minimumNoticeUnit: string;
setShowMinimumNoticeUnitDropdown: (show: boolean) => void;
// Slot interval
slotInterval: string;
setShowSlotIntervalDropdown: (show: boolean) => void;
// Booking frequency
limitBookingFrequency: boolean;
toggleBookingFrequency: (value: boolean) => void;
frequencyAnimationValue: Animated.Value;
frequencyLimits: FrequencyLimit[];
updateFrequencyLimit: (id: number, field: string, value: string) => void;
setShowFrequencyUnitDropdown: (id: number) => void;
removeFrequencyLimit: (id: number) => void;
addFrequencyLimit: () => void;
// Total duration
limitTotalDuration: boolean;
toggleTotalDuration: (value: boolean) => void;
durationAnimationValue: Animated.Value;
durationLimits: DurationLimit[];
updateDurationLimit: (id: number, field: string, value: string) => void;
setShowDurationUnitDropdown: (id: number) => void;
removeDurationLimit: (id: number) => void;
addDurationLimit: () => void;
// Only show first slot
onlyShowFirstAvailableSlot: boolean;
setOnlyShowFirstAvailableSlot: (value: boolean) => void;
// Max active bookings
maxActiveBookingsPerBooker: boolean;
setMaxActiveBookingsPerBooker: (value: boolean) => void;
maxActiveBookingsValue: string;
setMaxActiveBookingsValue: (value: string) => void;
// Future bookings
limitFutureBookings: boolean;
setLimitFutureBookings: (value: boolean) => void;
futureBookingType: "rolling" | "range";
setFutureBookingType: (type: "rolling" | "range") => void;
rollingDays: string;
setRollingDays: (value: string) => void;
rollingCalendarDays: boolean;
setRollingCalendarDays: (value: boolean) => void;
rangeStartDate: string;
setRangeStartDate: (value: string) => void;
rangeEndDate: string;
setRangeEndDate: (value: string) => void;
}
export function LimitsTab(props: LimitsTabProps) {
return (
<View className="gap-3">
{/* Buffer Time, Minimum Notice, and Slot Interval Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="mb-3">
<Text className="mb-1.5 text-base font-semibold text-[#333]">Before event</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowBeforeBufferDropdown(true)}
>
<Text className="text-base text-black">{props.beforeEventBuffer}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
<View
className="mb-3"
style={{ borderTopWidth: 1, borderTopColor: "#E5E5EA", paddingTop: 12, marginTop: 12 }}
>
<Text className="mb-1.5 text-base font-semibold text-[#333]">After event</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowAfterBufferDropdown(true)}
>
<Text className="text-base text-black">{props.afterEventBuffer}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
<View
className="mb-3"
style={{ borderTopWidth: 1, borderTopColor: "#E5E5EA", paddingTop: 12, marginTop: 12 }}
>
<Text className="mb-1.5 text-base font-semibold text-[#333]">Minimum Notice</Text>
<View className="flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={props.minimumNoticeValue}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.setMinimumNoticeValue(numericValue || "0");
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<TouchableOpacity
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowMinimumNoticeUnitDropdown(true)}
>
<Text className="text-base text-black">{props.minimumNoticeUnit}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
</View>
<View
style={{ borderTopWidth: 1, borderTopColor: "#E5E5EA", paddingTop: 12, marginTop: 12 }}
>
<Text className="mb-1.5 text-base font-semibold text-[#333]">Time-slot intervals</Text>
<TouchableOpacity
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowSlotIntervalDropdown(true)}
>
<Text className="text-base text-black">
{props.slotInterval === "Default" ? "Use event length (default)" : props.slotInterval}
</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
</View>
{/* Booking Frequency Limit Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Limit booking frequency</Text>
<Text className="text-sm leading-5 text-[#666]">
Limit how many times this event can be booked.
</Text>
</View>
<Switch
value={props.limitBookingFrequency}
onValueChange={props.toggleBookingFrequency}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
<Animated.View
style={[
{ overflow: "hidden" },
{
opacity: props.frequencyAnimationValue,
maxHeight: props.frequencyAnimationValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 500],
}),
},
]}
>
{props.limitBookingFrequency && (
<>
{props.frequencyLimits.map((limit, index) => (
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={limit.value}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.updateFrequencyLimit(limit.id, "value", numericValue || "0");
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<TouchableOpacity
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowFrequencyUnitDropdown(limit.id)}
>
<Text className="text-base text-black">{limit.unit}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
{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="#FF3B30" />
</TouchableOpacity>
)}
</View>
))}
<TouchableOpacity
className="mt-4 flex-row items-center justify-center gap-2 rounded-lg border border-black bg-transparent px-2 py-3 md:px-4"
onPress={props.addFrequencyLimit}
>
<Ionicons name="add" size={20} color="#000" />
<Text className="text-base font-medium text-black">Add Limit</Text>
</TouchableOpacity>
</>
)}
</Animated.View>
</View>
{/* Total Booking Duration Limit Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Limit total booking duration
</Text>
<Text className="text-sm leading-5 text-[#666]">
Limit total amount of time that this event can be booked.
</Text>
</View>
<Switch
value={props.limitTotalDuration}
onValueChange={props.toggleTotalDuration}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
<Animated.View
style={[
{ overflow: "hidden" },
{
opacity: props.durationAnimationValue,
maxHeight: props.durationAnimationValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 500],
}),
},
]}
>
{props.limitTotalDuration && (
<>
{props.durationLimits.map((limit, index) => (
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
<View className="flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={limit.value}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.updateDurationLimit(limit.id, "value", numericValue || "0");
}
}}
placeholder="60"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<Text className="text-base text-[#666]">Minutes</Text>
</View>
<TouchableOpacity
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => props.setShowDurationUnitDropdown(limit.id)}
>
<Text className="text-base text-black">{limit.unit}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
{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="#FF3B30" />
</TouchableOpacity>
)}
</View>
))}
<TouchableOpacity
className="mt-4 flex-row items-center justify-center gap-2 rounded-lg border border-black bg-transparent px-2 py-3 md:px-4"
onPress={props.addDurationLimit}
>
<Ionicons name="add" size={20} color="#000" />
<Text className="text-base font-medium text-black">Add Limit</Text>
</TouchableOpacity>
</>
)}
</Animated.View>
</View>
{/* Only Show First Available Slot Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Only show the first slot of each day as available
</Text>
<Text className="text-sm leading-5 text-[#666]">
This will limit your availability for this event type to one slot per day, scheduled
at the earliest available time.
</Text>
</View>
<Switch
value={props.onlyShowFirstAvailableSlot}
onValueChange={props.setOnlyShowFirstAvailableSlot}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Max Active Bookings Per Booker Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="mb-3 flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">
Limit number of upcoming bookings per booker
</Text>
<Text className="text-sm leading-5 text-[#666]">
Limit the number of active bookings a booker can make for this event type.
</Text>
</View>
<Switch
value={props.maxActiveBookingsPerBooker}
onValueChange={props.setMaxActiveBookingsPerBooker}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{props.maxActiveBookingsPerBooker && (
<View className="mt-3">
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.maxActiveBookingsValue}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.setMaxActiveBookingsValue(numericValue || "1");
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
</View>
)}
</View>
{/* Limit Future Bookings Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="mb-3 flex-row items-start justify-between">
<View className="mr-4 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Limit future bookings</Text>
<Text className="text-sm leading-5 text-[#666]">
Limit how far in the future this event can be booked.
</Text>
</View>
<Switch
value={props.limitFutureBookings}
onValueChange={props.setLimitFutureBookings}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
{props.limitFutureBookings && (
<View className="mt-3 gap-3">
<View className="flex-row items-center gap-3">
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
props.futureBookingType === "rolling"
? "border-[#333] bg-[#F0F0F0]"
: "border-[#E5E5EA] bg-[#F8F9FA]"
}`}
onPress={() => props.setFutureBookingType("rolling")}
>
<Text
className={`text-base ${
props.futureBookingType === "rolling"
? "font-semibold text-[#333]"
: "text-[#666]"
}`}
>
Rolling
</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
props.futureBookingType === "range"
? "border-[#333] bg-[#F0F0F0]"
: "border-[#E5E5EA] bg-[#F8F9FA]"
}`}
onPress={() => props.setFutureBookingType("range")}
>
<Text
className={`text-base ${
props.futureBookingType === "range"
? "font-semibold text-[#333]"
: "text-[#666]"
}`}
>
Date Range
</Text>
</TouchableOpacity>
</View>
{props.futureBookingType === "rolling" && (
<View className="gap-3">
<View className="flex-row items-center gap-3">
<TextInput
className="flex-1 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.rollingDays}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
const num = parseInt(numericValue) || 0;
if (num >= 0) {
props.setRollingDays(numericValue || "30");
}
}}
placeholder="30"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<TouchableOpacity
className={`flex-1 flex-row items-center justify-center rounded-lg border px-3 py-3 ${
props.rollingCalendarDays
? "border-[#333] bg-[#F0F0F0]"
: "border-[#E5E5EA] bg-[#F8F9FA]"
}`}
onPress={() => props.setRollingCalendarDays(!props.rollingCalendarDays)}
>
<Text
className={`text-base ${
props.rollingCalendarDays ? "font-semibold text-[#333]" : "text-[#666]"
}`}
>
{props.rollingCalendarDays ? "Calendar days" : "Business days"}
</Text>
</TouchableOpacity>
</View>
<Text className="text-sm text-[#666]">days into the future</Text>
</View>
)}
{props.futureBookingType === "range" && (
<View className="gap-3">
<View>
<Text className="mb-1.5 text-sm text-[#666]">Start date</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.rangeStartDate}
onChangeText={props.setRangeStartDate}
placeholder="YYYY-MM-DD"
placeholderTextColor="#8E8E93"
/>
</View>
<View>
<Text className="mb-1.5 text-sm text-[#666]">End date</Text>
<TextInput
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
value={props.rangeEndDate}
onChangeText={props.setRangeEndDate}
placeholder="YYYY-MM-DD"
placeholderTextColor="#8E8E93"
/>
</View>
</View>
)}
</View>
)}
</View>
</View>
);
}
@@ -0,0 +1,137 @@
import React from "react";
import { View, Text, TextInput, TouchableOpacity, Switch, Alert } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface RecurringTabProps {
recurringEnabled: boolean;
setRecurringEnabled: (value: boolean) => void;
recurringInterval: string;
setRecurringInterval: (value: string) => void;
recurringFrequency: "daily" | "weekly" | "monthly" | "yearly";
setRecurringFrequency: (value: "daily" | "weekly" | "monthly" | "yearly") => void;
recurringOccurrences: string;
setRecurringOccurrences: (value: string) => void;
}
export function RecurringTab({
recurringEnabled,
setRecurringEnabled,
recurringInterval,
setRecurringInterval,
recurringFrequency,
setRecurringFrequency,
recurringOccurrences,
setRecurringOccurrences,
}: RecurringTabProps) {
return (
<View className="gap-3">
{/* Recurring Event Toggle Card */}
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<View className="flex-row items-start justify-between">
<View className="mr-3 flex-1">
<Text className="mb-1 text-base font-medium text-[#333]">Recurring event</Text>
<Text className="text-sm text-[#666]">
Set up this event type to repeat at regular intervals
</Text>
</View>
<Switch
value={recurringEnabled}
onValueChange={setRecurringEnabled}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#FFFFFF"
/>
</View>
</View>
{/* Recurring Configuration Card - shown when enabled */}
{recurringEnabled && (
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
<Text className="mb-4 text-base font-semibold text-[#333]">Recurrence pattern</Text>
{/* Repeats Every */}
<View className="mb-4">
<Text className="mb-2 text-sm font-medium text-[#333]">Repeats every</Text>
<View className="flex-row items-center gap-3">
<TextInput
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={recurringInterval}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
// Don't allow empty or 0 values; fall back to 1 so users can keep editing
if (numericValue === "" || numericValue === "0") {
setRecurringInterval("1");
return;
}
const num = parseInt(numericValue);
if (num >= 1 && num <= 20) {
setRecurringInterval(numericValue);
}
}}
placeholder="1"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<TouchableOpacity
className="flex-1 flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
onPress={() => {
Alert.alert("Select Frequency", "Choose how often this event repeats", [
{
text: "Daily",
onPress: () => setRecurringFrequency("daily"),
},
{
text: "Weekly",
onPress: () => setRecurringFrequency("weekly"),
},
{
text: "Monthly",
onPress: () => setRecurringFrequency("monthly"),
},
{
text: "Yearly",
onPress: () => setRecurringFrequency("yearly"),
},
{ text: "Cancel", style: "cancel" },
]);
}}
>
<Text className="text-base capitalize text-black">{recurringFrequency}</Text>
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
</TouchableOpacity>
</View>
</View>
{/* Maximum Occurrences */}
<View>
<Text className="mb-2 text-sm font-medium text-[#333]">Maximum number of events</Text>
<View className="flex-row items-center gap-2">
<TextInput
className="w-24 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
value={recurringOccurrences}
onChangeText={(text) => {
const numericValue = text.replace(/[^0-9]/g, "");
// Don't allow empty or 0 values; fall back to 1 so users can keep editing
if (numericValue === "" || numericValue === "0") {
setRecurringOccurrences("1");
return;
}
const num = parseInt(numericValue);
if (num >= 1) {
setRecurringOccurrences(numericValue);
}
}}
placeholder="12"
placeholderTextColor="#8E8E93"
keyboardType="numeric"
/>
<Text className="text-sm text-[#666]">occurrences</Text>
</View>
<Text className="mt-2 text-xs text-[#666]">
The booking will create {recurringOccurrences} events that repeat {recurringFrequency}
</Text>
</View>
</View>
)}
</View>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Utility functions for Event Type Detail
export const formatDuration = (minutes: string) => {
const mins = parseInt(minutes) || 0;
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
};
export const truncateTitle = (text: string, maxLength: number = 20) => {
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
export const formatAppIdToDisplayName = (appId: string): string => {
// Convert appId like "google-meet" to "Google Meet"
return appId
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
export const displayNameToLocationValue = (
displayName: string,
defaultLocations: Array<{ label: string; type: string }>
): {
type: string;
integration?: string;
address?: string;
link?: string;
phone?: string;
public?: boolean;
} | null => {
// First check if it's a default location
const defaultLocation = defaultLocations.find((loc) => loc.label === displayName);
if (defaultLocation) {
// Map internal location types to API location types
switch (defaultLocation.type) {
case "attendeeInPerson":
return { type: "attendeeAddress" };
case "inPerson":
return { type: "address", address: "", public: true };
case "link":
return { type: "link", link: "", public: true };
case "phone":
return { type: "attendeePhone" };
case "userPhone":
return { type: "phone", phone: "", public: true };
default:
return null;
}
}
// Check if it's a conferencing app (formatted display name)
// e.g., "Google Meet", "Zoom", etc.
const appId = displayName.toLowerCase().replace(/\s+/g, "-");
return { type: "integration", integration: appId };
};
+5
View File
@@ -0,0 +1,5 @@
import { Redirect } from "expo-router";
export default function Index() {
return <Redirect href="/(tabs)/event-types" />;
}
+97
View File
@@ -0,0 +1,97 @@
import React, { useEffect } from "react";
import { View, Text, ActivityIndicator } from "react-native";
import { useRouter, useLocalSearchParams } from "expo-router";
export default function OAuthCallback() {
const router = useRouter();
const params = useLocalSearchParams();
useEffect(() => {
// Extract code, state, and error parameters from URL
const code = params.code as string;
const state = params.state as string;
const error = params.error as string;
const errorDescription = params.error_description as string;
// Handle OAuth error response
if (error) {
const errorMessage = errorDescription || error || "OAuth authorization failed";
console.error("OAuth error:", error, errorDescription);
if (typeof window !== "undefined") {
if (window.opener) {
window.opener.postMessage(
{
type: "OAUTH_ERROR",
error: errorMessage,
errorCode: error,
},
"*"
);
window.close();
} else {
// Store error for main app to pick up
if (state) {
window.localStorage.setItem(`oauth_callback_error_${state}`, errorMessage);
window.localStorage.setItem(`oauth_callback_error_code_${state}`, error);
}
router.replace("/(tabs)");
}
}
return;
}
if (code && state) {
// Store the parameters temporarily using state-specific keys to prevent race conditions
// The OAuth service should be able to pick these up
if (typeof window !== "undefined") {
window.localStorage.setItem(`oauth_callback_code_${state}`, code);
window.localStorage.setItem(`oauth_callback_state_${state}`, state);
// Close the OAuth popup/tab if it exists
if (window.opener) {
window.opener.postMessage(
{
type: "OAUTH_SUCCESS",
code,
state,
},
"*"
);
window.close();
} else {
// Redirect to main app
router.replace("/(tabs)");
}
}
} else {
console.error("No authorization code or state in OAuth callback");
// Handle error case
if (typeof window !== "undefined") {
if (window.opener) {
window.opener.postMessage(
{
type: "OAUTH_ERROR",
error: "No authorization code received",
},
"*"
);
window.close();
} else {
router.replace("/(tabs)");
}
}
}
}, [params, router]);
return (
<View className="flex-1 items-center justify-center bg-white">
<ActivityIndicator size="large" color="#2563eb" />
<Text className="mt-4 text-lg text-gray-700">Processing OAuth callback...</Text>
<Text className="mt-2 text-sm text-gray-500">
Please wait while we complete the authentication.
</Text>
</View>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="101" height="22" viewBox="0 0 101 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0582 20.817C4.32115 20.817 0 16.2763 0 10.6704C0 5.04589 4.1005 0.467773 10.0582 0.467773C13.2209 0.467773 15.409 1.43945 17.1191 3.66311L14.3609 5.96151C13.2025 4.72822 11.805 4.11158 10.0582 4.11158C6.17833 4.11158 4.04533 7.08268 4.04533 10.6704C4.04533 14.2582 6.38059 17.1732 10.0582 17.1732C11.7866 17.1732 13.2577 16.5566 14.4161 15.3233L17.1375 17.7151C15.501 19.8453 13.2577 20.817 10.0582 20.817Z" fill="#292929"/>
<path d="M29.0161 5.88601H32.7304V20.4612H29.0161V18.331C28.2438 19.8446 26.9566 20.8536 24.4927 20.8536C20.5577 20.8536 17.4133 17.4341 17.4133 13.2297C17.4133 9.02528 20.5577 5.60571 24.4927 5.60571C26.9383 5.60571 28.2438 6.61477 29.0161 8.12835V5.88601ZM29.1264 13.2297C29.1264 10.95 27.5634 9.06266 25.0995 9.06266C22.7274 9.06266 21.1828 10.9686 21.1828 13.2297C21.1828 15.4346 22.7274 17.3967 25.0995 17.3967C27.5451 17.3967 29.1264 15.4907 29.1264 13.2297Z" fill="#292929"/>
<path d="M35.3599 0H39.0742V20.4427H35.3599V0Z" fill="#292929"/>
<path d="M40.7291 18.5182C40.7291 17.3223 41.6853 16.3132 42.9908 16.3132C44.2964 16.3132 45.2158 17.3223 45.2158 18.5182C45.2158 19.7515 44.278 20.7605 42.9908 20.7605C41.7037 20.7605 40.7291 19.7515 40.7291 18.5182Z" fill="#292929"/>
<path d="M59.4296 18.1068C58.0505 19.7885 55.9543 20.8536 53.4719 20.8536C49.0404 20.8536 45.7858 17.4341 45.7858 13.2297C45.7858 9.02528 49.0404 5.60571 53.4719 5.60571C55.8623 5.60571 57.9402 6.61477 59.3193 8.20309L56.4508 10.6136C55.7336 9.71667 54.7958 9.04397 53.4719 9.04397C51.0999 9.04397 49.5553 10.95 49.5553 13.211C49.5553 15.472 51.0999 17.378 53.4719 17.378C54.9062 17.378 55.8991 16.6306 56.6346 15.6215L59.4296 18.1068Z" fill="#292929"/>
<path d="M59.7422 13.2297C59.7422 9.02528 62.9968 5.60571 67.4283 5.60571C71.8598 5.60571 75.1144 9.02528 75.1144 13.2297C75.1144 17.4341 71.8598 20.8536 67.4283 20.8536C62.9968 20.8349 59.7422 17.4341 59.7422 13.2297ZM71.3449 13.2297C71.3449 10.95 69.8003 9.06266 67.4283 9.06266C65.0563 9.04397 63.5117 10.95 63.5117 13.2297C63.5117 15.4907 65.0563 17.3967 67.4283 17.3967C69.8003 17.3967 71.3449 15.4907 71.3449 13.2297Z" fill="#292929"/>
<path d="M100.232 11.5482V20.4428H96.518V12.4638C96.518 9.94119 95.3412 8.85739 93.576 8.85739C91.921 8.85739 90.7442 9.67958 90.7442 12.4638V20.4428H87.0299V12.4638C87.0299 9.94119 85.8346 8.85739 84.0878 8.85739C82.4329 8.85739 80.9802 9.67958 80.9802 12.4638V20.4428H77.2659V5.8676H80.9802V7.88571C81.7525 6.31607 83.15 5.53125 85.3014 5.53125C87.3425 5.53125 89.0525 6.5403 89.9903 8.24074C90.9281 6.50293 92.3072 5.53125 94.8079 5.53125C97.8603 5.54994 100.232 7.86702 100.232 11.5482Z" fill="#292929"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel"],
};
};
@@ -0,0 +1,21 @@
import { useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import { setAuthToken, setWebSession } from "../services/calcom";
export function AuthTokenProvider({ children }: { children: React.ReactNode }) {
const { accessToken, isAuthenticated, isWebSession } = useAuth();
useEffect(() => {
if (isAuthenticated) {
if (accessToken) {
setAuthToken(accessToken);
}
setWebSession(isWebSession);
} else {
setAuthToken(null);
setWebSession(false);
}
}, [isAuthenticated, accessToken, isWebSession]);
return <>{children}</>;
}
+40
View File
@@ -0,0 +1,40 @@
import React from "react";
import Svg, { Path } from "react-native-svg";
interface CalComLogoProps {
width?: number;
height?: number;
color?: string;
}
export function CalComLogo({ width = 101, height = 22, color = "#292929" }: CalComLogoProps) {
return (
<Svg width={width} height={height} viewBox="0 0 101 22" fill="none">
<Path
d="M10.0582 20.817C4.32115 20.817 0 16.2763 0 10.6704C0 5.04589 4.1005 0.467773 10.0582 0.467773C13.2209 0.467773 15.409 1.43945 17.1191 3.66311L14.3609 5.96151C13.2025 4.72822 11.805 4.11158 10.0582 4.11158C6.17833 4.11158 4.04533 7.08268 4.04533 10.6704C4.04533 14.2582 6.38059 17.1732 10.0582 17.1732C11.7866 17.1732 13.2577 16.5566 14.4161 15.3233L17.1375 17.7151C15.501 19.8453 13.2577 20.817 10.0582 20.817Z"
fill={color}
/>
<Path
d="M29.0161 5.88601H32.7304V20.4612H29.0161V18.331C28.2438 19.8446 26.9566 20.8536 24.4927 20.8536C20.5577 20.8536 17.4133 17.4341 17.4133 13.2297C17.4133 9.02528 20.5577 5.60571 24.4927 5.60571C26.9383 5.60571 28.2438 6.61477 29.0161 8.12835V5.88601ZM29.1264 13.2297C29.1264 10.95 27.5634 9.06266 25.0995 9.06266C22.7274 9.06266 21.1828 10.9686 21.1828 13.2297C21.1828 15.4346 22.7274 17.3967 25.0995 17.3967C27.5451 17.3967 29.1264 15.4907 29.1264 13.2297Z"
fill={color}
/>
<Path d="M35.3599 0H39.0742V20.4427H35.3599V0Z" fill={color} />
<Path
d="M40.7291 18.5182C40.7291 17.3223 41.6853 16.3132 42.9908 16.3132C44.2964 16.3132 45.2158 17.3223 45.2158 18.5182C45.2158 19.7515 44.278 20.7605 42.9908 20.7605C41.7037 20.7605 40.7291 19.7515 40.7291 18.5182Z"
fill={color}
/>
<Path
d="M59.4296 18.1068C58.0505 19.7885 55.9543 20.8536 53.4719 20.8536C49.0404 20.8536 45.7858 17.4341 45.7858 13.2297C45.7858 9.02528 49.0404 5.60571 53.4719 5.60571C55.8623 5.60571 57.9402 6.61477 59.3193 8.20309L56.4508 10.6136C55.7336 9.71667 54.7958 9.04397 53.4719 9.04397C51.0999 9.04397 49.5553 10.95 49.5553 13.211C49.5553 15.472 51.0999 17.378 53.4719 17.378C54.9062 17.378 55.8991 16.6306 56.6346 15.6215L59.4296 18.1068Z"
fill={color}
/>
<Path
d="M59.7422 13.2297C59.7422 9.02528 62.9968 5.60571 67.4283 5.60571C71.8598 5.60571 75.1144 9.02528 75.1144 13.2297C75.1144 17.4341 71.8598 20.8536 67.4283 20.8536C62.9968 20.8349 59.7422 17.4341 59.7422 13.2297ZM71.3449 13.2297C71.3449 10.95 69.8003 9.06266 67.4283 9.06266C65.0563 9.04397 63.5117 10.95 63.5117 13.2297C63.5117 15.4907 65.0563 17.3967 67.4283 17.3967C69.8003 17.3967 71.3449 15.4907 71.3449 13.2297Z"
fill={color}
/>
<Path
d="M100.232 11.5482V20.4428H96.518V12.4638C96.518 9.94119 95.3412 8.85739 93.576 8.85739C91.921 8.85739 90.7442 9.67958 90.7442 12.4638V20.4428H87.0299V12.4638C87.0299 9.94119 85.8346 8.85739 84.0878 8.85739C82.4329 8.85739 80.9802 9.67958 80.9802 12.4638V20.4428H77.2659V5.8676H80.9802V7.88571C81.7525 6.31607 83.15 5.53125 85.3014 5.53125C87.3425 5.53125 89.0525 6.5403 89.9903 8.24074C90.9281 6.50293 92.3072 5.53125 94.8079 5.53125C97.8603 5.54994 100.232 7.86702 100.232 11.5482Z"
fill={color}
/>
</Svg>
);
}
+65
View File
@@ -0,0 +1,65 @@
import React, { useEffect } from "react";
import { Modal, ModalProps, Platform, View } from "react-native";
interface FullScreenModalProps extends ModalProps {
visible: boolean;
onRequestClose: () => void;
children: React.ReactNode;
}
/**
* A modal wrapper that expands the browser extension iframe to full screen
* when running in the web platform (browser extension).
*
* For mobile platforms, it behaves like a regular Modal.
*/
export function FullScreenModal({
visible,
onRequestClose,
children,
...modalProps
}: FullScreenModalProps) {
useEffect(() => {
// Only send expand/collapse messages on web platform (browser extension)
if (Platform.OS !== "web") {
return;
}
if (visible) {
// Expand the iframe to full width when modal opens
window.parent.postMessage({ type: "cal-companion-expand" }, "*");
} else {
// Collapse the iframe back to 400px when modal closes
window.parent.postMessage({ type: "cal-companion-collapse" }, "*");
}
// Cleanup: collapse on unmount if still visible
return () => {
if (visible && Platform.OS === "web") {
window.parent.postMessage({ type: "cal-companion-collapse" }, "*");
}
};
}, [visible]);
// For non-web platforms, just use the regular Modal
if (Platform.OS !== "web") {
return (
<Modal visible={visible} onRequestClose={onRequestClose} {...modalProps}>
{children}
</Modal>
);
}
// For web platform (browser extension), use the modal with full-screen behavior
return (
<Modal
visible={visible}
onRequestClose={onRequestClose}
transparent
animationType="fade"
{...modalProps}
>
{children}
</Modal>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { useState, useEffect } from "react";
import {
View,
Text,
TouchableOpacity,
Image,
ActivityIndicator,
Modal,
Alert,
Linking,
Platform,
ActionSheetIOS,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, UserProfile } from "../services/calcom";
import { CalComLogo } from "./CalComLogo";
import { FullScreenModal } from "./FullScreenModal";
export function Header() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [showProfileModal, setShowProfileModal] = useState(false);
useEffect(() => {
fetchUserProfile();
}, []);
const fetchUserProfile = async () => {
try {
const profile = await CalComAPIService.getUserProfile();
setUserProfile(profile);
} catch (error) {
console.error("Failed to fetch user profile:", error);
} finally {
setLoading(false);
}
};
const handleProfile = () => {
if (Platform.OS === "ios") {
const options = ["Cancel", "My Profile", "My Settings", "Out of Office", "Help", "Sign Out"];
ActionSheetIOS.showActionSheetWithOptions(
{
options,
destructiveButtonIndex: 6, // Sign Out
cancelButtonIndex: 0,
title: userProfile?.name || "Profile Menu",
},
(buttonIndex) => {
switch (buttonIndex) {
case 1: // My Profile
handleMenuOption("profile");
break;
case 2: // My Settings
handleMenuOption("settings");
break;
case 3: // Out of Office
handleMenuOption("outOfOffice");
break;
case 4: // Support
handleMenuOption("help");
break;
case 5: // Sign Out
handleMenuOption("signOut");
break;
}
}
);
} else {
setShowProfileModal(true);
}
};
const openExternalLink = async (url: string, fallbackMessage: string) => {
try {
const supported = await Linking.canOpenURL(url);
if (supported) {
await Linking.openURL(url);
} else {
Alert.alert("Error", `Cannot open ${fallbackMessage} on your device.`);
}
} catch (error) {
console.error(`Failed to open ${url}:`, error);
Alert.alert("Error", `Failed to open ${fallbackMessage}. Please try again.`);
}
};
const handleMenuOption = (option: string) => {
if (Platform.OS !== "ios") {
setShowProfileModal(false);
}
switch (option) {
case "profile":
openExternalLink("https://app.cal.com/settings/my-account/profile", "Profile page");
break;
case "settings":
openExternalLink("https://app.cal.com/settings/my-account", "Settings page");
break;
case "outOfOffice":
openExternalLink(
"https://app.cal.com/settings/my-account/out-of-office",
"Out of Office page"
);
break;
case "roadmap":
openExternalLink("https://cal.com/roadmap", "Roadmap");
break;
case "help":
openExternalLink("https://cal.com/help", "Help page");
break;
case "signOut":
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: () => {
// TODO: Implement sign out
console.log("Sign Out pressed");
},
},
]);
break;
}
};
return (
<>
<View
className="flex-row items-center justify-between border-b border-[#E5E5EA] bg-white px-2 md:px-4"
style={{ paddingTop: insets.top + 4, paddingBottom: 4 }}
>
{/* Left: Cal.com Logo */}
<View className="ms-1">
<CalComLogo width={101} height={22} color="#333" />
</View>
{/* Right: Icons */}
<View
className="flex-row items-center gap-4"
style={Platform.OS === "web" ? { marginRight: 8 } : {}}
>
{/* Profile Picture */}
<TouchableOpacity onPress={handleProfile} className="p-1">
{loading ? (
<ActivityIndicator size="small" color="#666" />
) : userProfile?.avatarUrl ? (
<Image
source={{ uri: userProfile.avatarUrl }}
className="h-8 w-8 rounded-full"
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
) : (
<View
className="items-center justify-center rounded-full bg-[#E5E5EA]"
style={{ width: 32, height: 32 }}
>
<Ionicons name="person-outline" size={20} color="#666" />
</View>
)}
</TouchableOpacity>
</View>
</View>
{/* Profile Menu Modal */}
<FullScreenModal
visible={showProfileModal}
animationType="fade"
transparent
onRequestClose={() => setShowProfileModal(false)}
>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
activeOpacity={1}
onPress={() => setShowProfileModal(false)}
>
<TouchableOpacity
className="mx-4 w-full max-w-sm rounded-2xl bg-white"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Header with profile info */}
<View className="border-b border-gray-200 p-6">
<View className="flex-row items-center">
<View className="mr-3 h-12 w-12 items-center justify-center rounded-full bg-black">
{userProfile?.avatarUrl ? (
<Image
source={{ uri: userProfile.avatarUrl }}
className="h-12 w-12 rounded-full"
style={{ width: 48, height: 48, borderRadius: 24 }}
/>
) : (
<Text className="text-lg font-semibold text-white">
{userProfile?.name
? userProfile.name.charAt(0).toUpperCase()
: userProfile?.email?.charAt(0).toUpperCase() || "?"}
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-lg font-semibold text-gray-900">
{userProfile?.name || "User"}
</Text>
{userProfile?.email && (
<Text className="text-sm text-gray-600">{userProfile.email}</Text>
)}
</View>
</View>
</View>
{/* Menu Items */}
<View className="p-2">
<TouchableOpacity
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
onPress={() => handleMenuOption("profile")}
>
<View className="flex-row items-center">
<Ionicons name="person-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">My Profile</Text>
</View>
<Ionicons name="open-outline" size={16} color="#6B7280" />
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
onPress={() => handleMenuOption("settings")}
>
<View className="flex-row items-center">
<Ionicons name="settings-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">My Settings</Text>
</View>
<Ionicons name="open-outline" size={16} color="#6B7280" />
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
onPress={() => handleMenuOption("outOfOffice")}
>
<View className="flex-row items-center">
<Ionicons name="moon-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Out of Office</Text>
</View>
<Ionicons name="open-outline" size={16} color="#6B7280" />
</TouchableOpacity>
<View className="mx-4 my-2 h-px bg-gray-200" />
<TouchableOpacity
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
onPress={() => handleMenuOption("roadmap")}
>
<View className="flex-row items-center">
<Ionicons name="map-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Roadmap</Text>
</View>
<Ionicons name="open-outline" size={16} color="#6B7280" />
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center justify-between p-2 hover:bg-gray-50 md:p-4"
onPress={() => handleMenuOption("help")}
>
<View className="flex-row items-center">
<Ionicons name="help-circle-outline" size={20} color="#6B7280" />
<Text className="ml-3 text-base text-gray-900">Help</Text>
</View>
<Ionicons name="open-outline" size={16} color="#6B7280" />
</TouchableOpacity>
<View className="mx-4 my-2 h-px bg-gray-200" />
<TouchableOpacity
className="flex-row items-center p-2 hover:bg-gray-50 md:p-4"
onPress={() => handleMenuOption("signOut")}
>
<Ionicons name="log-out-outline" size={20} color="#EF4444" />
<Text className="ml-3 text-base text-red-500">Sign Out</Text>
</TouchableOpacity>
</View>
{/* Cancel button */}
<View className="border-t border-gray-200 p-2 md:p-4">
<TouchableOpacity
className="w-full rounded-lg bg-gray-100 p-3"
onPress={() => setShowProfileModal(false)}
>
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
</>
);
}
+48
View File
@@ -0,0 +1,48 @@
import React from "react";
import { View, Text, TouchableOpacity, Alert, ActivityIndicator } from "react-native";
import { useAuth } from "../contexts/AuthContext";
export function LoginScreen() {
const { loginWithOAuth, loading } = useAuth();
const handleOAuthLogin = async () => {
try {
await loginWithOAuth();
} catch (error) {
console.error("OAuth login error:", error);
Alert.alert(
"Login Failed",
error instanceof Error
? error.message
: "Failed to login with OAuth. Please check your configuration and try again.",
[{ text: "OK" }]
);
}
};
return (
<View className="flex-1 justify-center bg-white px-6">
<View className="mb-8">
<Text className="mb-2 text-center text-3xl font-bold text-gray-900">
Welcome to Cal.com Companion
</Text>
<Text className="text-center text-gray-600">
Sign in to manage your bookings and event types
</Text>
</View>
<TouchableOpacity
onPress={handleOAuthLogin}
disabled={loading}
className="mb-4 flex-row items-center justify-center rounded-lg bg-gray-900 px-6 py-4"
style={{ opacity: loading ? 0.7 : 1 }}
>
{loading ? <ActivityIndicator size="small" color="white" className="mr-2" /> : null}
<Text className="text-lg font-semibold text-white">
{loading ? "Signing in..." : "Sign in with Cal.com"}
</Text>
</TouchableOpacity>
</View>
);
}
export default LoginScreen;
+76
View File
@@ -0,0 +1,76 @@
import React, { useState } from "react";
import { TouchableOpacity, Text, Alert, Platform } from "react-native";
import { useAuth } from "../contexts/AuthContext";
import { LogoutConfirmModal } from "./LogoutConfirmModal";
interface LogoutButtonProps {
className?: string;
variant?: "default" | "destructive" | "ghost";
}
export function LogoutButton({ className = "" }: LogoutButtonProps) {
const { logout } = useAuth();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const handleLogoutPress = () => {
if (Platform.OS === "web") {
setShowConfirmModal(true);
} else {
// Mobile: use Alert.alert
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Sign Out",
style: "destructive",
onPress: performLogout,
},
]);
}
};
const performLogout = async () => {
try {
await logout();
} catch (error) {
console.error("Logout error:", error);
if (Platform.OS === "web") {
window.alert("Failed to sign out. Please try again.");
} else {
Alert.alert("Error", "Failed to sign out. Please try again.");
}
}
};
const handleConfirmLogout = () => {
setShowConfirmModal(false);
performLogout();
};
const handleCancelLogout = () => {
setShowConfirmModal(false);
};
return (
<>
<TouchableOpacity
onPress={handleLogoutPress}
className={`"px-4 rounded-lg" bg-gray-600 py-2 ${className}`}
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<Text className="font-medium text-gray-900">Sign Out</Text>
</TouchableOpacity>
<LogoutConfirmModal
visible={showConfirmModal}
onConfirm={handleConfirmLogout}
onCancel={handleCancelLogout}
/>
</>
);
}
export default LogoutButton;
@@ -0,0 +1,42 @@
import React from "react";
import { View, Text, TouchableOpacity, Platform } from "react-native";
import { FullScreenModal } from "./FullScreenModal";
interface LogoutConfirmModalProps {
visible: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function LogoutConfirmModal({ visible, onConfirm, onCancel }: LogoutConfirmModalProps) {
return (
<FullScreenModal visible={visible} onRequestClose={onCancel}>
<View className="flex-1 items-center justify-center bg-black bg-opacity-50">
<View className="mx-4 min-w-[300px] rounded-lg bg-white p-6 shadow-lg">
<Text className="mb-2 text-lg font-semibold text-gray-900">Sign Out</Text>
<Text className="mb-6 text-gray-600">Are you sure you want to sign out?</Text>
<View className="flex-row justify-end space-x-3">
<TouchableOpacity
onPress={onCancel}
className="rounded-md border border-gray-800 bg-white px-4 py-2"
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<Text className="font-medium text-gray-800">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onConfirm}
className="rounded-md border border-red-800 bg-white px-4 py-2"
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<Text className="font-medium text-red-800">Sign Out</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
);
}
+51
View File
@@ -0,0 +1,51 @@
import React, { useState } from "react";
import { View, Image } from "react-native";
import { SvgUri } from "react-native-svg";
interface SvgImageProps {
uri: string;
width: number;
height: number;
style?: { marginRight?: number };
}
/**
* Component that can display both SVG and regular images
* Falls back to regular Image if SVG fails
*/
export const SvgImage: React.FC<SvgImageProps> = ({ uri, width, height, style }) => {
const [useFallback, setUseFallback] = useState(false);
// Extract pathname (ignore query strings and hash) to detect SVG correctly
// Handles URLs like: icon.svg?variant=dark or icon.svg#hash
const pathname = uri.split("?")[0].split("#")[0];
const isSvg = pathname.toLowerCase().endsWith(".svg");
if (!isSvg || useFallback) {
// Use regular Image for non-SVG or if SVG failed
return (
<View style={style}>
<Image
source={{ uri }}
style={{ width, height }}
resizeMode="contain"
onError={() => console.log("Image load error:", uri)}
/>
</View>
);
}
// Use SvgUri for SVG files
return (
<View style={[{ width, height }, style]}>
<SvgUri
uri={uri}
width={width}
height={height}
onError={(error) => {
console.log("SVG load error, falling back to Image:", error);
setUseFallback(true);
}}
/>
</View>
);
};
+75
View File
@@ -0,0 +1,75 @@
import React, { useState, useRef, useEffect } from "react";
import { View, Text, Platform } from "react-native";
interface TooltipProps {
text: string;
children: React.ReactElement;
}
export function Tooltip({ text, children }: TooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const containerRef = useRef<any>(null);
// Only show tooltips on web platform
if (Platform.OS !== "web") {
return children;
}
const handleMouseEnter = (e: any) => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setPosition({
top: rect.top - 40,
left: rect.left + rect.width / 2 + 20,
});
setShowTooltip(true);
}
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<View
ref={containerRef}
// @ts-ignore - web-only props
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{ position: "relative" }}
>
{children}
{showTooltip && (
<View
style={{
position: "fixed",
top: position.top,
left: position.left,
transform: [{ translateX: -50 }, { translateY: -100 }],
backgroundColor: "#1a1a1a",
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 6,
zIndex: 10002,
pointerEvents: "none",
// @ts-ignore - web-only props
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
}}
>
<Text
style={{
color: "white",
fontSize: 13,
fontWeight: "600",
whiteSpace: "nowrap",
lineHeight: 18,
}}
>
{text}
</Text>
</View>
)}
</View>
);
}
+367
View File
@@ -0,0 +1,367 @@
/// <reference types="chrome" />
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { WebAuthService } from "../services/webAuth";
import { CalComAPIService } from "../services/calcom";
import {
createCalComOAuthService,
OAuthTokens,
CalComOAuthService,
} from "../services/oauthService";
interface AuthContextType {
isAuthenticated: boolean;
accessToken: string | null;
refreshToken: string | null;
userInfo: any;
isWebSession: boolean;
loginFromWebSession: (userInfo: any) => Promise<void>;
loginWithOAuth: () => Promise<void>;
logout: () => Promise<void>;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const ACCESS_TOKEN_KEY = "cal_access_token";
const REFRESH_TOKEN_KEY = "cal_refresh_token";
const OAUTH_TOKENS_KEY = "cal_oauth_tokens";
const AUTH_TYPE_KEY = "cal_auth_type";
type AuthType = "oauth" | "web_session";
interface AuthProviderProps {
children: ReactNode;
}
// Check if chrome.storage is available (browser extension context)
const isChromeStorageAvailable = (): boolean => {
return (
Platform.OS === "web" &&
typeof chrome !== "undefined" &&
chrome.storage !== undefined &&
chrome.storage.local !== undefined
);
};
// Unified storage helper to abstract web/mobile/extension differences
const storage = {
get: async (key: string): Promise<string | null> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve) => {
chrome.storage.local.get([key], (result) => {
resolve(result[key] || null);
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
return localStorage.getItem(key);
}
// Use SecureStore for mobile
return await SecureStore.getItemAsync(key);
},
set: async (key: string, value: string): Promise<void> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
localStorage.setItem(key, value);
return;
}
// Use SecureStore for mobile
await SecureStore.setItemAsync(key, value);
},
remove: async (key: string): Promise<void> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.remove(key, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
localStorage.removeItem(key);
return;
}
// Use SecureStore for mobile
await SecureStore.deleteItemAsync(key);
},
removeAll: async (keys: string[]): Promise<void> => {
// Use chrome.storage in browser extension context (most secure)
if (isChromeStorageAvailable()) {
return new Promise((resolve, reject) => {
chrome.storage.local.remove(keys, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else {
resolve();
}
});
});
}
// Fall back to localStorage for regular web apps
if (Platform.OS === "web") {
keys.forEach((key) => localStorage.removeItem(key));
return;
}
// Use SecureStore for mobile
await Promise.all(keys.map((key) => SecureStore.deleteItemAsync(key)));
},
};
export function AuthProvider({ children }: AuthProviderProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<any>(null);
const [isWebSession, setIsWebSession] = useState(false);
const [loading, setLoading] = useState(true);
const [oauthService] = useState(() => {
try {
return createCalComOAuthService();
} catch (error) {
console.warn("Failed to initialize OAuth service:", error);
return null;
}
});
// Setup refresh token function for OAuth
const setupRefreshTokenFunction = (service: CalComOAuthService) => {
CalComAPIService.setRefreshTokenFunction(async (refreshToken: string) => {
const newTokens = await service.refreshAccessToken(refreshToken);
return {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
};
});
};
// Common post-login setup: configure API service and fetch user profile
const setupAfterLogin = async (token: string, refreshToken?: string) => {
CalComAPIService.setAccessToken(token, refreshToken);
try {
await CalComAPIService.getUserProfile();
} catch (profileError) {
console.error("Failed to fetch user profile:", profileError);
// Don't fail login if profile fetch fails
}
};
// Save OAuth tokens to storage
const saveOAuthTokens = async (tokens: OAuthTokens) => {
await storage.set(OAUTH_TOKENS_KEY, JSON.stringify(tokens));
await storage.set(AUTH_TYPE_KEY, "oauth");
};
// Clear all auth data from storage
const clearAuth = async () => {
await storage.removeAll([ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, OAUTH_TOKENS_KEY, AUTH_TYPE_KEY]);
};
// Reset all auth state
const resetAuthState = () => {
setAccessToken(null);
setRefreshToken(null);
setUserInfo(null);
setIsAuthenticated(false);
setIsWebSession(false);
CalComAPIService.clearAuth();
CalComAPIService.clearUserProfile();
};
useEffect(() => {
checkAuthState();
// Set up token refresh callback
const handleTokenRefresh = async (newAccessToken: string, newRefreshToken?: string) => {
try {
const tokens: OAuthTokens = {
accessToken: newAccessToken,
refreshToken: newRefreshToken || refreshToken || undefined,
tokenType: "Bearer",
};
await saveOAuthTokens(tokens);
setAccessToken(newAccessToken);
if (newRefreshToken) {
setRefreshToken(newRefreshToken);
}
CalComAPIService.setAccessToken(
newAccessToken,
newRefreshToken || refreshToken || undefined
);
} catch (error) {
console.error("Failed to handle token refresh:", error);
await logout();
}
};
CalComAPIService.setTokenRefreshCallback(handleTokenRefresh);
return () => {
CalComAPIService.setTokenRefreshCallback(() => Promise.resolve());
};
}, [refreshToken]);
// Handle OAuth authentication
const handleOAuthAuth = async (storedTokens: OAuthTokens) => {
if (!oauthService) return;
// Refresh token if expired
let tokens = storedTokens;
if (oauthService.isTokenExpired(storedTokens) && storedTokens.refreshToken) {
try {
console.log("Access token expired, refreshing...");
tokens = await oauthService.refreshAccessToken(storedTokens.refreshToken);
await saveOAuthTokens(tokens);
} catch (refreshError) {
console.error("Failed to refresh token:", refreshError);
await clearAuth();
return;
}
}
// Set state
setAccessToken(tokens.accessToken);
setRefreshToken(tokens.refreshToken || null);
setIsAuthenticated(true);
setIsWebSession(false);
// Setup API service and refresh function
await setupAfterLogin(tokens.accessToken, tokens.refreshToken);
if (tokens.refreshToken) {
setupRefreshTokenFunction(oauthService);
}
};
// Handle web session authentication
const handleWebSessionAuth = () => {
setIsWebSession(true);
};
const checkAuthState = async () => {
try {
const authType = (await storage.get(AUTH_TYPE_KEY)) as AuthType | null;
const storedOAuthTokens = await storage.get(OAUTH_TOKENS_KEY);
const storedTokens = storedOAuthTokens ? JSON.parse(storedOAuthTokens) : null;
if (authType === "oauth" && storedTokens && oauthService) {
await handleOAuthAuth(storedTokens);
} else if (authType === "web_session") {
handleWebSessionAuth();
}
} catch (error) {
console.error("Failed to check auth state:", error);
} finally {
setLoading(false);
}
};
const loginFromWebSession = async (sessionUserInfo: any) => {
try {
setUserInfo(sessionUserInfo);
setIsAuthenticated(true);
setIsWebSession(true);
await storage.set(AUTH_TYPE_KEY, "web_session");
// Try to get tokens from web session
const tokens = await WebAuthService.getTokensFromWebSession();
if (tokens.accessToken) {
setAccessToken(tokens.accessToken);
setRefreshToken(tokens.refreshToken || null);
await setupAfterLogin(tokens.accessToken, tokens.refreshToken);
}
} catch (error) {
console.error("Failed to login from web session:", error);
throw error;
}
};
const loginWithOAuth = async (): Promise<void> => {
if (!oauthService) {
throw new Error("OAuth service not available. Please check your configuration.");
}
try {
setLoading(true);
const tokens = await oauthService.startAuthorizationFlow();
// Save tokens
await saveOAuthTokens(tokens);
// Update state
setAccessToken(tokens.accessToken);
setRefreshToken(tokens.refreshToken || null);
setIsAuthenticated(true);
setIsWebSession(false);
// Setup API service and refresh function
await setupAfterLogin(tokens.accessToken, tokens.refreshToken);
if (tokens.refreshToken) {
setupRefreshTokenFunction(oauthService);
}
// Clear PKCE parameters
oauthService.clearPKCEParams();
} catch (error) {
console.error("OAuth login failed:", error);
throw error;
} finally {
setLoading(false);
}
};
const logout = async () => {
try {
await clearAuth();
resetAuthState();
} catch (error) {
console.error("Failed to logout:", error);
}
};
const value: AuthContextType = {
isAuthenticated,
accessToken,
refreshToken,
userInfo,
isWebSession,
loginFromWebSession,
loginWithOAuth,
logout,
loading,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
@@ -0,0 +1,223 @@
/// <reference types="chrome" />
// @ts-ignore - WXT provides this globally
export default defineBackground(() => {
chrome.action.onClicked.addListener((tab) => {
if (tab.id) {
chrome.tabs.sendMessage(tab.id, { action: "icon-clicked" }, (response) => {
if (chrome.runtime.lastError) {
// Expected on pages where content script isn't loaded
}
});
}
});
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;
const urlObj = new URL(authUrl);
const state = urlObj.searchParams.get("state");
if (state) {
chrome.storage.local.set({ oauth_state: state }, () => {
if (chrome.runtime.lastError) {
console.warn("Failed to store OAuth state:", chrome.runtime.lastError.message);
}
});
}
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;
}
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;
}
chrome.identity.launchWebAuthFlow(
{
url: authUrl,
interactive: true,
},
(responseUrl) => {
if (chrome.runtime.lastError) {
console.error("OAuth flow failed:", chrome.runtime.lastError.message);
reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`));
} else if (responseUrl) {
resolve(responseUrl);
} else {
console.error("OAuth flow returned no response URL");
reject(new Error("OAuth flow cancelled or failed"));
}
}
);
});
}
async function handleTokenExchange(
tokenRequest: any,
tokenEndpoint: string,
state?: string
): Promise<any> {
// CSRF protection: validate state parameter (defense-in-depth)
// State is already validated in iframe context; this validates in background script
if (state) {
try {
const result = await chrome.storage.local.get(["oauth_state"]);
const storedState = result.oauth_state;
if (storedState && storedState !== state) {
await chrome.storage.local.remove("oauth_state");
console.error("State parameter mismatch - possible CSRF attack");
throw new Error("Invalid state parameter - possible CSRF attack");
}
if (storedState && storedState === state) {
await chrome.storage.local.remove("oauth_state");
}
} catch (error) {
if (error instanceof Error && error.message.includes("Invalid state parameter")) {
throw error;
}
// For storage errors, log but continue (non-blocking)
console.warn("State validation warning (non-blocking):", error);
}
}
const body = new URLSearchParams();
Object.keys(tokenRequest).forEach((key) => {
body.append(key, tokenRequest[key]);
});
try {
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const errorData = await response.text();
console.error("Token exchange error response:", errorData);
throw new Error(`Token exchange failed: ${response.status} ${errorData}`);
}
const tokenData = await response.json();
const tokens = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type || "Bearer",
expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
scope: tokenData.scope,
};
return tokens;
} catch (error) {
console.error("Token exchange failed:", error);
throw error instanceof Error ? error : new Error(String(error));
}
}
async function fetchEventTypes() {
const API_BASE_URL = "https://api.cal.com/v2";
const result = await chrome.storage.local.get(["cal_oauth_tokens"]);
const oauthTokens = result.cal_oauth_tokens
? JSON.parse(result.cal_oauth_tokens as string)
: null;
if (!oauthTokens?.accessToken) {
throw new Error("No OAuth access token found. Please sign in with OAuth.");
}
// Get current user to retrieve username
const userResponse = await fetch(`${API_BASE_URL}/me`, {
headers: {
Authorization: `Bearer ${oauthTokens.accessToken}`,
"Content-Type": "application/json",
"cal-api-version": "2024-06-11",
},
});
if (!userResponse.ok) {
throw new Error(`Failed to get user: ${userResponse.statusText}`);
}
const userData = await userResponse.json();
const username = userData?.data?.username;
const params = new URLSearchParams();
if (username) {
params.append("username", username);
}
const queryString = params.toString();
const endpoint = `${API_BASE_URL}/event-types${queryString ? `?${queryString}` : ""}`;
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${oauthTokens.accessToken}`,
"Content-Type": "application/json",
"cal-api-version": "2024-06-14",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch event types: ${response.statusText}`);
}
const data = await response.json();
// Handle different response formats
if (Array.isArray(data)) {
return data;
} else if (data?.data && Array.isArray(data.data)) {
return data.data;
} else if (data?.eventTypes && Array.isArray(data.eventTypes)) {
return data.eventTypes;
}
return [];
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"manifest_version": 3,
"name": "Cal.com Companion",
"version": "1.7.0",
"description": "Your calendar companion for quick booking and scheduling",
"permissions": ["activeTab", "http://localhost:8081/*", "identity"],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;"
},
"action": {
"default_title": "Cal.com Companion"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content-scripts/content.js"]
}
]
}
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Cal.com OAuth Callback</title>
</head>
<body>
<p>Processing authentication...</p>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Force light color scheme for all web components */
:root {
color-scheme: light only;
}
html {
color-scheme: light;
}
* {
color-scheme: light;
}
input:focus {
--tw-ring-color: rgb(0 0 0);
--tw-border-opacity: 1;
border-color: rgb(0 0 0);
}
}
+1
View File
@@ -0,0 +1 @@
import "expo-router/entry";
+6
View File
@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });
+1
View File
@@ -0,0 +1 @@
/// <reference types="nativewind/types" />
+16506
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
{
"name": "cal-companion",
"displayName": "Cal.com Companion",
"version": "1.7.0",
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"ext:build": "wxt build",
"ext": "wxt",
"ext:zip": "wxt zip",
"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"
},
"dependencies": {
"@expo/ui": "^0.2.0-beta.7",
"@expo/vector-icons": "^15.0.3",
"@react-native-segmented-control/segmented-control": "^2.5.7",
"@types/react": "~19.1.10",
"@types/react-dom": "~19.1.7",
"base64-js": "^1.5.1",
"expo": "~54.0.0",
"expo-auth-session": "^7.0.9",
"expo-constants": "~18.0.10",
"expo-crypto": "^15.0.7",
"expo-device": "^8.0.9",
"expo-glass-effect": "~0.1.6",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.14",
"expo-secure-store": "^15.0.7",
"expo-status-bar": "~3.0.8",
"expo-web-browser": "^15.0.9",
"nativewind": "^4.2.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-native": "0.81.5",
"react-native-context-menu-view": "^1.20.0",
"react-native-gesture-handler": "^2.29.1",
"react-native-reanimated": "~3.17.4",
"react-native-safe-area-context": "5.4.0",
"react-native-svg": "^15.15.0",
"react-native-web": "^0.21.2",
"react-native-worklets": "^0.6.1"
},
"private": true,
"devDependencies": {
"@types/chrome": "^0.1.31",
"babel-preset-expo": "^54.0.7",
"husky": "^9.0.11",
"lint-staged": "^15.2.0",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.17",
"wxt": "^0.20.11"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"
]
}
}
File diff suppressed because it is too large Load Diff
+496
View File
@@ -0,0 +1,496 @@
/// <reference types="chrome" />
import * as AuthSession from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
import * as Crypto from "expo-crypto";
import { Platform } from "react-native";
import { fromByteArray } from "base64-js";
// Complete warm up for WebBrowser on mobile
WebBrowser.maybeCompleteAuthSession();
export interface OAuthTokens {
accessToken: string;
refreshToken?: string;
tokenType: string;
expiresAt?: number;
scope?: string;
}
export interface OAuthConfig {
clientId: string;
redirectUri: string;
calcomBaseUrl: string;
}
export class CalComOAuthService {
private config: OAuthConfig;
private codeVerifier: string | null = null;
private state: string | null = null;
constructor(config: OAuthConfig) {
this.config = config;
}
private async generatePKCEParams(): Promise<{
codeVerifier: string;
codeChallenge: string;
state: string;
}> {
const codeVerifier = this.generateRandomBase64Url();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
const state = this.generateRandomBase64Url();
this.codeVerifier = codeVerifier;
this.state = state;
return { codeVerifier, codeChallenge, state };
}
private async generateCodeChallenge(codeVerifier: string): Promise<string> {
try {
const base64Hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
codeVerifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
return base64Hash.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
} catch (error) {
console.error("Failed to generate code challenge:", error);
throw new Error("Failed to generate OAuth code challenge");
}
}
private generateRandomBase64Url(): string {
const bytes = new Uint8Array(Crypto.getRandomBytes(32));
const base64 =
typeof btoa !== "undefined" ? btoa(String.fromCharCode(...bytes)) : fromByteArray(bytes);
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
private buildAuthorizationUrl(codeChallenge: string, state: string): string {
const params = new URLSearchParams({
client_id: this.config.clientId,
response_type: "code",
redirect_uri: this.config.redirectUri,
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return `${this.config.calcomBaseUrl}/auth/oauth2/authorize?${params.toString()}`;
}
async startAuthorizationFlow(): Promise<OAuthTokens> {
try {
const { codeChallenge, state } = await this.generatePKCEParams();
// Web only: check for stored callback with state-specific keys to prevent race conditions
if (Platform.OS === "web" && typeof window !== "undefined") {
const storedCode = window.localStorage.getItem(`oauth_callback_code_${state}`);
const storedState = window.localStorage.getItem(`oauth_callback_state_${state}`);
if (storedCode && storedState) {
// CSRF protection: verify state matches
if (storedState !== state) {
window.localStorage.removeItem(`oauth_callback_code_${state}`);
window.localStorage.removeItem(`oauth_callback_state_${state}`);
throw new Error("Invalid state parameter - possible CSRF attack");
}
window.localStorage.removeItem(`oauth_callback_code_${state}`);
window.localStorage.removeItem(`oauth_callback_state_${state}`);
return await this.exchangeCodeForTokens(storedCode, state);
}
}
// Note: State stored in background script (iframe may not have chrome.storage access)
const authResult = await this.getAuthorizationResult(codeChallenge, state);
if (authResult.type === "success") {
const code = authResult.params?.code || authResult.params?.authorizationCode;
const returnedState = authResult.params?.state;
if (returnedState !== state) {
throw new Error("Invalid state parameter - possible CSRF attack");
}
if (!code) {
throw new Error("No authorization code received");
}
return await this.exchangeCodeForTokens(code, state);
}
if (authResult.type === "error") {
const errorDescription =
authResult.params?.error_description ||
("error" in authResult ? authResult.error?.message : undefined) ||
"Unknown error";
throw new Error(`OAuth error: ${errorDescription}`);
}
if (authResult.type === "cancel") {
throw new Error("OAuth flow was cancelled by user");
}
throw new Error("OAuth flow failed or was dismissed");
} catch (error) {
console.error("OAuth authorization error:", error);
throw error;
}
}
// Detect browser extension environment using reliable indicators
private isBrowserExtension(): boolean {
if (Platform.OS !== "web" || typeof window === "undefined") {
return false;
}
if (typeof chrome !== "undefined" && chrome.runtime?.id) {
return true;
}
if (window.location.protocol === "chrome-extension:") {
return true;
}
// Extension content script injects companion app as iframe
if (window.parent !== window) {
try {
const parentOrigin = window.parent.location.origin;
const currentOrigin = window.location.origin;
if (parentOrigin !== currentOrigin && window.location.hostname === "localhost") {
return true;
}
} catch {
// Cross-origin access blocked - expected for extension iframes
if (window.location.hostname === "localhost") {
return true;
}
}
}
return false;
}
private async launchExtensionAuthFlow(authUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
if (typeof chrome !== "undefined" && chrome.identity) {
chrome.identity.launchWebAuthFlow(
{
url: authUrl,
interactive: true,
},
(responseUrl) => {
if (chrome.runtime.lastError) {
reject(new Error(`OAuth flow failed: ${chrome.runtime.lastError.message}`));
} else if (responseUrl) {
resolve(responseUrl);
} else {
reject(new Error("OAuth flow cancelled or failed"));
}
}
);
return;
}
// Iframe: communicate with parent window
if (window.parent !== window) {
let timeoutId: NodeJS.Timeout | null = null;
const messageHandler = (event: MessageEvent) => {
// Security: only accept messages from parent
if (event.source !== window.parent) {
return;
}
if (event.data.type === "cal-extension-oauth-result") {
window.removeEventListener("message", messageHandler);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (event.data.success) {
resolve(event.data.responseUrl);
} else {
console.error("OAuth flow failed:", event.data.error);
reject(new Error(event.data.error || "OAuth flow failed"));
}
}
};
window.addEventListener("message", messageHandler);
window.parent.postMessage(
{
type: "cal-extension-oauth-request",
authUrl: authUrl,
},
"*"
);
timeoutId = setTimeout(() => {
console.error("OAuth flow timeout - no response from extension");
window.removeEventListener("message", messageHandler);
reject(new Error("OAuth flow timeout - no response from extension"));
}, 30000);
} else {
reject(new Error("Chrome extension context not detected"));
}
});
}
private async getAuthorizationResult(
codeChallenge: string,
state: string
): Promise<AuthSession.AuthSessionResult | { type: string; params: Record<string, string> }> {
const authUrl = this.buildAuthorizationUrl(codeChallenge, state);
const isExtension = this.isBrowserExtension();
if (isExtension) {
try {
const responseUrl = await this.launchExtensionAuthFlow(authUrl);
const params = this.parseCallbackUrl(responseUrl);
return { type: "success" as const, params };
} catch (error) {
console.error("Extension OAuth flow failed:", error);
return { type: "error", params: { error: error.message } } as {
type: string;
params: Record<string, string>;
};
}
} else if (Platform.OS === "web") {
const discovery = await this.getDiscoveryEndpoints();
const request = new AuthSession.AuthRequest({
clientId: this.config.clientId,
redirectUri: this.config.redirectUri,
responseType: AuthSession.ResponseType.Code,
state: state,
codeChallenge: codeChallenge,
codeChallengeMethod: AuthSession.CodeChallengeMethod.S256,
});
return await request.promptAsync(discovery);
} else {
const result = await WebBrowser.openAuthSessionAsync(authUrl, this.config.redirectUri);
if (result.type === "success") {
const params = this.parseCallbackUrl(result.url);
return { type: "success" as const, params };
}
return { type: result.type, params: {} } as { type: string; params: Record<string, string> };
}
}
private async getDiscoveryEndpoints(): Promise<AuthSession.DiscoveryDocument> {
try {
const discovery = await AuthSession.fetchDiscoveryAsync(
`${this.config.calcomBaseUrl}/.well-known/openid_configuration`
);
return discovery;
} catch {
return {
authorizationEndpoint: `${this.config.calcomBaseUrl}/auth/oauth2/authorize`,
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
} as AuthSession.DiscoveryDocument;
}
}
private parseCallbackUrl(url: string): Record<string, string> {
const urlObj = new URL(url);
const params: Record<string, string> = {};
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
});
if (Object.keys(params).length === 0 && urlObj.hash) {
const hashParams = new URLSearchParams(urlObj.hash.substring(1));
hashParams.forEach((value, key) => {
params[key] = value;
});
}
return params;
}
private async exchangeCodeForTokens(code: string, state?: string): Promise<OAuthTokens> {
if (!this.codeVerifier) {
throw new Error("No code verifier available");
}
// Extension: use APIs to avoid CORS
if (this.isBrowserExtension() && window.parent !== window) {
return await this.exchangeTokensViaExtension(code, state);
}
const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/token`;
const body = new URLSearchParams();
body.append("grant_type", "authorization_code");
body.append("client_id", this.config.clientId);
body.append("code", code);
body.append("redirect_uri", this.config.redirectUri);
body.append("code_verifier", this.codeVerifier);
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const errorData = await response.text();
console.error("Token exchange error response:", errorData);
try {
const errorJson = JSON.parse(errorData);
console.error("Parsed error:", errorJson);
} catch {
console.error("Could not parse error response as JSON");
}
throw new Error(`Token exchange failed: ${response.status} ${errorData}`);
}
const tokenData = await response.json();
const tokens: OAuthTokens = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
tokenType: tokenData.token_type || "Bearer",
expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
scope: tokenData.scope,
};
return tokens;
}
private async exchangeTokensViaExtension(code: string, state?: string): Promise<OAuthTokens> {
return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null;
const messageHandler = (event: MessageEvent) => {
// Security: only accept messages from parent
if (event.source !== window.parent) {
return;
}
if (event.data.type === "cal-extension-token-exchange-result") {
window.removeEventListener("message", messageHandler);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (event.data.success) {
resolve(event.data.tokens);
} else {
console.error("Token exchange failed via extension:", event.data.error);
reject(new Error(event.data.error || "Token exchange failed"));
}
}
};
window.addEventListener("message", messageHandler);
window.parent.postMessage(
{
type: "cal-extension-token-exchange-request",
tokenRequest: {
grant_type: "authorization_code",
client_id: this.config.clientId,
code: code,
redirect_uri: this.config.redirectUri,
code_verifier: this.codeVerifier,
},
state: state, // CSRF validation in background script
tokenEndpoint: `${this.config.calcomBaseUrl}/api/auth/oauth/token`,
},
"*"
);
timeoutId = setTimeout(() => {
window.removeEventListener("message", messageHandler);
reject(new Error("Token exchange timeout"));
}, 30000);
});
}
async refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
const tokenEndpoint = `${this.config.calcomBaseUrl}/api/auth/oauth/refreshToken`;
const body = new URLSearchParams({
grant_type: "refresh_token",
client_id: this.config.clientId,
refresh_token: refreshToken,
});
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorData}`);
}
const tokenData = await response.json();
const tokens: OAuthTokens = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || refreshToken,
tokenType: tokenData.token_type || "Bearer",
expiresAt: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
scope: tokenData.scope,
};
return tokens;
}
isTokenExpired(tokens: OAuthTokens): boolean {
if (!tokens.expiresAt) {
return false;
}
// 5-minute buffer before expiry
return Date.now() >= tokens.expiresAt - 5 * 60 * 1000;
}
clearPKCEParams(): void {
this.codeVerifier = null;
this.state = null;
}
}
export function createCalComOAuthService(overrides: Partial<OAuthConfig> = {}): CalComOAuthService {
let defaultRedirectUri: string;
const defaultConfig: OAuthConfig = {
clientId: process.env.EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID || "",
redirectUri: process.env.EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI,
calcomBaseUrl: "https://app.cal.com",
...overrides,
};
if (!defaultConfig.clientId) {
throw new Error(
"OAuth client ID is required. Set EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID environment variable."
);
}
return new CalComOAuthService(defaultConfig);
}
@@ -0,0 +1,64 @@
export interface Booking {
id: number;
uid: string;
title: string;
description?: string;
startTime: string;
endTime: string;
start?: string;
end?: string;
eventTypeId: number;
eventType?: {
id: number;
title: string;
slug: string;
};
hosts?: Array<{
id?: number | string;
name?: string;
email?: string;
username?: string;
timeZone?: string;
}>;
user?: {
id: number;
email: string;
name: string;
timeZone: string;
};
attendees?: Array<{
id?: number | string;
email: string;
name: string;
timeZone: string;
noShow?: boolean;
}>;
status: "ACCEPTED" | "PENDING" | "CANCELLED" | "REJECTED";
paid?: boolean;
payment?: Array<{
id: number;
success: boolean;
paymentOption: string;
}>;
rescheduled?: boolean;
fromReschedule?: string;
recurringEventId?: string;
recurringBookingUid?: string;
smsReminderNumber?: string;
location?: string;
cancellationReason?: string;
rejectionReason?: string;
responses?: Record<string, any>;
}
export interface GetBookingsResponse {
status: "success";
data: Booking[];
}
export interface BookingParticipationResult {
isOrganizer: boolean;
isHost: boolean;
isAttendee: boolean;
isParticipating: boolean;
}
@@ -0,0 +1,288 @@
// Event Type Creation/Update Types
export interface BookingLimitsCount {
day?: number;
week?: number;
month?: number;
year?: number;
}
export interface BookingLimitsDuration {
day?: number;
week?: number;
month?: number;
year?: number;
}
export interface BookerActiveBookingsLimit {
count: number;
}
export interface BusinessDaysWindow {
type: "businessDays";
value: number;
rolling?: boolean;
rollingWindow?: number;
}
export interface CalendarDaysWindow {
type: "calendarDays";
value: number;
rolling?: boolean;
rollingWindow?: number;
}
export interface RangeWindow {
type: "range";
value: [string, string]; // ISO date strings
}
export type BookingWindow =
| BusinessDaysWindow
| CalendarDaysWindow
| RangeWindow
| { disabled: true };
export interface BookerLayouts {
enabledLayouts: Array<"month_view" | "week_view" | "column_view">;
defaultLayout: "month_view" | "week_view" | "column_view";
}
export interface ConfirmationPolicy {
noticeThreshold?: {
count: number;
unit: "hours" | "minutes";
};
}
export interface Recurrence {
interval: number;
occurrences: number;
frequency: "daily" | "weekly" | "monthly" | "yearly";
}
export interface Seats {
seatsPerTimeSlot?: number;
showAttendeeInfo?: boolean;
showAvailabilityCount?: boolean;
seatsShowAvailabilityCount?: boolean;
}
export interface EventTypeColor {
lightEventTypeColor?: string;
darkEventTypeColor?: string;
}
export interface EmailSettings {
additionalGuestsEmails?: string[];
}
// Booking Field Types
export interface BaseBookingField {
name: string;
type: string;
label?: string;
placeholder?: string;
required?: boolean;
hidden?: boolean;
}
export interface NameDefaultField extends BaseBookingField {
type: "name";
variant?: "fullName" | "firstAndLastName";
}
export interface EmailDefaultField extends BaseBookingField {
type: "email";
}
export interface PhoneField extends BaseBookingField {
type: "phone";
}
export interface AddressField extends BaseBookingField {
type: "address";
}
export interface TextField extends BaseBookingField {
type: "text";
}
export interface NumberField extends BaseBookingField {
type: "number";
}
export interface TextAreaField extends BaseBookingField {
type: "textarea";
}
export interface SelectField extends BaseBookingField {
type: "select" | "multiselect";
options?: Array<{ label: string; value: string }>;
}
export interface CheckboxField extends BaseBookingField {
type: "checkbox";
}
export interface RadioField extends BaseBookingField {
type: "radio" | "radioInput";
options?: Array<{ label: string; value: string }>;
}
export interface BooleanField extends BaseBookingField {
type: "boolean";
}
export type BookingField =
| NameDefaultField
| EmailDefaultField
| PhoneField
| AddressField
| TextField
| NumberField
| TextAreaField
| SelectField
| CheckboxField
| RadioField
| BooleanField;
export interface EventType {
id: number;
title: string;
slug: string;
description?: string;
length: number; // Deprecated: use lengthInMinutes instead
lengthInMinutes?: number; // API returns this field
lengthInMinutesOptions?: number[];
// Locations
locations?: Array<{
type: string;
address?: string;
link?: string;
phone?: string;
integration?: string;
credentialId?: number;
public?: boolean;
}>;
// Pricing
price?: number;
currency?: string;
// Basic settings
disableGuests?: boolean;
lockTimeZoneToggleOnBookingPage?: boolean;
requiresConfirmation?: boolean;
requiresBookerEmailVerification?: boolean;
hideCalendarNotes?: boolean;
successRedirectUrl?: string;
forwardParamsSuccessRedirect?: boolean;
// Schedule and buffers
scheduleId?: number;
slotInterval?: number;
minimumBookingNotice?: number;
beforeEventBuffer?: number;
afterEventBuffer?: number;
// Limits
bookingLimitsCount?: BookingLimitsCount | { disabled: true };
bookingLimitsDuration?: BookingLimitsDuration | { disabled: true };
bookerActiveBookingsLimit?: BookerActiveBookingsLimit | { disabled: true };
onlyShowFirstAvailableSlot?: boolean;
bookingWindow?: BookingWindow;
offsetStart?: number;
// Advanced
bookerLayouts?: BookerLayouts;
confirmationPolicy?: ConfirmationPolicy | { disabled: true };
// Recurring
recurrence?: Recurrence | { disabled: true };
// Seats
seats?: Seats | { disabled: true };
// Customization
bookingFields?: BookingField[];
hidden?: boolean;
eventTypeColor?: EventTypeColor;
// Ownership
userId?: number;
teamId?: number;
hosts?: Array<{
userId: number;
isFixed: boolean;
}>;
// Metadata
metadata?: Record<string, any>;
}
export interface CreateEventTypeInput {
// Basic fields
title: string;
slug: string;
description?: string;
lengthInMinutes: number;
lengthInMinutesOptions?: number[];
// Locations
locations?: Array<{
type: string;
address?: string;
link?: string;
phone?: string;
integration?: string;
credentialId?: number;
public?: boolean;
}>;
// Schedule
scheduleId?: number;
// Booking settings
disableGuests?: boolean;
slotInterval?: number;
minimumBookingNotice?: number;
beforeEventBuffer?: number;
afterEventBuffer?: number;
// Limits
bookingLimitsCount?: BookingLimitsCount | { disabled: true };
bookingLimitsDuration?: BookingLimitsDuration | { disabled: true };
bookerActiveBookingsLimit?: BookerActiveBookingsLimit | { disabled: true };
onlyShowFirstAvailableSlot?: boolean;
// Booking window
bookingWindow?: BookingWindow;
// Advanced settings
offsetStart?: number;
bookerLayouts?: BookerLayouts;
confirmationPolicy?: ConfirmationPolicy | { disabled: true };
recurrence?: Recurrence | { disabled: true };
seats?: Seats | { disabled: true };
// Customization
bookingFields?: BookingField[];
hidden?: boolean;
eventTypeColor?: EventTypeColor;
emailSettings?: EmailSettings;
// Additional fields
requiresConfirmation?: boolean;
requiresBookerEmailVerification?: boolean;
hideCalendarNotes?: boolean;
lockTimeZoneToggleOnBookingPage?: boolean;
successRedirectUrl?: string;
forwardParamsSuccessRedirect?: boolean;
metadata?: Record<string, any>;
}
export interface GetEventTypesResponse {
status: "success";
data: EventType[];
}
+7
View File
@@ -0,0 +1,7 @@
// Re-export all types for easy importing
export * from "./event-types.types";
export * from "./bookings.types";
export * from "./schedules.types";
export * from "./users.types";
export * from "./webhooks.types";
export * from "./private-links.types";
@@ -0,0 +1,25 @@
// Private Link Types
export interface PrivateLink {
id: number;
link: string;
enabled: boolean;
eventTypeId: number;
}
export interface CreatePrivateLinkInput {
enabled?: boolean;
}
export interface UpdatePrivateLinkInput {
enabled?: boolean;
}
export interface GetPrivateLinksResponse {
status: string;
data: PrivateLink[];
}
export interface GetPrivateLinkResponse {
status: string;
data: PrivateLink;
}
@@ -0,0 +1,31 @@
export interface ScheduleAvailability {
days: string[];
startTime: string;
endTime: string;
}
export interface ScheduleOverride {
date: string;
startTime: string;
endTime: string;
}
export interface Schedule {
id: number;
ownerId: number;
name: string;
timeZone: string;
availability: ScheduleAvailability[];
isDefault: boolean;
overrides: ScheduleOverride[];
}
export interface GetSchedulesResponse {
status: "success";
data: Schedule[];
}
export interface GetScheduleResponse {
status: "success";
data: Schedule | null;
}
+35
View File
@@ -0,0 +1,35 @@
export interface UserProfile {
id: number;
username: string;
name: string;
email: string;
bio?: string;
avatarUrl?: string;
timeZone?: string;
weekStart?: string;
timeFormat?: number;
defaultScheduleId?: number;
locale?: string;
organizationId?: number;
organization?: {
id: number;
isPlatform: boolean;
};
metadata?: Record<string, any>;
brandColor?: string;
darkBrandColor?: string;
theme?: string;
}
export interface ConferencingOption {
id: number;
type: string;
userId: number | null;
teamId: number | null;
appId: string | null;
subscriptionId: number | null;
paymentStatus: string | null;
billingCycleStart: string | null;
invalid: boolean;
delegationCredentialId: string | null;
}
@@ -0,0 +1,36 @@
// Webhook Types
export interface Webhook {
id: string;
subscriberUrl: string;
eventTriggers: string[];
active: boolean;
payloadTemplate?: string;
secret?: string;
eventTypeId?: number;
}
export interface CreateWebhookInput {
subscriberUrl: string;
eventTriggers: string[];
active?: boolean;
payloadTemplate?: string;
secret?: string;
}
export interface UpdateWebhookInput {
subscriberUrl?: string;
eventTriggers?: string[];
active?: boolean;
payloadTemplate?: string;
secret?: string;
}
export interface GetWebhooksResponse {
status: string;
data: Webhook[];
}
export interface GetWebhookResponse {
status: string;
data: Webhook;
}
+192
View File
@@ -0,0 +1,192 @@
import { Platform } from "react-native";
export interface WebSessionInfo {
isLoggedIn: boolean;
accessToken?: string;
refreshToken?: string;
userInfo?: any;
}
export class WebAuthService {
// Check if running on web
static isWeb(): boolean {
return Platform.OS === "web";
}
// Get cookies from the browser
static getCookies(): { [key: string]: string } {
if (!this.isWeb()) return {};
const cookies: { [key: string]: string } = {};
if (typeof document !== "undefined") {
document.cookie.split(";").forEach((cookie) => {
const [name, value] = cookie.trim().split("=");
if (name && value) {
cookies[name] = decodeURIComponent(value);
}
});
}
return cookies;
}
// Check for Cal.com session cookies
static async checkCalComSession(): Promise<WebSessionInfo> {
if (!this.isWeb()) {
return { isLoggedIn: false };
}
try {
// Check for common Cal.com session cookies
const cookies = this.getCookies();
// Look for Cal.com authentication cookies
// Common patterns: next-auth.session-token, __Secure-next-auth.session-token, etc.
const sessionCookieNames = [
"next-auth.session-token",
"__Secure-next-auth.session-token",
"__Host-next-auth.session-token",
"next-auth.csrf-token",
"cal-session",
"session-token",
];
const foundSessionCookie = sessionCookieNames.find((name) => cookies[name]);
if (foundSessionCookie) {
// Try to validate the session with Cal.com API
const sessionInfo = await this.validateWebSession();
return sessionInfo;
}
return { isLoggedIn: false };
} catch (error) {
return { isLoggedIn: false };
}
}
// Validate web session by calling Cal.com API with cookies
static async validateWebSession(): Promise<WebSessionInfo> {
if (!this.isWeb()) {
return { isLoggedIn: false };
}
try {
// First try to call the NextAuth session endpoint
const sessionResponse = await fetch("https://app.cal.com/api/auth/session", {
method: "GET",
credentials: "include", // Include cookies
headers: {
"Content-Type": "application/json",
},
});
if (sessionResponse.ok) {
const sessionData = await sessionResponse.json();
if (sessionData && sessionData.user) {
// User is logged in via web session
return {
isLoggedIn: true,
userInfo: sessionData.user,
};
}
}
// Try the internal Cal.com me endpoint (this might work with cookies)
const meResponse = await fetch("https://app.cal.com/api/me", {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (meResponse.ok) {
const userData = await meResponse.json();
if (userData && (userData.id || userData.user || userData.data)) {
return {
isLoggedIn: true,
userInfo: userData.user || userData.data || userData,
};
}
}
// Try to check if user is logged in by attempting to access a protected page
const dashboardResponse = await fetch("https://app.cal.com/api/trpc/viewer.me", {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (dashboardResponse.ok) {
const dashboardData = await dashboardResponse.json();
if (dashboardData && dashboardData.result && dashboardData.result.data) {
return {
isLoggedIn: true,
userInfo: dashboardData.result.data,
};
}
}
return { isLoggedIn: false };
} catch (error) {
return { isLoggedIn: false };
}
}
// Try to get API tokens from web session
static async getTokensFromWebSession(): Promise<{ accessToken?: string; refreshToken?: string }> {
if (!this.isWeb()) {
return {};
}
try {
// Some Cal.com implementations might expose tokens via specific endpoints
// This is a fallback approach - in practice, web sessions might not expose tokens directly
// Check localStorage/sessionStorage for any stored tokens
if (typeof window !== "undefined") {
const localStorage = window.localStorage;
const sessionStorage = window.sessionStorage;
const possibleTokenKeys = [
"cal-access-token",
"cal-auth-token",
"next-auth.token",
"auth-token",
"access-token",
];
for (const key of possibleTokenKeys) {
const localToken = localStorage.getItem(key);
const sessionToken = sessionStorage.getItem(key);
if (localToken || sessionToken) {
return {
accessToken: localToken || sessionToken || undefined,
};
}
}
}
return {};
} catch (error) {
return {};
}
}
// Redirect to Cal.com web login instead of using WebView
static redirectToWebLogin(): void {
if (!this.isWeb()) return;
// For web, redirect directly to Cal.com login
const currentUrl = window.location.href;
const loginUrl = `https://app.cal.com/auth/signin?callbackUrl=${encodeURIComponent(currentUrl)}`;
window.location.href = loginUrl;
}
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};
+6
View File
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"types": ["nativewind/types"]
},
"extends": "expo/tsconfig.base"
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Default location types that are available alongside conferencing apps
* These match the pattern from packages/app-store/locations.ts
*/
export enum DefaultLocationType {
AttendeeInPerson = "attendeeInPerson",
InPerson = "inPerson",
Phone = "phone",
UserPhone = "userPhone",
Link = "link",
Conferencing = "conferencing",
SomewhereElse = "somewhereElse",
}
export type DefaultLocation = {
type: DefaultLocationType;
label: string;
iconUrl: string;
category: "in person" | "conferencing" | "other" | "phone";
organizerInputType?: "text" | "phone" | null;
organizerInputPlaceholder?: string;
organizerInputLabel?: string;
messageForOrganizer?: string;
};
/**
* Default locations available in Cal.com
* Icons are served from https://app.cal.com/{iconPath}
*/
export const defaultLocations: DefaultLocation[] = [
{
type: DefaultLocationType.AttendeeInPerson,
label: "In Person (Attendee Address)",
iconUrl: "https://app.cal.com/map-pin-dark.svg",
category: "in person",
},
{
type: DefaultLocationType.SomewhereElse,
label: "Custom Attendee Location",
iconUrl: "https://app.cal.com/message-pin.svg",
category: "other",
},
{
type: DefaultLocationType.InPerson,
label: "In Person (Organizer Address)",
iconUrl: "https://app.cal.com/map-pin-dark.svg",
category: "in person",
organizerInputType: "text",
organizerInputPlaceholder: "Enter address or place",
organizerInputLabel: "Address",
messageForOrganizer: "Provide an Address or Place",
},
{
type: DefaultLocationType.Link,
label: "Link Meeting",
iconUrl: "https://app.cal.com/link.svg",
category: "other",
organizerInputType: "text",
organizerInputPlaceholder: "https://meet.example.com/join/123456",
organizerInputLabel: "Meeting Link",
messageForOrganizer: "Provide a Meeting Link",
},
{
type: DefaultLocationType.Phone,
label: "Attendee Phone Number",
iconUrl: "https://app.cal.com/phone.svg",
category: "phone",
organizerInputType: null,
messageForOrganizer: "Cal will ask your invitee to enter a phone number before scheduling.",
},
{
type: DefaultLocationType.UserPhone,
label: "Organizer Phone Number",
iconUrl: "https://app.cal.com/phone.svg",
category: "phone",
organizerInputType: "phone",
organizerInputPlaceholder: "Enter phone number",
organizerInputLabel: "Phone Number",
messageForOrganizer: "Provide your phone number with country code",
},
];
/**
* Get icon URL for a default location type
*/
export function getDefaultLocationIconUrl(locationType: string): string | null {
const location = defaultLocations.find((loc) => loc.type === locationType);
return location ? location.iconUrl : null;
}
/**
* Check if a location type is a default location (not a conferencing app)
*/
export function isDefaultLocation(locationType: string): boolean {
return defaultLocations.some((loc) => loc.type === locationType);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Maps conferencing app types or appIds to their icon URLs.
* Icons are publicly accessible at: https://app.cal.com/app-store/{dirName}/icon.svg
*/
const APP_TYPE_TO_DIR_NAME: Record<string, string> = {
// Video conferencing apps
zoom_video: "zoomvideo",
google_video: "googlevideo",
office365_video: "office365video",
teams_video: "office365video",
daily_video: "dailyvideo",
jitsi_video: "jitsivideo",
huddle01_video: "huddle01video",
tandem_video: "tandemvideo",
whereby_video: "whereby",
webex_video: "webex",
sylapsvideo_video: "sylapsvideo",
shimmervideo_video: "shimmervideo",
riverside_video: "riverside",
roam_video: "roam",
ping_video: "ping",
mirotalk_video: "mirotalk",
nextcloudtalk_conferencing: "nextcloudtalk",
jelly_conferencing: "jelly",
"horizon-workrooms_video": "horizon-workrooms",
"element-call_conferencing": "element-call",
eightxeight_video: "eightxeight",
discord_video: "discord",
dialpad_video: "dialpad",
demodesk_video: "demodesk",
campfire_video: "campfire",
facetime_video: "facetime",
whatsapp_video: "whatsapp",
telegram_video: "telegram",
skype_video: "skype",
signal_video: "signal",
sirius_video: "sirius_video",
sirius_video_video: "sirius_video", // Handle double _video suffix
salesroom_video: "salesroom",
};
const APP_ID_TO_DIR_NAME: Record<string, string> = {
zoom: "zoomvideo",
"google-meet": "googlevideo",
msteams: "office365video",
office365: "office365video",
teams: "office365video",
"cal-video": "dailyvideo",
"daily-video": "dailyvideo",
daily: "dailyvideo",
jitsi: "jitsivideo",
huddle01: "huddle01video",
tandem: "tandemvideo",
whereby: "whereby",
webex: "webex",
sylapsvideo: "sylapsvideo",
shimmervideo: "shimmervideo",
riverside: "riverside",
roam: "roam",
ping: "ping",
mirotalk: "mirotalk",
nextcloudtalk: "nextcloudtalk",
jelly: "jelly",
"horizon-workrooms": "horizon-workrooms",
"element-call": "element-call",
eightxeight: "eightxeight",
discord: "discord",
dialpad: "dialpad",
demodesk: "demodesk",
campfire: "campfire",
facetime: "facetime",
whatsapp: "whatsapp",
telegram: "telegram",
skype: "skype",
signal: "signal",
sirius_video: "sirius_video",
salesroom: "salesroom",
};
/**
* Generates the icon URL for a conferencing app based on its type or appId.
* Uses publicly available icons from https://app.cal.com/app-store/{dirName}/icon.svg
* Special cases are handled (e.g., Google Meet uses logo.webp)
*
* @param type - The credential type (e.g., "zoom_video", "google_video")
* @param appId - The app identifier (e.g., "zoom", "google-meet")
* @returns The full icon URL or null if the app is not found
*
* @example
* getAppIconUrl("zoom_video", "zoom") // "https://app.cal.com/app-store/zoomvideo/icon.svg"
* getAppIconUrl("google_video", "google-meet") // "https://app.cal.com/app-store/googlevideo/logo.webp"
*/
export function getAppIconUrl(type: string, appId: string | null): string | null {
let dirName: string | null = null;
let iconFileName = "icon.svg";
// Try to find by appId first (more specific)
if (appId && APP_ID_TO_DIR_NAME[appId]) {
dirName = APP_ID_TO_DIR_NAME[appId];
} else if (APP_TYPE_TO_DIR_NAME[type]) {
// Fallback to type
dirName = APP_TYPE_TO_DIR_NAME[type];
}
if (!dirName) {
return null;
}
// Special cases for icon file names
if (appId === "google-meet" || type === "google_video") {
// Google Meet uses logo.webp instead of icon.svg
iconFileName = "logo.webp";
} else if (appId === "riverside" || type === "riverside_video") {
// Riverside uses icon-dark.svg
iconFileName = "icon-dark.svg";
} else if (appId === "sirius_video" || type === "sirius_video" || type === "sirius_video_video") {
// Sirius Video uses icon-dark.svg
iconFileName = "icon-dark.svg";
}
return `https://app.cal.com/app-store/${dirName}/${iconFileName}`;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Parser for Google Calendar scheduling chips in Gmail
* Extracts time slots from the Gmail ad-hoc scheduling chip
*/
export interface GoogleTimeSlot {
date: string; // "Wed, 26 November"
startTime: string; // "3:30 pm"
endTime: string; // "4:30 pm"
durationMinutes: number; // 60
isoDate: string; // "2025-11-27"
isoTimestamp: string; // "2025-11-27T05:30:00.000Z"
googleUrl: string; // Original Google Calendar URL
}
export interface ParsedGoogleChip {
scheduleId: string;
timezone: string; // "Asia/Kolkata"
timezoneOffset: string; // "GMT+05:30"
slots: GoogleTimeSlot[];
detectedDuration: number; // 15, 30, 45, or 60
}
/**
* Parse Google Calendar scheduling chip from Gmail
*/
export function parseGoogleChip(chipElement: HTMLElement): ParsedGoogleChip | null {
try {
// 1. Get schedule ID from data attribute
const scheduleId = chipElement.getAttribute("data-ad-hoc-schedule-id");
if (!scheduleId) {
console.warn("No schedule ID found in Google chip");
return null;
}
// 2. Parse timezone - first try to get IANA timezone from data attribute
let timezone = "UTC";
let timezoneOffset = "GMT+00:00";
// Try to get IANA timezone from data-ad-hoc-v2-params
const paramsAttr = chipElement.getAttribute("data-ad-hoc-v2-params");
if (paramsAttr) {
// The timezone is at the end of the params string, like: "Asia/Kolkata"
// Try multiple patterns as Gmail might format it differently
let tzMatch = paramsAttr.match(/&quot;([^&]+)&quot;\]/);
// Also try without HTML entities
if (!tzMatch) {
tzMatch = paramsAttr.match(/"([^"]+)"\]/);
}
// Also try with escaped quotes
if (!tzMatch) {
tzMatch = paramsAttr.match(/\\"([^\\]+)\\"\]/);
}
if (tzMatch && tzMatch[1]) {
timezone = tzMatch[1]; // e.g., "Asia/Kolkata"
}
}
// Also get the display timezone offset from the UI text
const timezoneText = chipElement.querySelector("td")?.textContent?.trim() || "";
const timezoneMatch = timezoneText.match(/^(.+?)\s*-\s*(.+?)\s*\((.+?)\)$/);
if (timezoneMatch) {
timezoneOffset = timezoneMatch[3].trim(); // e.g., "GMT+05:30"
}
// 3. Find all time slot links
const slotLinks = Array.from(chipElement.querySelectorAll("a[href*='slotStartTime']"));
if (slotLinks.length === 0) {
console.warn("No time slot links found in Google chip");
return null;
}
const slots: GoogleTimeSlot[] = [];
let detectedDuration = 60; // Default
slotLinks.forEach((link) => {
const href = (link as HTMLAnchorElement).href;
const url = new URL(href);
// Extract parameters
const slotStartTime = url.searchParams.get("slotStartTime");
const slotDurationMinutes = url.searchParams.get("slotDurationMinutes");
if (!slotStartTime || !slotDurationMinutes) {
return;
}
const durationMinutes = parseInt(slotDurationMinutes, 10);
detectedDuration = durationMinutes; // Use the duration from slots
// Convert Unix timestamp (milliseconds) to Date
const startDate = new Date(parseInt(slotStartTime, 10));
const endDate = new Date(startDate.getTime() + durationMinutes * 60 * 1000);
// Format date and time using the chip's timezone for consistency
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "short",
day: "numeric",
month: "long",
timeZone: timezone, // Use chip's timezone
};
const timeOptions: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
hour12: true,
timeZone: timezone, // Use chip's timezone
};
const date = startDate.toLocaleDateString("en-US", dateOptions);
const startTime = startDate
.toLocaleTimeString("en-US", timeOptions)
.toLowerCase()
.replace(/\s/g, " ");
const endTime = endDate
.toLocaleTimeString("en-US", timeOptions)
.toLowerCase()
.replace(/\s/g, " ");
// ISO format for Cal.com
const isoDate = startDate.toISOString().split("T")[0]; // "2025-11-27"
const isoTimestamp = startDate.toISOString(); // "2025-11-27T05:30:00.000Z"
slots.push({
date,
startTime,
endTime,
durationMinutes,
isoDate,
isoTimestamp,
googleUrl: href,
});
});
if (slots.length === 0) {
console.warn("No valid slots parsed from Google chip");
return null;
}
return {
scheduleId,
timezone,
timezoneOffset,
slots,
detectedDuration,
};
} catch (error) {
console.error("Error parsing Google chip:", error);
return null;
}
}
@@ -0,0 +1,29 @@
/**
* Helper functions to parse form values into API-compatible formats
*/
export const parseBufferTime = (buffer: string): number => {
const match = buffer.match(/(\d+)/);
return match ? parseInt(match[1]) : 0;
};
export const parseMinimumNotice = (value: string, unit: string): number => {
const val = parseInt(value) || 0;
if (unit === "Hours") return val * 60;
if (unit === "Days") return val * 24 * 60;
return val; // Minutes
};
export const parseFrequencyUnit = (unit: string): string | null => {
const normalized = unit.toLowerCase();
if (normalized.includes("day")) return "day";
if (normalized.includes("week")) return "week";
if (normalized.includes("month")) return "month";
if (normalized.includes("year")) return "year";
return null;
};
export const parseSlotInterval = (interval: string): number => {
const match = interval.match(/(\d+)/);
return match ? parseInt(match[1]) : 0;
};
+33
View File
@@ -0,0 +1,33 @@
// forDisplayingInput is used to allow user to type "-" at the end and not replace with empty space.
// For eg:- "test-slug" is the slug user wants to set but while typing "test-" would get replace to "test" because of replace(/-+$/, "")
export const slugify = (str: string, forDisplayingInput?: boolean) => {
if (!str) {
return "";
}
const s = str
.toLowerCase() // Convert to lowercase
.trim() // Remove whitespace from both sides
.normalize("NFD") // Normalize to decomposed form for handling accents
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.replace(/\p{Diacritic}/gu, "") // Remove any diacritics (accents) from characters
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.replace(/[^.\p{L}\p{N}\p{Zs}\p{Emoji}]+/gu, "-") // Replace any non-alphanumeric characters (including Unicode and except "." period) with a dash
.replace(/[\s_#]+/g, "-") // Replace whitespace, # and underscores with a single dash
.replace(/^-+/, "") // Remove dashes from start
.replace(/\.{2,}/g, ".") // Replace consecutive periods with a single period
.replace(/^\.+/, "") // Remove periods from the start
.replace(
/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
""
) // Removes emojis
.replace(/\s+/g, " ")
.replace(/-+/g, "-"); // Replace consecutive dashes with a single dash
return forDisplayingInput ? s : s.replace(/-+$/, "").replace(/\.*$/, ""); // Remove dashes and period from end
};
export default slugify;
+63
View File
@@ -0,0 +1,63 @@
import { defineConfig } from "wxt";
export default defineConfig({
srcDir: "extension",
entrypointsDir: "entrypoints",
publicDir: "extension/public",
outDir: ".output",
manifest: {
name: "Cal.com Companion",
version: "1.7.0",
description: "Your calendar companion for quick booking and scheduling",
permissions: ["activeTab", "storage", "identity"],
host_permissions: [
"http://localhost:8081/*",
"https://api.cal.com/*",
"https://app.cal.com/*",
"https://mail.google.com/*",
],
content_security_policy: {
extension_pages:
"script-src 'self'; object-src 'self'; frame-src 'self' http://localhost:8081;",
},
action: {
default_title: "Cal.com Companion",
default_icon: {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png",
},
},
icons: {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png",
},
},
vite: () => ({
resolve: {
alias: {
"react-native": "react-native-web",
},
},
define: {
global: "globalThis",
__DEV__: JSON.stringify(false),
// Expose environment variables to the extension
"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
),
},
optimizeDeps: {
include: ["react-native-web"],
esbuildOptions: {
loader: {
".js": "jsx",
},
},
},
}),
});