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>
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
cd companion
|
||||
npx lint-staged
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
trailingComma: "es5",
|
||||
singleQuote: false,
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
arrowParens: "always",
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/(tabs)/event-types" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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 |
|
After Width: | Height: | Size: 17 KiB |
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "expo-router/entry";
|
||||
@@ -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" });
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["nativewind/types"]
|
||||
},
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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(/"([^&]+)"\]/);
|
||||
|
||||
// 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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||