chore: remove companion app (moved to calcom/companion) (#27957)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Volnei Munhoz
2026-02-16 10:08:24 -03:00
committed by GitHub
co-authored by Devin AI
parent 85e1ed3ab5
commit f8a93414a5
301 changed files with 1 additions and 58172 deletions
-57
View File
@@ -1,57 +0,0 @@
# EAS Build ignore file for monorepo
# Only upload the companion app folder
# ===========================================
# IGNORE EVERYTHING AT ROOT LEVEL
# ===========================================
/*
# ===========================================
# EXCEPT THE COMPANION APP
# ===========================================
!companion
# ===========================================
# BUT IGNORE THESE INSIDE COMPANION
# ===========================================
# Native folders - EAS generates these during builds (~2.1 GB)
companion/ios
companion/android
# Dependencies - EAS installs these during builds (~1.6 GB)
companion/node_modules
# Expo build cache
companion/.expo
companion/dist
companion/web-build
# Metro
companion/.metro-health-check*
# WXT Chrome Extension outputs (not needed for mobile builds)
companion/.output
companion/.wxt
companion/dev
# Kotlin cache
companion/.kotlin
# Build artifacts
companion/*.jks
companion/*.p8
companion/*.p12
companion/*.key
companion/*.mobileprovision
# Debug logs
companion/*.log
# TypeScript build info
companion/*.tsbuildinfo
# Environment files
companion/.env
companion/.env.*
!companion/.env.example
-120
View File
@@ -1,120 +0,0 @@
---
name: Companion (iOS/Android/Extension)
about: Report bugs or request features for mobile apps and browser extension
title: ""
labels: ["companion", "🚨 needs approval"]
assignees: ""
---
### Issue Type
<!--
Select one:
- Bug Report
- Feature Request
-->
- [ ] 🐛 Bug Report
- [ ] ✨ Feature Request
### Platform
<!--
Select all that apply:
-->
- [ ] 📱 iOS App
- [ ] 🤖 Android App
- [ ] 🧩 Browser Extension
### Platform Details
<!--
Fill in the details for your selected platform(s).
Delete sections that don't apply.
-->
#### iOS
- **iOS Version**: (e.g., 17.2)
- **Device Model**: (e.g., iPhone 15 Pro)
- **App Version**: (e.g., 1.0.0)
#### Android
- **Android Version**: (e.g., 14)
- **Device Model**: (e.g., Pixel 8)
- **App Version**: (e.g., 1.0.0)
#### Browser Extension
- **Browser & Version**: (e.g., Chrome 120, Firefox 121)
- **Extension Version**: (e.g., 1.0.0)
---
### Description
<!--
Provide a clear and concise description of the issue or feature.
-->
(Write your description here.)
### Steps to Reproduce (Bug Reports Only)
<!--
If this is a bug report, provide steps to reproduce the issue.
Delete this section for feature requests.
-->
1. Go to...
2. Click on...
3. See error...
### Expected Behavior
<!--
What did you expect to happen?
-->
(Describe what you expected.)
### Actual Behavior (Bug Reports Only)
<!--
What actually happened? Delete this section for feature requests.
-->
(Describe what actually happened.)
### Proposed Solution (Feature Requests Only)
<!--
If this is a feature request, describe your proposed solution.
Delete this section for bug reports.
-->
(Describe your proposed solution.)
### Screenshots / Evidence
<!--
Add screenshots, screen recordings, or console logs if applicable.
You can use tools like Bird Eats Bug for recordings.
-->
(Attach evidence here.)
### Additional Context
<!--
Any other information that might be helpful.
-->
(Add any additional context here.)
---
##### House rules
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves the request by removing this label.
- Your ideas are invaluable! However, they undergo review to ensure alignment with the product's direction.
- Follow best practices in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
-44
View File
@@ -1,44 +0,0 @@
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: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- uses: ./.github/actions/cache-checkout
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
cache: true
cache-dependency-path: companion/bun.lock
- name: Install dependencies
working-directory: companion
run: bun install --frozen-lockfile
- name: Build Expo web app
working-directory: companion
env:
EXPO_NO_TELEMETRY: 1
CI: true
run: bunx expo export --platform web
- name: Build browser extension
working-directory: companion
run: bun run ext:build
-33
View File
@@ -1,33 +0,0 @@
name: Companion Lint
on:
workflow_call:
permissions:
contents: read
jobs:
lint:
name: Lint Companion App
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- uses: ./.github/actions/cache-checkout
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
cache: false
- name: Install dependencies
working-directory: companion
run: bun install --frozen-lockfile
- name: Run lint
working-directory: companion
run: bun run lint:all
-34
View File
@@ -1,34 +0,0 @@
name: Companion Type Check
on:
workflow_call:
permissions:
contents: read
jobs:
typecheck:
name: Type Check Companion App
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- uses: ./.github/actions/cache-checkout
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.0
cache: true
cache-dependency-path: companion/bun.lock
- name: Install dependencies
working-directory: companion
run: bun install --frozen-lockfile
- name: Type check
working-directory: companion
run: bun run typecheck:all
-36
View File
@@ -167,7 +167,6 @@ jobs:
pull-requests: read
outputs:
has-files-requiring-all-checks: ${{ steps.filter-exclusions.outputs.has-files-requiring-all-checks }}
has-companion: ${{ steps.filter-inclusions.outputs.has-companion }}
has-api-v2-changes: ${{ steps.filter-inclusions.outputs.has-api-v2-changes }}
has-prisma-changes: ${{ steps.filter-inclusions.outputs.has-prisma-changes }}
has-agents-changes: ${{ steps.filter-inclusions.outputs.has-agents-changes }}
@@ -197,7 +196,6 @@ jobs:
filters: |
has-files-requiring-all-checks:
- '**'
- '!companion/**'
- '!.vscode/**'
- '!**/*.md'
- '!**/*.mdx'
@@ -213,8 +211,6 @@ jobs:
filters: |
has-agents-changes:
- "agents/**"
has-companion:
- "companion/**"
has-api-v2-changes:
- "apps/api/v2/**"
- "packages/platform-constants/**"
@@ -387,27 +383,6 @@ jobs:
uses: ./.github/workflows/docs-build.yml
secrets: inherit
build-companion:
name: Companion builds
needs: [prepare]
if: needs.prepare.outputs.has-companion == 'true'
uses: ./.github/workflows/companion-build.yml
secrets: inherit
typecheck-companion:
name: Type Checks
needs: [prepare]
if: needs.prepare.outputs.has-companion == 'true'
uses: ./.github/workflows/companion-typecheck.yml
secrets: inherit
lint-companion:
name: Linters
needs: [prepare]
if: needs.prepare.outputs.has-companion == 'true'
uses: ./.github/workflows/companion-lint.yml
secrets: inherit
build:
name: Production builds
needs: [prepare]
@@ -489,9 +464,6 @@ jobs:
build-api-v2,
build-atoms,
build-docs,
build-companion,
typecheck-companion,
lint-companion,
setup-db,
e2e,
e2e-api-v2,
@@ -540,14 +512,6 @@ jobs:
needs.e2e-app-store.result != 'success'
)
) ||
(
needs.prepare.outputs.has-companion == 'true' &&
(
needs.build-companion.result != 'success' ||
needs.typecheck-companion.result != 'success' ||
needs.lint-companion.result != 'success'
)
) ||
(
needs.prepare.outputs.has-agents-changes == 'true' &&
needs.validate-agents-format.result != 'success'
-63
View File
@@ -1,63 +0,0 @@
# 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
# =============================================================================
# OAUTH REDIRECT URL FORMATS BY BROWSER
# =============================================================================
#
# | Browser | Redirect URL Format | Notes |
# |---------|------------------------------------------------------------------|----------------------------------------------------|
# | Chrome | https://<id>.chromiumapp.org/oauth/callback | Get ID from `chrome://extensions` |
# | Brave | https://<id>.chromiumapp.org/oauth/callback | Same extension package as Chrome |
# | Edge | https://<id>.chromiumapp.org/oauth/callback | Different ID from Chrome |
# | Firefox | https://<uuid>.extensions.allizom.org/oauth/callback | UUID from `browser.identity.getRedirectURL()` |
# | Safari | https://<team>.<bundle>.appextension/oauth/callback | Check Xcode project configuration |
#
# =============================================================================
# =============================================================================
# FIREFOX OAUTH CONFIG
# =============================================================================
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_FIREFOX=
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_FIREFOX=
# =============================================================================
# SAFARI OAUTH CONFIG
# =============================================================================
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_SAFARI=
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_SAFARI=
# =============================================================================
# EDGE OAUTH CONFIG
# =============================================================================
EXPO_PUBLIC_CALCOM_OAUTH_CLIENT_ID_EDGE=
EXPO_PUBLIC_CALCOM_OAUTH_REDIRECT_URI_EDGE=
# ===========================================
# CACHE CONFIGURATION (all values in MINUTES)
# ===========================================
# Default cache stale time for all queries (default: 5 minutes)
EXPO_PUBLIC_CACHE_STALE_TIME_MINUTES=5
# Garbage collection time - how long to keep unused cache (default: 1440 minutes - 24 hours)
EXPO_PUBLIC_CACHE_GC_TIME_MINUTES=1440
# Bookings cache stale time (default: 5 minutes)
# After this time, data is considered stale and will refetch in background
EXPO_PUBLIC_BOOKINGS_CACHE_STALE_TIME_MINUTES=5
# Event Types cache stale time (default: -1 = never stale)
# -1 means data only refreshes on mutations (create/update/delete) or manual pull-to-refresh
EXPO_PUBLIC_EVENT_TYPES_CACHE_STALE_TIME_MINUTES=-1
# Schedules (Availability) cache stale time (default: -1 = never stale)
EXPO_PUBLIC_SCHEDULES_CACHE_STALE_TIME_MINUTES=-1
# User Profile cache stale time (default: -1 = never stale)
EXPO_PUBLIC_USER_PROFILE_CACHE_STALE_TIME_MINUTES=-1
# required for dev/local
EXPO_PUBLIC_COMPANION_DEV_URL=http://localhost:8081
-68
View File
@@ -1,68 +0,0 @@
# 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
safari-extension/
# 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
-2
View File
@@ -1,2 +0,0 @@
cd companion
npx lint-staged
-92
View File
@@ -1,92 +0,0 @@
import { createReadStream, existsSync, promises as fs } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { extname, join, normalize } from "node:path";
const distDir = join(process.cwd(), "dist");
const mimeTypes: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".map": "application/json; charset=utf-8",
};
const getSafePath = (urlPath: string): string => {
const decodedPath = decodeURIComponent(urlPath.split("?")[0]);
const normalized = normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, "");
return normalized.startsWith("/") ? normalized : `/${normalized}`;
};
const resolveFilePath = async (safePath: string): Promise<string> => {
let candidate = join(distDir, safePath);
try {
const stats = await fs.stat(candidate);
if (stats.isDirectory()) {
candidate = join(candidate, "index.html");
}
return candidate;
} catch {
const fallback = join(distDir, "index.html");
if (existsSync(fallback)) {
return fallback;
}
throw new Error("Dist folder missing. Run `npm run build` before deploying.");
}
};
const sendFile = (res: ServerResponse, filePath: string) => {
const ext = extname(filePath).toLowerCase();
const contentType = mimeTypes[ext] || "application/octet-stream";
res.setHeader("Content-Type", contentType);
res.setHeader(
"Cache-Control",
ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000, immutable"
);
const stream = createReadStream(filePath);
stream.on("error", (error) => {
console.error("Failed to read file");
if (process.env.NODE_ENV !== "production") {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[companion/api] Failed to read file", { message, stack });
}
res.statusCode = 500;
res.end("Internal Server Error");
});
stream.pipe(res);
};
export default async function handler(req: IncomingMessage, res: ServerResponse) {
if (req.method && !["GET", "HEAD"].includes(req.method)) {
res.statusCode = 405;
res.setHeader("Allow", "GET, HEAD");
res.end("Method Not Allowed");
return;
}
try {
const urlPath = req.url ?? "/";
const safePath = getSafePath(urlPath);
const filePath = await resolveFilePath(safePath);
sendFile(res, filePath);
} catch (error) {
console.error("Server render error");
if (process.env.NODE_ENV !== "production") {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[companion/api] Server render error", { message, stack });
}
res.statusCode = 500;
res.end("Internal Server Error");
}
}
-119
View File
@@ -1,119 +0,0 @@
{
"expo": {
"name": "Calcom",
"slug": "calcom-companion",
"scheme": "calcom",
"version": "1.0.4",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": false,
"appleTeamId": "ADWKXDJV75",
"deploymentTarget": "18.0",
"bundleIdentifier": "com.cal.companion",
"userInterfaceStyle": "automatic",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.default-data-protection": "NSFileProtectionComplete",
"com.apple.security.application-groups": ["group.com.cal.companion"]
},
"icon": "./assets/cal-logo.icon"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"package": "com.calcom.companion",
"softwareKeyboardLayoutMode": "pan",
"usesCleartextTraffic": false,
"userInterfaceStyle": "automatic"
},
"web": {
"favicon": "./assets/favicon.png",
"bundler": "metro"
},
"extra": {
"eas": {
"projectId": "f5e97f6f-1e95-44ac-bfa4-f737ef90f198"
}
},
"owner": "calcoms-organization",
"plugins": [
"expo-router",
"expo-secure-store",
"expo-web-browser",
"expo-image",
[
"expo-splash-screen",
{
"backgroundColor": "#ffffff",
"image": "./assets/splash-icon.png",
"imageWidth": 200
}
],
[
"@react-native-community/datetimepicker",
{
"android": {
"datePicker": {
"colorAccent": {
"light": "#000000"
},
"textColorPrimary": {
"light": "#000000"
}
},
"timePicker": {
"background": {
"light": "#ffffff"
},
"numbersBackgroundColor": {
"light": "#ffffff"
},
"headerBackground": {
"light": "#000000"
},
"numbersSelectorColor": {
"light": "#000000"
}
}
}
}
],
"@bacons/apple-targets",
[
"react-native-android-widget",
{
"widgets": [
{
"name": "UpcomingBookingsWidget",
"label": "Upcoming Bookings",
"minWidth": "180dp",
"minHeight": "110dp",
"description": "View your upcoming Cal.com bookings",
"previewImage": "./assets/widget-preview.png",
"resizeMode": "horizontal|vertical",
"targetCellWidth": 3,
"targetCellHeight": 2
}
]
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}
@@ -1,10 +0,0 @@
import { Stack } from "expo-router";
export default function AvailabilityLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="availability-detail" />
</Stack>
);
}
@@ -1,123 +0,0 @@
import { useColorScheme } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useRef } from "react";
import {
AvailabilityDetailScreen,
type AvailabilityDetailScreenHandle,
} from "@/components/screens/AvailabilityDetailScreen";
import { getColors } from "@/constants/colors";
// Type for action handlers exposed by AvailabilityDetailScreen.ios.tsx
type ActionHandlers = {
handleSetAsDefault: () => void;
handleDelete: () => void;
};
export default function AvailabilityDetailIOS() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
// Ref to store action handlers from AvailabilityDetailScreen
const actionHandlersRef = useRef<ActionHandlers | null>(null);
const screenRef = useRef<AvailabilityDetailScreenHandle>(null);
// Callback to receive action handlers from AvailabilityDetailScreen
const handleActionsReady = useCallback((handlers: ActionHandlers) => {
actionHandlersRef.current = handlers;
}, []);
// Navigation handlers for edit bottom sheets
const handleEditNameAndTimezone = useCallback(() => {
router.push(`/edit-availability-name?id=${id}` as never);
}, [router, id]);
const handleEditWorkingHours = useCallback(() => {
router.push(`/edit-availability-hours?id=${id}` as never);
}, [router, id]);
const handleEditOverride = useCallback(() => {
router.push(`/edit-availability-override?id=${id}` as never);
}, [router, id]);
// Action handlers for inline actions
const handleSetAsDefault = useCallback(() => {
if (actionHandlersRef.current?.handleSetAsDefault) {
actionHandlersRef.current.handleSetAsDefault();
} else if (screenRef.current?.setAsDefault) {
screenRef.current.setAsDefault();
}
}, []);
const handleDelete = useCallback(() => {
if (actionHandlersRef.current?.handleDelete) {
actionHandlersRef.current.handleDelete();
} else if (screenRef.current?.delete) {
screenRef.current.delete();
}
}, []);
if (!id) {
return null;
}
return (
<>
<Stack.Screen
options={{
title: "Availability",
headerBackTitle: "Availability",
headerBackButtonDisplayMode: "default",
headerTitle: "",
headerStyle: {
backgroundColor: isDark ? theme.background : theme.backgroundMuted,
},
headerShadowVisible: false,
}}
/>
<Stack.Header
style={{
shadowColor: "transparent",
backgroundColor: isDark ? theme.background : theme.backgroundMuted,
}}
>
<Stack.Header.Right>
{/* Edit Menu */}
<Stack.Header.Menu>
<Stack.Header.Label>Edit</Stack.Header.Label>
{/* Name and Timezone */}
<Stack.Header.MenuAction icon="pencil" onPress={handleEditNameAndTimezone}>
Name and Timezone
</Stack.Header.MenuAction>
{/* Working Hours */}
<Stack.Header.MenuAction icon="clock" onPress={handleEditWorkingHours}>
Working Hours
</Stack.Header.MenuAction>
{/* Date Override */}
<Stack.Header.MenuAction icon="calendar.badge.plus" onPress={handleEditOverride}>
Date Override
</Stack.Header.MenuAction>
{/* Set as Default */}
<Stack.Header.MenuAction icon="star" onPress={handleSetAsDefault}>
Set as Default
</Stack.Header.MenuAction>
{/* Delete */}
<Stack.Header.MenuAction icon="trash" onPress={handleDelete} destructive>
Delete Schedule
</Stack.Header.MenuAction>
</Stack.Header.Menu>
</Stack.Header.Right>
</Stack.Header>
<AvailabilityDetailScreen ref={screenRef} id={id} onActionsReady={handleActionsReady} />
</>
);
}
@@ -1,167 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useRef } from "react";
import { Text, useColorScheme } from "react-native";
import {
AvailabilityDetailScreen,
type AvailabilityDetailScreenHandle,
} from "@/components/screens/AvailabilityDetailScreen";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { getColors } from "@/constants/colors";
import { AppPressable } from "@/components/AppPressable";
// Type for action handlers exposed by AvailabilityDetailScreen
type ActionHandlers = {
handleSetAsDefault: () => void;
handleDelete: () => void;
};
export default function AvailabilityDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
// Ref to store action handlers from AvailabilityDetailScreen
const actionHandlersRef = useRef<ActionHandlers | null>(null);
const screenRef = useRef<AvailabilityDetailScreenHandle>(null);
// Callback to receive action handlers from AvailabilityDetailScreen
const handleActionsReady = useCallback((handlers: ActionHandlers) => {
actionHandlersRef.current = handlers;
}, []);
// Navigation handlers for edit modal screens
const handleEditNameAndTimezone = useCallback(() => {
router.push(`/edit-availability-name?id=${id}` as never);
}, [router, id]);
const handleEditWorkingHours = useCallback(() => {
router.push(`/edit-availability-hours?id=${id}` as never);
}, [router, id]);
const handleEditOverride = useCallback(() => {
router.push(`/edit-availability-override?id=${id}` as never);
}, [router, id]);
// Action handlers for inline actions
const handleSetAsDefault = useCallback(() => {
if (actionHandlersRef.current?.handleSetAsDefault) {
actionHandlersRef.current.handleSetAsDefault();
} else if (screenRef.current?.setAsDefault) {
screenRef.current.setAsDefault();
}
}, []);
const handleDelete = useCallback(() => {
if (actionHandlersRef.current?.handleDelete) {
actionHandlersRef.current.handleDelete();
} else if (screenRef.current?.delete) {
screenRef.current.delete();
}
}, []);
if (!id) {
return null;
}
return (
<>
<Stack.Screen
options={{
title: "",
headerBackTitle: "", // Hide default back title if it appears
headerStyle: {
backgroundColor: isDark ? "black" : "white",
},
headerShadowVisible: false,
headerLeft: () => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="mr-2 h-10 flex-row items-center justify-center rounded-full border border-[#E5E5E5] bg-white px-3 dark:border-[#262626] dark:bg-[#171717]"
>
<Ionicons
name="chevron-back"
size={20}
color={isDark ? "#FFFFFF" : "#000000"}
style={{ marginRight: 4 }}
/>
<Text className={`text-[15px] font-medium ${isDark ? "text-white" : "text-black"}`}>
Availability
</Text>
</AppPressable>
</HeaderButtonWrapper>
),
headerRight: () => (
<HeaderButtonWrapper side="right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="h-10 flex-row items-center justify-center rounded-full border border-[#E5E5E5] bg-white px-4 dark:border-[#262626] dark:bg-[#171717]">
<Text
className={`text-[15px] font-medium ${isDark ? "text-white" : "text-black"}`}
>
Edit
</Text>
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onPress={handleEditNameAndTimezone}>
<Ionicons
name="pencil-outline"
size={18}
color={isDark ? theme.text : "#000"}
/>
<Text className="ml-2 text-[15px] text-black dark:text-white">
Name and Timezone
</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={handleEditWorkingHours}>
<Ionicons name="time-outline" size={18} color={isDark ? theme.text : "#000"} />
<Text className="ml-2 text-[15px] text-black dark:text-white">
Working Hours
</Text>
</DropdownMenuItem>
<DropdownMenuItem onPress={handleEditOverride}>
<Ionicons
name="calendar-outline"
size={18}
color={isDark ? theme.text : "#000"}
/>
<Text className="ml-2 text-[15px] text-black dark:text-white">
Date Override
</Text>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onPress={handleSetAsDefault}>
<Ionicons name="star-outline" size={18} color={isDark ? theme.text : "#000"} />
<Text className="ml-2 text-[15px] text-black dark:text-white">
Set as Default
</Text>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onPress={handleDelete} variant="destructive">
<Ionicons name="trash-outline" size={18} color={theme.error} />
<Text className="ml-2 text-[15px]" style={{ color: theme.error }}>
Delete Schedule
</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</HeaderButtonWrapper>
),
}}
/>
<AvailabilityDetailScreen ref={screenRef} id={id} onActionsReady={handleActionsReady} />
</>
);
}
@@ -1,140 +0,0 @@
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Alert, Platform, Pressable, useColorScheme } from "react-native";
import { AvailabilityListScreen } from "@/components/screens/AvailabilityListScreen";
import { useCreateSchedule, useUserProfile } from "@/hooks";
import { CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
export default function Availability() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const { mutate: createScheduleMutation } = useCreateSchedule();
const { data: userProfile } = useUserProfile();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const handleCreateNew = () => {
// Use native iOS Alert.prompt for a native look
Alert.prompt(
"Add a new schedule",
"Create a new availability schedule.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Continue",
onPress: async (name?: string) => {
if (!name?.trim()) {
Alert.alert("Error", "Please enter a schedule name");
return;
}
// 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 {
console.log("Could not get user timezone, using default");
}
// Create schedule with Monday-Friday 9 AM - 5 PM default
createScheduleMutation(
{
name: name.trim(),
timeZone: userTimezone,
isDefault: false,
availability: [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
],
},
{
onSuccess: (newSchedule) => {
// Navigate to edit the newly created schedule
router.push({
pathname: "/(tabs)/(availability)/availability-detail",
params: {
id: newSchedule.id.toString(),
},
});
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error("Failed to create schedule", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[Availability] createSchedule failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to create schedule. Please try again.");
},
}
);
},
},
],
"plain-text",
"",
"default"
);
};
return (
<>
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : isDark ? "dark" : "light"}
hidden={Platform.OS === "android" || Platform.OS === "web"}
>
<Stack.Header.Title large>Availability</Stack.Header.Title>
<Stack.Header.Right>
{/* New Button - directly opens create dialog */}
<Stack.Header.Button onPress={handleCreateNew}>
<Stack.Header.Icon sf="plus" />
</Stack.Header.Button>
{/* Profile Button */}
{userProfile?.avatarUrl ? (
<Stack.Header.View>
<Pressable onPress={() => router.push("/profile-sheet")}>
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
</Pressable>
</Stack.Header.View>
) : (
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
)}
</Stack.Header.Right>
<Stack.Header.SearchBar
placeholder="Search schedules"
onChangeText={(e) => setSearchQuery(e.nativeEvent.text)}
obscureBackground={false}
barTintColor={isDark ? "#000" : "#fff"}
/>
</Stack.Header>
<AvailabilityListScreen
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
showCreateModal={showCreateModal}
onShowCreateModalChange={setShowCreateModal}
/>
</>
);
}
@@ -1,17 +0,0 @@
import { Stack } from "expo-router";
import { Platform } from "react-native";
// Define initial route for deep linking
// This ensures booking-detail has index as parent in the stack
export const unstable_settings = {
initialRouteName: "index",
};
export default function BookingsLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: Platform.OS === "ios" }} />
<Stack.Screen name="booking-detail" />
</Stack>
);
}
@@ -1,302 +0,0 @@
import * as Clipboard from "expo-clipboard";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { useColorScheme } from "react-native";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import { useAuth } from "@/contexts/AuthContext";
import { useBookingByUid } from "@/hooks/useBookings";
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { getMeetingUrl } from "@/utils/booking";
import { openInDefaultBrowser } from "@/utils/browser";
// Empty actions result for when no booking is loaded
const EMPTY_ACTIONS: BookingActionsResult = {
reschedule: { visible: false, enabled: false },
rescheduleRequest: { visible: false, enabled: false },
cancel: { visible: false, enabled: false },
changeLocation: { visible: false, enabled: false },
addGuests: { visible: false, enabled: false },
viewRecordings: { visible: false, enabled: false },
meetingSessionDetails: { visible: false, enabled: false },
markNoShow: { visible: false, enabled: false },
};
// Type for action handlers exposed by BookingDetailScreen
type ActionHandlers = {
openRescheduleModal: () => void;
openEditLocationModal: () => void;
openAddGuestsModal: () => void;
openViewRecordingsModal: () => void;
openMeetingSessionDetailsModal: () => void;
openMarkNoShowModal: () => void;
handleCancelBooking: () => void;
};
// Get month name from date string
const getMonthName = (dateString: string | undefined): string => {
if (!dateString) return "Back";
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return "Back";
return date.toLocaleDateString("en-US", { month: "long" });
};
export default function BookingDetailIOS() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const { userInfo } = useAuth();
const colorScheme = useColorScheme();
// Use React Query hook for booking data - single source of truth
const { data: booking, isLoading, error, refetch, isRefetching } = useBookingByUid(uid);
// Ref to store action handlers from BookingDetailScreen
const actionHandlersRef = useRef<ActionHandlers | null>(null);
// Callback to receive action handlers from BookingDetailScreen
const handleActionsReady = useCallback((handlers: ActionHandlers) => {
actionHandlersRef.current = handlers;
}, []);
// Get the month name from booking start date for back button
const monthName = useMemo(() => {
const startTime = booking?.start || booking?.startTime;
return getMonthName(startTime);
}, [booking?.start, booking?.startTime]);
// Get meeting URL for Join button
const meetingUrl = useMemo(() => getMeetingUrl(booking ?? null), [booking]);
// Handle join meeting
const handleJoinMeeting = useCallback(() => {
if (meetingUrl) {
openInDefaultBrowser(meetingUrl, "meeting link");
}
}, [meetingUrl]);
// Handle copy meeting link
const handleCopyMeetingLink = useCallback(async () => {
if (meetingUrl) {
await Clipboard.setStringAsync(meetingUrl);
showSuccessAlert("Copied", "Meeting link copied to clipboard");
}
}, [meetingUrl]);
// Compute actions using centralized gating (same as BookingDetailScreen)
const actions = useMemo(() => {
if (!booking) return EMPTY_ACTIONS;
return getBookingActions({
booking,
eventType: undefined, // EventType not available in this context
currentUserId: userInfo?.id,
currentUserEmail: userInfo?.email,
isOnline: true, // Assume online for now
});
}, [booking, userInfo?.id, userInfo?.email]);
// Invoke a handler by name - only accesses ref at invocation time (event handler)
// This avoids creating closures that capture the ref during render
const invokeHandler = useCallback((handlerName: keyof ActionHandlers, errorMessage: string) => {
const handlers = actionHandlersRef.current;
if (handlers?.[handlerName]) {
(handlers[handlerName] as () => void)();
} else {
showErrorAlert("Error", errorMessage);
}
}, []);
// Define and filter booking actions
// Actions store handler metadata instead of closures to avoid capturing ref during render
const bookingActionsSections = useMemo(() => {
// Define all actions with handler metadata (not closures)
const allEditEventActions = [
{
id: "reschedule",
label: "Reschedule Booking",
icon: "calendar" as const,
handlerName: "openRescheduleModal" as const,
errorMessage: "Unable to reschedule. Please try again.",
gatingKey: "reschedule" as const,
},
{
id: "edit-location",
label: "Edit Location",
icon: "location" as const,
handlerName: "openEditLocationModal" as const,
errorMessage: "Unable to edit location. Please try again.",
gatingKey: "changeLocation" as const,
},
{
id: "add-guests",
label: "Add Guests",
icon: "person.badge.plus" as const,
handlerName: "openAddGuestsModal" as const,
errorMessage: "Unable to add guests. Please try again.",
gatingKey: "addGuests" as const,
},
];
const allAfterEventActions = [
{
id: "view-recordings",
label: "View Recordings",
icon: "video" as const,
handlerName: "openViewRecordingsModal" as const,
errorMessage: "Unable to view recordings. Please try again.",
gatingKey: "viewRecordings" as const,
},
{
id: "session-details",
label: "Meeting Session Details",
icon: "info.circle" as const,
handlerName: "openMeetingSessionDetailsModal" as const,
errorMessage: "Unable to view session details. Please try again.",
gatingKey: "meetingSessionDetails" as const,
},
{
id: "mark-no-show",
label: "Mark as No-Show",
icon: "eye.slash" as const,
handlerName: "openMarkNoShowModal" as const,
errorMessage: "Unable to mark no-show. Please try again.",
gatingKey: "markNoShow" as const,
},
];
const allStandaloneActions = [
{
id: "report",
label: "Report Booking",
icon: "flag" as const,
handlerName: null,
errorMessage: null,
customHandler: () => {
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
},
destructive: true,
gatingKey: null,
},
{
id: "cancel",
label: "Cancel Event",
icon: "xmark.circle" as const,
handlerName: "handleCancelBooking" as const,
errorMessage: "Unable to cancel. Please try again.",
destructive: true,
gatingKey: "cancel" as const,
},
];
// Filter actions based on gating logic
const filterAction = (action: { gatingKey: keyof typeof actions | null }) => {
if (action.gatingKey === null) return true;
const gating = actions[action.gatingKey];
return gating.visible && gating.enabled;
};
return {
editEvent: allEditEventActions.filter(filterAction),
afterEvent: allAfterEventActions.filter(filterAction),
standalone: allStandaloneActions.filter(filterAction),
};
}, [actions]);
return (
<>
<Stack.Screen
options={{
title: "Booking", // This appears in long-press navigation history
headerBackTitle: monthName, // This shows on the back button
headerBackButtonDisplayMode: "default",
headerTitle: "", // Hide the title in the header bar itself
headerShadowVisible: false,
headerTransparent: true,
}}
/>
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : "light"}
>
<Stack.Header.Right>
{/* Actions Menu */}
<Stack.Header.Menu>
<Stack.Header.Icon sf="ellipsis" />
{/* Copy Meeting Link - only show if there's a meeting URL */}
{meetingUrl && (
<Stack.Header.MenuAction icon="doc.on.doc" onPress={handleCopyMeetingLink}>
Copy Meeting Link
</Stack.Header.MenuAction>
)}
{/* Edit Event Section */}
<Stack.Header.Menu inline title="Edit Event">
{bookingActionsSections.editEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={() => invokeHandler(action.handlerName, action.errorMessage)}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* After Event Section */}
<Stack.Header.Menu inline title="After Event">
{bookingActionsSections.afterEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={() => invokeHandler(action.handlerName, action.errorMessage)}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* Danger Zone Submenu */}
<Stack.Header.Menu inline title="Danger Zone">
{bookingActionsSections.standalone.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={() => {
if (action.customHandler) {
action.customHandler();
} else if (action.handlerName && action.errorMessage) {
invokeHandler(action.handlerName, action.errorMessage);
}
}}
destructive
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
</Stack.Header.Menu>
{meetingUrl && (
<Stack.Header.Button
onPress={handleJoinMeeting}
variant="prominent"
tintColor={colorScheme === "dark" ? "#FFF" : "#000"}
>
Join
</Stack.Header.Button>
)}
</Stack.Header.Right>
</Stack.Header>
<BookingDetailScreen
booking={booking}
isLoading={isLoading}
error={error ?? null}
refetch={refetch}
isRefetching={isRefetching}
onActionsReady={handleActionsReady}
/>
</>
);
}
@@ -1,530 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { Stack, useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Platform, Text, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAuth } from "@/contexts/AuthContext";
import { useBookingByUid } from "@/hooks/useBookings";
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
import { getMeetingUrl } from "@/utils/booking";
import { openInDefaultBrowser } from "@/utils/browser";
// Empty actions result for when no booking is loaded
const EMPTY_ACTIONS: BookingActionsResult = {
reschedule: { visible: false, enabled: false },
rescheduleRequest: { visible: false, enabled: false },
cancel: { visible: false, enabled: false },
changeLocation: { visible: false, enabled: false },
addGuests: { visible: false, enabled: false },
viewRecordings: { visible: false, enabled: false },
meetingSessionDetails: { visible: false, enabled: false },
markNoShow: { visible: false, enabled: false },
};
// Type for action handlers exposed by BookingDetailScreen
type ActionHandlers = {
openRescheduleModal: () => void;
openEditLocationModal: () => void;
openAddGuestsModal: () => void;
openViewRecordingsModal: () => void;
openMeetingSessionDetailsModal: () => void;
openMarkNoShowModal: () => void;
handleCancelBooking: () => void;
};
const getIconName = (sfSymbol: string): keyof typeof Ionicons.glyphMap => {
switch (sfSymbol) {
case "calendar":
return "calendar";
case "location":
return "location";
case "person.badge.plus":
return "person-add";
case "video":
return "videocam";
case "info.circle":
return "information-circle";
case "eye.slash":
return "eye-off";
case "flag":
return "flag";
case "xmark.circle":
return "close-circle";
default:
return "ellipsis-horizontal";
}
};
export default function BookingDetail() {
"use no memo";
const { uid } = useLocalSearchParams<{ uid: string }>();
const { userInfo } = useAuth();
// Use React Query hook for booking data - single source of truth
const { data: booking, isLoading, error, refetch, isRefetching } = useBookingByUid(uid);
// Ref to store action handlers from BookingDetailScreen
const actionHandlersRef = useRef<ActionHandlers | null>(null);
// Callback to receive action handlers from BookingDetailScreen
const handleActionsReady = useCallback((handlers: ActionHandlers) => {
actionHandlersRef.current = handlers;
}, []);
// Compute actions using centralized gating (same as BookingDetailScreen)
const actions = useMemo(() => {
if (!booking) return EMPTY_ACTIONS;
return getBookingActions({
booking,
eventType: undefined, // EventType not available in this context
currentUserId: userInfo?.id,
currentUserEmail: userInfo?.email,
isOnline: true, // Assume online for now
});
}, [booking, userInfo?.id, userInfo?.email]);
// Action handlers that use the booking data
// Note: These are stable functions that don't change, so we don't memoize them
// They access refs which is safe in event handlers
const handleReschedule = useCallback(() => {
if (actionHandlersRef.current?.openRescheduleModal) {
actionHandlersRef.current.openRescheduleModal();
} else {
showErrorAlert("Error", "Unable to reschedule. Please try again.");
}
}, []);
const handleEditLocation = useCallback(() => {
if (actionHandlersRef.current?.openEditLocationModal) {
actionHandlersRef.current.openEditLocationModal();
} else {
showErrorAlert("Error", "Unable to edit location. Please try again.");
}
}, []);
const handleAddGuests = useCallback(() => {
if (actionHandlersRef.current?.openAddGuestsModal) {
actionHandlersRef.current.openAddGuestsModal();
} else {
showErrorAlert("Error", "Unable to add guests. Please try again.");
}
}, []);
const handleViewRecordings = useCallback(() => {
if (actionHandlersRef.current?.openViewRecordingsModal) {
actionHandlersRef.current.openViewRecordingsModal();
} else {
showErrorAlert("Error", "Unable to view recordings. Please try again.");
}
}, []);
const handleSessionDetails = useCallback(() => {
if (actionHandlersRef.current?.openMeetingSessionDetailsModal) {
actionHandlersRef.current.openMeetingSessionDetailsModal();
} else {
showErrorAlert("Error", "Unable to view session details. Please try again.");
}
}, []);
const handleMarkNoShow = useCallback(() => {
if (actionHandlersRef.current?.openMarkNoShowModal) {
actionHandlersRef.current.openMarkNoShowModal();
} else {
showErrorAlert("Error", "Unable to mark no-show. Please try again.");
}
}, []);
const handleReport = useCallback(() => {
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
}, []);
const handleCancel = useCallback(() => {
if (actionHandlersRef.current?.handleCancelBooking) {
actionHandlersRef.current.handleCancelBooking();
} else {
showErrorAlert("Error", "Unable to cancel. Please try again.");
}
}, []);
// Define booking actions organized by sections
// For iOS glass UI (header menu), we filter out actions where !visible || !enabled
// This matches the behavior in BookingListItem.ios.tsx context menu
const bookingActionsSections = useMemo(() => {
// All possible actions with their gating keys
const allEditEventActions = [
{
id: "reschedule",
label: "Reschedule Booking",
icon: "calendar" as const,
onPress: handleReschedule,
gatingKey: "reschedule" as const,
},
{
id: "edit-location",
label: "Edit Location",
icon: "location" as const,
onPress: handleEditLocation,
gatingKey: "changeLocation" as const,
},
{
id: "add-guests",
label: "Add Guests",
icon: "person.badge.plus" as const,
onPress: handleAddGuests,
gatingKey: "addGuests" as const,
},
];
const allAfterEventActions = [
{
id: "view-recordings",
label: "View Recordings",
icon: "video" as const,
onPress: handleViewRecordings,
gatingKey: "viewRecordings" as const,
},
{
id: "session-details",
label: "Meeting Session Details",
icon: "info.circle" as const,
onPress: handleSessionDetails,
gatingKey: "meetingSessionDetails" as const,
},
{
id: "mark-no-show",
label: "Mark as No-Show",
icon: "eye.slash" as const,
onPress: handleMarkNoShow,
gatingKey: "markNoShow" as const,
},
];
const allStandaloneActions = [
{
id: "report",
label: "Report Booking",
icon: "flag" as const,
onPress: handleReport,
destructive: true,
// Report is always visible (no gating)
gatingKey: null,
},
{
id: "cancel",
label: "Cancel Event",
icon: "xmark.circle" as const,
onPress: handleCancel,
destructive: true,
gatingKey: "cancel" as const,
},
];
// Filter actions based on gating (for glass UI, only show visible && enabled)
const filterAction = (action: { gatingKey: keyof typeof actions | null }) => {
if (action.gatingKey === null) return true; // No gating, always show
const gating = actions[action.gatingKey];
return gating.visible && gating.enabled;
};
return {
editEvent: allEditEventActions.filter(filterAction),
afterEvent: allAfterEventActions.filter(filterAction),
standalone: allStandaloneActions.filter(filterAction),
};
}, [
actions,
handleReschedule,
handleEditLocation,
handleAddGuests,
handleViewRecordings,
handleSessionDetails,
handleMarkNoShow,
handleReport,
handleCancel,
]);
const router = useRouter();
const navigation = useNavigation();
const colorScheme = useColorScheme();
const isDarkMode = colorScheme === "dark";
const meetingUrl = useMemo(() => getMeetingUrl(booking ?? null), [booking]);
const handleJoinMeeting = useCallback(() => {
if (meetingUrl) {
openInDefaultBrowser(meetingUrl, "meeting link");
}
}, [meetingUrl]);
const handleCopyMeetingLink = useCallback(async () => {
if (meetingUrl) {
await Clipboard.setStringAsync(meetingUrl);
showSuccessAlert("Copied", "Meeting link copied to clipboard");
}
}, [meetingUrl]);
const renderHeaderLeft = useCallback(() => {
let backButtonLabel = "Back";
// Check both start and startTime properties
const startTime = booking?.start || booking?.startTime;
if (startTime) {
const date = new Date(startTime);
if (!Number.isNaN(date.getTime())) {
backButtonLabel = date.toLocaleDateString("en-US", { month: "long" });
}
}
return (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="mr-2 h-10 flex-row items-center justify-center rounded-full border border-[#E5E5E5] bg-white px-3 dark:border-[#262626] dark:bg-[#171717]"
>
<Ionicons
name="chevron-back"
size={20}
color={isDarkMode ? "#FFFFFF" : "#000000"}
style={{ marginRight: 4 }}
/>
<Text className={`text-[15px] font-medium ${isDarkMode ? "text-white" : "text-black"}`}>
{backButtonLabel}
</Text>
</AppPressable>
</HeaderButtonWrapper>
);
}, [booking?.start, booking?.startTime, router, isDarkMode]);
const renderHeaderRight = useCallback(
() => (
<HeaderButtonWrapper side="right">
<View className="flex-row items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="h-10 w-10 items-center justify-center rounded-full border border-[#E5E5E5] bg-white dark:border-[#262626] dark:bg-[#171717]">
<Ionicons
name="ellipsis-horizontal"
size={20}
color={isDarkMode ? "#FFFFFF" : "#000000"}
/>
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-56"
align="end"
>
{meetingUrl && (
<>
<DropdownMenuItem onPress={handleCopyMeetingLink}>
<View className="flex-row items-center gap-2">
<Ionicons
name="copy-outline"
size={18}
color={isDarkMode ? "#FFFFFF" : "#000000"}
/>
<Text className="text-base text-[#000000] dark:text-white">
Copy meeting link
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{bookingActionsSections.editEvent.length > 0 && (
<>
<DropdownMenuLabel>Edit Event</DropdownMenuLabel>
{bookingActionsSections.editEvent.map((action) => (
<DropdownMenuItem key={action.id} onPress={action.onPress}>
<View className="flex-row items-center gap-2">
<Ionicons
name={getIconName(action.icon)}
size={18}
color={isDarkMode ? "#FFFFFF" : "#000000"}
/>
<Text className="text-base text-[#000000] dark:text-white">
{action.label}
</Text>
</View>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{bookingActionsSections.afterEvent.length > 0 && (
<>
<DropdownMenuLabel>After Event</DropdownMenuLabel>
{bookingActionsSections.afterEvent.map((action) => (
<DropdownMenuItem key={action.id} onPress={action.onPress}>
<View className="flex-row items-center gap-2">
<Ionicons
name={getIconName(action.icon)}
size={18}
color={isDarkMode ? "#FFFFFF" : "#000000"}
/>
<Text className="text-base text-[#000000] dark:text-white">
{action.label}
</Text>
</View>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>Danger Zone</DropdownMenuLabel>
{bookingActionsSections.standalone.map((action) => (
<DropdownMenuItem
key={action.id}
onPress={action.onPress}
variant={action.destructive ? "destructive" : "default"}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={getIconName(action.icon)}
size={18}
color={action.destructive ? "#EF4444" : isDarkMode ? "#FFFFFF" : "#000000"}
/>
<Text
className={`text-base ${
action.destructive ? "text-red-500" : "text-[#000000] dark:text-white"
}`}
>
{action.label}
</Text>
</View>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{meetingUrl && (
<AppPressable
onPress={handleJoinMeeting}
className={`h-9 flex-row items-center justify-center rounded-full border px-5 ${
isDarkMode ? "border-white bg-white" : "border-black bg-black"
}`}
>
<Text
className={`text-[15px] font-medium ${isDarkMode ? "text-black" : "text-white"}`}
>
Join
</Text>
</AppPressable>
)}
</View>
</HeaderButtonWrapper>
),
[isDarkMode, meetingUrl, handleCopyMeetingLink, bookingActionsSections, handleJoinMeeting]
);
// Force header update on Android/Web
useEffect(() => {
if (Platform.OS === "android" || Platform.OS === "web") {
navigation.setOptions({
headerRight: renderHeaderRight,
headerLeft: renderHeaderLeft,
});
}
}, [navigation, renderHeaderRight, renderHeaderLeft]);
return (
<>
<Stack.Screen
options={{
headerBackButtonDisplayMode: "minimal",
title: "",
headerShown: Platform.OS !== "ios",
headerStyle: {
backgroundColor: isDarkMode ? "black" : "white",
},
headerShadowVisible: false,
headerLeft:
Platform.OS === "android" || Platform.OS === "web" ? renderHeaderLeft : undefined,
headerRight:
Platform.OS === "android" || Platform.OS === "web" ? renderHeaderRight : undefined,
}}
/>
{Platform.OS === "ios" && (
<Stack.Header style={{ shadowColor: "transparent" }}>
<Stack.Header.Title>Booking Details</Stack.Header.Title>
{/* Header right and left API only works on iOS ATM see: https://docs.expo.dev/versions/unversioned/sdk/router/#stackheaderright */}
<Stack.Header.Right>
<Stack.Header.Menu>
<Stack.Header.Label>Actions</Stack.Header.Label>
<Stack.Header.Icon sf="ellipsis" />
{/* Edit Event Section */}
<Stack.Header.Menu inline title="Edit Event">
{bookingActionsSections.editEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* After Event Section */}
<Stack.Header.Menu inline title="After Event">
{bookingActionsSections.afterEvent.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
{/* Danger Zone Submenu */}
<Stack.Header.Menu inline title="Danger Zone">
{bookingActionsSections.standalone.map((action) => (
<Stack.Header.MenuAction
key={action.id}
icon={action.icon}
onPress={action.onPress}
destructive
>
{action.label}
</Stack.Header.MenuAction>
))}
</Stack.Header.Menu>
</Stack.Header.Menu>
</Stack.Header.Right>
</Stack.Header>
)}
<BookingDetailScreen
booking={booking}
isLoading={isLoading}
error={error ?? null}
refetch={refetch}
isRefetching={isRefetching}
onActionsReady={handleActionsReady}
/>
{/* Action Modals for iOS header menu */}
</>
);
}
@@ -1,216 +0,0 @@
import type { NativeStackHeaderItemMenuAction } from "@react-navigation/native-stack";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams } from "expo-router";
import { useState, useEffect, useRef } from "react";
import { useColorScheme } from "react-native";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { useEventTypes } from "@/hooks";
import { type BookingFilter, useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
const VALID_FILTERS: BookingFilter[] = [
"upcoming",
"unconfirmed",
"recurring",
"past",
"cancelled",
];
function isValidBookingFilter(value: string | undefined): value is BookingFilter {
return value !== undefined && VALID_FILTERS.includes(value as BookingFilter);
}
export default function Bookings() {
const { filter } = useLocalSearchParams<{ filter?: string }>();
const initialFilter = isValidBookingFilter(filter) ? filter : "upcoming";
const [searchQuery, setSearchQuery] = useState("");
const [selectedEventTypeId, setSelectedEventTypeId] = useState<number | null>(null);
const { data: eventTypes } = useEventTypes();
// Use the active booking filter hook
const { activeFilter, filterOptions, filterParams, handleFilterChange } = useActiveBookingFilter(
initialFilter,
() => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
}
);
// Track if we want to ignore the next activeFilter change (because it came from URL sync)
const lastUrlFilter = useRef<string | null>(null);
// Update filter when URL params change (for when component is already mounted)
useEffect(() => {
if (
isValidBookingFilter(filter) &&
filter !== activeFilter &&
filter !== lastUrlFilter.current
) {
lastUrlFilter.current = filter;
handleFilterChange(filter);
}
}, [filter, activeFilter, handleFilterChange]);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const textColor = isDark ? "#FFFFFF" : "#000000";
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const clearEventTypeFilter = () => {
setSelectedEventTypeId(null);
};
const buildActiveBookingFilter = () => {
const currentFilterOption = filterOptions.find((option) => option.key === activeFilter);
const statusMenuItems: NativeStackHeaderItemMenuAction[] = filterOptions.map((option) => {
const isSelected = activeFilter === option.key;
return {
type: "action",
label: option.label,
icon: {
name: isSelected
? "checkmark.circle.fill"
: option.key === "upcoming"
? "calendar"
: option.key === "unconfirmed"
? "questionmark.circle"
: option.key === "recurring"
? "repeat.circle"
: option.key === "past"
? "checkmark.circle"
: "xmark.circle",
type: "sfSymbol",
},
onPress: () => {
handleFilterChange(option.key);
},
} satisfies NativeStackHeaderItemMenuAction;
});
return {
type: "menu" as const,
label: currentFilterOption?.label || "Filter",
labelStyle: {
fontWeight: "600",
color: textColor,
},
menu: {
title: "Filter by Status",
items: statusMenuItems,
},
};
};
const buildEventTypeFilterMenu = () => {
const eventTypeMenuItems: NativeStackHeaderItemMenuAction[] = (eventTypes || []).map(
(eventType) => {
const isSelected = selectedEventTypeId === eventType.id;
return {
type: "action",
label: eventType.title,
icon: {
name: "calendar.badge.clock",
type: "sfSymbol",
},
state: isSelected ? "on" : "off",
onPress: () => {
// Toggle behavior: if already selected, clear filter; otherwise, select it
if (isSelected) {
clearEventTypeFilter();
} else {
setSelectedEventTypeId(eventType.id);
}
},
};
}
);
const clearFilterItem: NativeStackHeaderItemMenuAction[] =
selectedEventTypeId !== null
? [
{
type: "action",
label: "Clear filter",
icon: {
name: "xmark.circle",
type: "sfSymbol",
},
onPress: () => {
clearEventTypeFilter();
},
},
]
: [];
const menuItems = [...eventTypeMenuItems, ...clearFilterItem];
return {
type: "menu" as const,
label: "Filter by Event Type",
icon: {
name: "line.3.horizontal.decrease",
type: "sfSymbol",
},
labelStyle: {
fontWeight: "600",
color: textColor,
},
menu: {
title: menuItems.length > 0 ? "Filter by Event Type" : "No Event Types",
items: [
...menuItems,
// Show message if no event types available
...(menuItems.length === 0
? [
{
type: "action",
label: "No event types available",
onPress: () => {},
} satisfies NativeStackHeaderItemMenuAction,
]
: []),
],
},
};
};
return (
<>
<Stack.Screen
options={{
title: "Bookings",
headerBlurEffect: isLiquidGlassAvailable() ? undefined : isDark ? "dark" : "light",
headerStyle: {
backgroundColor: "transparent",
},
headerLargeTitleEnabled: true,
headerSearchBarOptions: {
placeholder: "Search bookings",
onChangeText: (e) => handleSearch(e.nativeEvent.text),
obscureBackground: false,
barTintColor: isDark ? "#171717" : "#fff",
},
unstable_headerRightItems: () => {
const eventTypeFilterMenu = buildEventTypeFilterMenu();
const statusFilterMenu = buildActiveBookingFilter();
return [eventTypeFilterMenu, statusFilterMenu];
},
}}
/>
<BookingListScreen
searchQuery={searchQuery}
selectedEventTypeId={selectedEventTypeId}
activeFilter={activeFilter}
filterParams={filterParams}
iosStyle={true}
/>
</>
);
}
-221
View File
@@ -1,221 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useState, useEffect, useRef } from "react";
import { Text, TextInput, useColorScheme, View } from "react-native";
import { BookingListScreen } from "@/components/booking-list-screen/BookingListScreen";
import { Header } from "@/components/Header";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppPressable } from "@/components/AppPressable";
import { type BookingFilter, useActiveBookingFilter } from "@/hooks/useActiveBookingFilter";
import { useEventTypes } from "@/hooks";
import { getColors } from "@/constants/colors";
const VALID_FILTERS: BookingFilter[] = [
"upcoming",
"unconfirmed",
"recurring",
"past",
"cancelled",
];
function isValidBookingFilter(value: string | undefined): value is BookingFilter {
return value !== undefined && VALID_FILTERS.includes(value as BookingFilter);
}
export default function Bookings() {
const { filter } = useLocalSearchParams<{ filter?: string }>();
const initialFilter = isValidBookingFilter(filter) ? filter : "upcoming";
const [searchQuery, setSearchQuery] = useState("");
const [selectedEventTypeId, setSelectedEventTypeId] = useState<number | null>(null);
const [selectedEventTypeLabel, setSelectedEventTypeLabel] = useState<string | null>(null);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
// Use React Query hook for event types (same as iOS for unified caching)
const { data: eventTypes = [], isLoading: eventTypesLoading } = useEventTypes();
// Use the active booking filter hook
const {
activeFilter,
filterOptions,
filterParams,
handleFilterChange: originalHandleFilterChange,
} = useActiveBookingFilter(initialFilter, () => {
// Clear dependent filters when status filter changes
setSearchQuery("");
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
});
// Wrap handleFilterChange with debug logging
const handleFilterChange = (filter: string) => {
originalHandleFilterChange(filter as BookingFilter);
};
// Track if we want to ignore the next activeFilter change (because it came from URL sync)
const lastUrlFilter = useRef<string | null>(null);
// Reactively update filter when URL params change (e.g., from deep link)
useEffect(() => {
if (
isValidBookingFilter(filter) &&
filter !== activeFilter &&
filter !== lastUrlFilter.current
) {
lastUrlFilter.current = filter;
originalHandleFilterChange(filter);
}
}, [filter, activeFilter, originalHandleFilterChange]);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const clearEventTypeFilter = () => {
setSelectedEventTypeId(null);
setSelectedEventTypeLabel(null);
};
const handleEventTypeSelect = (eventTypeId: number | null, label?: string | null) => {
if (eventTypeId === null) {
clearEventTypeFilter();
} else {
setSelectedEventTypeId(eventTypeId);
setSelectedEventTypeLabel(label || null);
}
};
const renderFilterControls = () => {
const filterLabel =
selectedEventTypeId !== null ? selectedEventTypeLabel || "Event Type" : "Filter";
return (
<View
className="border-b border-gray-300 bg-gray-100 px-2 py-2 md:px-4"
style={{
borderBottomColor: theme.borderSubtle,
backgroundColor: isDark ? theme.background : undefined,
}}
>
<View className="flex-row items-center gap-3">
{/* Dropdown menu for event type filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable
className="flex-row items-center rounded-lg border border-gray-200 bg-white"
style={{
borderColor: theme.border,
backgroundColor: theme.backgroundSecondary,
paddingHorizontal: 8,
paddingVertical: 6,
flexDirection: "row",
alignItems: "center",
}}
>
<Ionicons name="options-outline" size={14} color={theme.textSecondary} />
<Text
className={`text-sm ${selectedEventTypeId !== null ? "font-semibold" : ""}`}
style={{
marginLeft: 4,
color: selectedEventTypeId !== null ? theme.text : theme.textSecondary,
}}
numberOfLines={1}
>
{filterLabel}
</Text>
<Ionicons
name="chevron-down"
size={12}
color={theme.textSecondary}
style={{ marginLeft: 2 }}
/>
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-52"
align="start"
>
{/* Clear filter option */}
<DropdownMenuCheckboxItem
checked={selectedEventTypeId === null}
onCheckedChange={() => handleEventTypeSelect(null)}
>
<Text className="text-base" style={{ color: theme.text }}>
All Event Types
</Text>
</DropdownMenuCheckboxItem>
{/* Event type options */}
{eventTypes.map((eventType) => (
<DropdownMenuCheckboxItem
key={eventType.id}
checked={selectedEventTypeId === eventType.id}
onCheckedChange={() => handleEventTypeSelect(eventType.id, eventType.title)}
>
<Text className="text-base" style={{ color: theme.text }} numberOfLines={1}>
{eventType.title}
</Text>
</DropdownMenuCheckboxItem>
))}
{/* Loading state */}
{eventTypesLoading && eventTypes.length === 0 && (
<DropdownMenuCheckboxItem checked={false} onCheckedChange={() => {}}>
<Text className="text-base text-gray-500 dark:text-gray-400">Loading...</Text>
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<View style={{ flex: 1 }}>
<TextInput
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-black"
style={{
borderColor: theme.border,
backgroundColor: theme.backgroundSecondary,
color: theme.text,
}}
placeholder="Search bookings"
placeholderTextColor={theme.textSecondary}
value={searchQuery}
onChangeText={handleSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
</View>
</View>
</View>
);
};
return (
<BookingListScreen
renderHeader={() => (
<Header
filterOptions={filterOptions}
activeFilter={activeFilter}
onFilterChange={handleFilterChange as (filterKey: string) => void}
/>
)}
renderFilterControls={renderFilterControls}
eventTypes={eventTypes}
eventTypesLoading={eventTypesLoading}
searchQuery={searchQuery}
selectedEventTypeId={selectedEventTypeId}
onEventTypeChange={handleEventTypeSelect}
activeFilter={activeFilter}
filterParams={filterParams}
/>
);
}
@@ -1,10 +0,0 @@
import { Stack } from "expo-router";
export default function EventTypesLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{}} />
<Stack.Screen name="event-type-detail" options={{}} />
</Stack>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,604 +0,0 @@
import * as Haptics from "expo-haptics";
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useMemo, useState } from "react";
import {
Alert,
Pressable,
RefreshControl,
ScrollView,
Share,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { EmptyScreen } from "@/components/EmptyScreen";
import { EventTypeListItem } from "@/components/event-type-list-item/EventTypeListItem";
import { EventTypeListSkeleton } from "@/components/event-type-list-item/EventTypeListItemSkeleton";
import {
useCreateEventType,
useDeleteEventType,
useDuplicateEventType,
useEventTypes,
useUserProfile,
} from "@/hooks";
import { useEventTypeFilter } from "@/hooks/useEventTypeFilter";
import type { EventType } from "@/services/calcom";
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { getEventDuration } from "@/utils/getEventDuration";
import { offlineAwareRefresh } from "@/utils/network";
import { slugify } from "@/utils/slugify";
import { getColors } from "@/constants/colors";
export default function EventTypesIOS() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const { data: userProfile } = useUserProfile();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
// No modal state needed for iOS - using native Alert.prompt
// Use React Query hooks
const {
data: eventTypes = [],
isLoading: loading,
isFetching,
error: queryError,
refetch,
} = useEventTypes();
// Show refresh indicator when fetching
const refreshing = isFetching && !loading;
const { mutate: createEventTypeMutation } = useCreateEventType();
const { mutate: deleteEventTypeMutation } = useDeleteEventType();
const { mutate: duplicateEventTypeMutation } = useDuplicateEventType();
// Convert query error to string
// Don't show error UI for authentication errors (user will be redirected to login)
// Only show error UI in development mode for other errors
const isAuthError =
queryError?.message?.includes("Authentication") ||
queryError?.message?.includes("sign in") ||
queryError?.message?.includes("401");
const error = queryError && !isAuthError && __DEV__ ? "Failed to load event types." : null;
// No modal state needed for iOS - using native Alert for delete confirmation
// Handle pull-to-refresh (offline-aware)
const onRefresh = () => offlineAwareRefresh(refetch);
// Event type filter and sort hook
const {
sortBy,
filters,
setSortBy,
toggleFilter,
resetFilters,
filteredAndSortedEventTypes,
activeFilterCount,
} = useEventTypeFilter();
// Filter event types based on search query and filter/sort options
const filteredEventTypes = useMemo(() => {
// First apply filter/sort from the hook
let filtered = filteredAndSortedEventTypes(eventTypes);
// Then apply search query filter
if (searchQuery.trim() !== "") {
const searchLower = searchQuery.toLowerCase();
filtered = filtered.filter(
(eventType) =>
eventType.title.toLowerCase().includes(searchLower) ||
eventType.description?.toLowerCase().includes(searchLower)
);
}
return filtered;
}, [eventTypes, searchQuery, filteredAndSortedEventTypes]);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleEventTypePress = (eventType: EventType) => {
handleEdit(eventType);
};
const handleCopyLink = async (eventType: EventType) => {
if (!eventType.bookingUrl) {
showErrorAlert("Error", "Booking URL not available for this event type.");
return;
}
try {
await Clipboard.setStringAsync(eventType.bookingUrl);
showSuccessAlert("Link Copied", "Event type link copied!");
} catch {
showErrorAlert("Error", "Failed to copy link. Please try again.");
}
};
const _handleShare = async (eventType: EventType) => {
if (!eventType.bookingUrl) {
showErrorAlert("Error", "Booking URL not available for this event type.");
return;
}
try {
await Share.share({
message: `Book a meeting: ${eventType.title}`,
url: eventType.bookingUrl,
});
} catch {
showErrorAlert("Error", "Failed to share link. Please try again.");
}
};
const handleEdit = (eventType: EventType) => {
const duration = getEventDuration(eventType);
router.push({
pathname: "/event-type-detail",
params: {
id: eventType.id.toString(),
title: eventType.title,
description: eventType.description || "",
duration: duration.toString(),
price: eventType.price?.toString() || "",
currency: eventType.currency || "",
slug: eventType.slug || "",
},
});
};
const handleDelete = (eventType: EventType) => {
// Use native iOS Alert for confirmation
Alert.alert(
"Delete Event Type",
`Are you sure you want to delete "${eventType.title}"? This action cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteEventTypeMutation(eventType.id, {
onSuccess: () => {
showSuccessAlert("Success", "Event type deleted successfully");
},
onError: (deleteError) => {
const message =
deleteError instanceof Error ? deleteError.message : String(deleteError);
console.error("Failed to delete event type", message);
if (__DEV__) {
const stack = deleteError instanceof Error ? deleteError.stack : undefined;
console.debug("[EventTypes] deleteEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to delete event type. Please try again.");
},
});
},
},
]
);
};
const handleDuplicate = (eventType: EventType) => {
duplicateEventTypeMutation(
{ eventType, existingEventTypes: eventTypes },
{
onSuccess: (duplicatedEventType) => {
showSuccessAlert("Success", "Event type duplicated successfully");
const duration = getEventDuration(eventType);
// Navigate to edit the newly created duplicate
router.push({
pathname: "/event-type-detail",
params: {
id: duplicatedEventType.id.toString(),
title: duplicatedEventType.title,
description: duplicatedEventType.description || "",
duration: (
duplicatedEventType.lengthInMinutes ||
duplicatedEventType.length ||
duration
).toString(),
slug: duplicatedEventType.slug || "",
},
});
},
onError: (duplicateError) => {
const message =
duplicateError instanceof Error ? duplicateError.message : String(duplicateError);
console.error("Failed to duplicate event type", message);
if (__DEV__) {
const stack = duplicateError instanceof Error ? duplicateError.stack : undefined;
console.debug("[EventTypes] duplicateEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
},
}
);
};
const handlePreview = async (eventType: EventType) => {
if (!eventType.bookingUrl) {
showErrorAlert("Error", "Booking URL not available for this event type.");
return;
}
try {
await openInAppBrowser(eventType.bookingUrl, "event type preview");
} catch {
console.error("Failed to open preview");
showErrorAlert("Error", "Failed to open preview. Please try again.");
}
};
const handleOpenCreateModal = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Use native iOS Alert.prompt for a native look
Alert.prompt(
"Add a new event type",
"Set up event types to offer different types of meetings.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Continue",
onPress: (title?: string) => {
if (!title?.trim()) {
showErrorAlert("Error", "Please enter a title for your event type");
return;
}
const autoSlug = slugify(title.trim());
if (!autoSlug) {
showErrorAlert("Error", "Title must contain at least one letter or number");
return;
}
createEventTypeMutation(
{
title: title.trim(),
slug: autoSlug,
lengthInMinutes: 15, // Default duration
description: undefined, // Empty description
},
{
onSuccess: (newEventType) => {
// Navigate to edit the newly created event type
router.push({
pathname: "/event-type-detail",
params: {
id: newEventType.id.toString(),
title: newEventType.title,
description: newEventType.description || "",
duration: (
newEventType.lengthInMinutes ||
newEventType.length ||
15
).toString(),
slug: newEventType.slug || "",
},
});
},
onError: (createError) => {
const message =
createError instanceof Error ? createError.message : String(createError);
console.error("Failed to create event type", message);
if (__DEV__) {
const stack = createError instanceof Error ? createError.stack : undefined;
console.debug("[EventTypes] createEventType failed", {
message,
stack,
});
}
showErrorAlert("Error", "Failed to create event type. Please try again.");
},
}
);
},
},
],
"plain-text",
"",
"default"
);
};
// Sort by menu handler
const handleSortByOption = (option: "alphabetical" | "newest" | "duration") => {
setSortBy(option);
};
// Filter menu handler - toggle filters
const handleFilterToggle = (
filterKey:
| "hiddenOnly"
| "paidOnly"
| "seatedOnly"
| "requiresConfirmationOnly"
| "recurringOnly"
) => {
toggleFilter(filterKey);
};
if (loading) {
return (
<>
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : isDark ? "dark" : "light"}
>
<Stack.Header.Title large>Event Types</Stack.Header.Title>
</Stack.Header>
<ScrollView
style={{ backgroundColor: theme.background }}
contentContainerStyle={{ paddingBottom: 120, paddingTop: 16 }}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
<EventTypeListSkeleton />
</ScrollView>
</>
);
}
if (error) {
return (
<View
className="flex-1 items-center justify-center bg-gray-50 p-5"
style={{ backgroundColor: theme.backgroundSecondary }}
>
<Ionicons name="alert-circle" size={64} color="#FF3B30" />
<Text
className="mb-2 mt-4 text-center text-xl font-bold text-gray-800"
style={{ color: theme.text }}
>
Unable to load event types
</Text>
<Text
className="mb-6 text-center text-base text-gray-500"
style={{ color: theme.textMuted }}
>
{error}
</Text>
<TouchableOpacity
className="rounded-lg bg-black px-6 py-3"
style={{ backgroundColor: isDark ? "white" : "black" }}
onPress={() => refetch()}
>
<Text
className="text-base font-semibold text-white"
style={{ color: isDark ? "black" : "white" }}
>
Retry
</Text>
</TouchableOpacity>
</View>
);
}
if (eventTypes.length === 0) {
return (
<>
<Stack.Screen
options={{
title: "Event Types",
headerBlurEffect: isLiquidGlassAvailable() ? undefined : isDark ? "dark" : "light",
headerStyle: { backgroundColor: "transparent" },
headerLargeTitleEnabled: true,
}}
/>
<View
className="flex-1 items-center justify-center bg-gray-50 p-5"
style={{ backgroundColor: theme.backgroundSecondary }}
>
<EmptyScreen
icon="link-outline"
headline="Create your first event type"
description="Event types enable you to share links that show available times on your calendar and allow people to make bookings with you."
buttonText="New"
onButtonPress={handleOpenCreateModal}
/>
</View>
</>
);
}
return (
<>
{/* iOS Native Header with Glass UI */}
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : isDark ? "dark" : "light"}
>
<Stack.Header.Title large>Event Types</Stack.Header.Title>
<Stack.Header.Right>
{/* Filter/Sort Menu */}
<Stack.Header.Menu>
<Stack.Header.Icon sf="line.3.horizontal.decrease" />
{/* Sort by Submenu - opens as separate submenu */}
<Stack.Header.Menu title="Sort by">
<Stack.Header.MenuAction
icon={sortBy === "alphabetical" ? "checkmark.circle.fill" : "textformat.abc"}
onPress={() => handleSortByOption("alphabetical")}
>
Alphabetical
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={sortBy === "newest" ? "checkmark.circle.fill" : "calendar.badge.clock"}
onPress={() => handleSortByOption("newest")}
>
Newest First
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={sortBy === "duration" ? "checkmark.circle.fill" : "clock"}
onPress={() => handleSortByOption("duration")}
>
By Duration
</Stack.Header.MenuAction>
</Stack.Header.Menu>
{/* Filter Submenu - multi-select toggles */}
<Stack.Header.Menu
title={`Filter${activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}`}
>
<Stack.Header.MenuAction
icon={filters.hiddenOnly ? "checkmark.circle.fill" : "eye.slash"}
onPress={() => handleFilterToggle("hiddenOnly")}
>
Hidden Only
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={filters.paidOnly ? "checkmark.circle.fill" : "dollarsign.circle"}
onPress={() => handleFilterToggle("paidOnly")}
>
Paid Events
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={filters.seatedOnly ? "checkmark.circle.fill" : "person.2"}
onPress={() => handleFilterToggle("seatedOnly")}
>
Seated Events
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={
filters.requiresConfirmationOnly ? "checkmark.circle.fill" : "checkmark.shield"
}
onPress={() => handleFilterToggle("requiresConfirmationOnly")}
>
Requires Confirmation
</Stack.Header.MenuAction>
<Stack.Header.MenuAction
icon={filters.recurringOnly ? "checkmark.circle.fill" : "repeat"}
onPress={() => handleFilterToggle("recurringOnly")}
>
Recurring
</Stack.Header.MenuAction>
{activeFilterCount > 0 && (
<Stack.Header.MenuAction icon="xmark.circle" onPress={resetFilters}>
Clear All Filters
</Stack.Header.MenuAction>
)}
</Stack.Header.Menu>
</Stack.Header.Menu>
{/* Profile Button - Opens bottom sheet */}
{userProfile?.avatarUrl ? (
<Stack.Header.View>
<Pressable onPress={() => router.push("/profile-sheet")}>
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
</Pressable>
</Stack.Header.View>
) : (
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
)}
</Stack.Header.Right>
{/* Search Bar */}
<Stack.Header.SearchBar
placeholder="Search event types"
onChangeText={(e) => handleSearch(e.nativeEvent.text)}
obscureBackground={false}
barTintColor={isDark ? "#171717" : "#fff"}
/>
</Stack.Header>
{/* Event Types List */}
<ScrollView
style={{ backgroundColor: theme.background }}
contentContainerStyle={{ paddingBottom: 120 }}
refreshControl={<RefreshControl refreshing={false} onRefresh={onRefresh} />}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
{refreshing ? (
<EventTypeListSkeleton />
) : filteredEventTypes.length === 0 && searchQuery.trim() !== "" ? (
<View
className="flex-1 items-center justify-center bg-gray-50 p-5 pt-20"
style={{ backgroundColor: theme.backgroundSecondary }}
>
<EmptyScreen
icon="search-outline"
headline={`No results found for "${searchQuery}"`}
description="Try searching with different keywords"
/>
</View>
) : filteredEventTypes.length === 0 && activeFilterCount > 0 ? (
<View
className="flex-1 items-center justify-center bg-white p-5 pt-20"
style={{ backgroundColor: theme.background }}
>
<EmptyScreen
icon="filter-outline"
headline="No event types match your filters"
description="Try adjusting your filter criteria or clear all filters to see all event types"
buttonText="Clear Filters"
onButtonPress={resetFilters}
className="border-0"
/>
</View>
) : (
<View className="px-2 pt-4 md:px-4">
<View
className="overflow-hidden rounded-lg"
style={{
backgroundColor: theme.backgroundSecondary,
borderWidth: 1,
borderColor: theme.border,
}}
>
{filteredEventTypes.map((item, index) => (
<EventTypeListItem
key={item.id.toString()}
item={item}
index={index}
filteredEventTypes={filteredEventTypes}
handleEventTypePress={handleEventTypePress}
handleCopyLink={handleCopyLink}
handlePreview={handlePreview}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
))}
</View>
</View>
)}
</ScrollView>
{/* Floating Action Button for New Event Type with Glass UI Menu */}
<View className="absolute right-6" style={{ bottom: 100 }}>
<TouchableOpacity
onPress={handleOpenCreateModal}
className="h-14 w-14 items-center justify-center rounded-full bg-black shadow-lg shadow-black/20 dark:bg-white"
activeOpacity={0.8}
>
<Ionicons name="add" size={30} color={isDark ? "#000000" : "#FFFFFF"} />
</TouchableOpacity>
</View>
</>
);
}
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
import { Stack } from "expo-router";
import { Platform } from "react-native";
export default function MoreLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
// Hide native header on Android/Web since we use custom Header component
// iOS uses Stack.Header in the component itself
headerShown: Platform.OS === "ios",
}}
/>
</Stack>
);
}
-362
View File
@@ -1,362 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Image } from "expo-image";
import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import {
Alert,
Pressable,
ScrollView,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { LandingPagePicker } from "@/components/LandingPagePicker";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { useQueryContext } from "@/contexts/QueryContext";
import { useUserProfile } from "@/hooks";
import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences";
import { showErrorAlert, showSuccessAlert, showNotAvailableAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { getColors } from "@/constants/colors";
interface MoreMenuItem {
name: string;
icon: keyof typeof Ionicons.glyphMap;
isExternal?: boolean;
href?: string;
onPress?: () => void;
}
export default function More() {
const router = useRouter();
const { logout } = useAuth();
const { clearCache } = useQueryContext();
const { preferences, setLandingPage, landingPageLabel } = useUserPreferences();
const [showLogoutModal, setShowLogoutModal] = useState(false);
const [showLandingPagePicker, setShowLandingPagePicker] = useState(false);
const { data: userProfile } = useUserProfile();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const colors = {
background: isDark ? "#000000" : "#FFFFFF",
backgroundSecondary: isDark ? "#171717" : "#FFFFFF",
backgroundActive: isDark ? "#2C2C2E" : "#F8F9FA",
text: isDark ? "#FFFFFF" : "#333333",
textSecondary: isDark ? "#A3A3A3" : "#C7C7CC",
border: isDark ? "#4D4D4D" : "#E5E5EA",
};
const handleLandingPageSelect = async (value: LandingPage) => {
try {
await setLandingPage(value);
showSuccessAlert("Saved", "First page updated");
} catch {
showErrorAlert("Error", "Failed to save preference. Please try again.");
}
};
const performLogout = async () => {
try {
// Clear in-memory cache before logout
await clearCache();
await logout();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("Logout error", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[More] logout failed", { message, stack });
}
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
};
const handleSignOut = () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: performLogout,
},
]);
};
// handleNotAvailable replaced by global showNotAvailableAlert
const menuItems: MoreMenuItem[] = [
{
name: "Apps",
icon: "grid-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Routing",
icon: "git-branch-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Workflows",
icon: "flash-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Insights",
icon: "bar-chart-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
{
name: "Support",
icon: "help-circle-outline",
isExternal: false,
onPress: () => showNotAvailableAlert(),
},
];
return (
<>
{/* iOS Native Header with Glass UI */}
<Stack.Header
style={{ backgroundColor: "transparent", shadowColor: "transparent" }}
blurEffect={isLiquidGlassAvailable() ? undefined : isDark ? "dark" : "light"}
>
<Stack.Header.Title large>More</Stack.Header.Title>
<Stack.Header.Right>
{userProfile?.avatarUrl ? (
<Stack.Header.View>
<Pressable onPress={() => router.push("/profile-sheet")}>
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 32, height: 32, borderRadius: 16 }}
/>
</Pressable>
</Stack.Header.View>
) : (
<Stack.Header.Button onPress={() => router.push("/profile-sheet")}>
<Stack.Header.Icon sf="person.circle.fill" />
</Stack.Header.Button>
)}
</Stack.Header.Right>
</Stack.Header>
{/* Content */}
<ScrollView
style={{ backgroundColor: colors.background }}
contentContainerStyle={{ padding: 16, paddingBottom: 120 }}
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior="automatic"
>
<View
style={{
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.name}
onPress={item.onPress}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 20,
borderBottomWidth: index < menuItems.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
}}
activeOpacity={0.7}
>
<View className="flex-1 flex-row items-center">
<Ionicons name={item.icon} size={20} color={colors.text} />
<Text style={{ color: colors.text }} className="ml-3 text-base font-semibold">
{item.name}
</Text>
</View>
{item.isExternal ? (
<Ionicons name="open-outline" size={20} color={colors.textSecondary} />
) : (
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
)}
</TouchableOpacity>
))}
</View>
{/* App Settings */}
<View
style={{
marginTop: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
<View
style={{
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: isDark ? "#2C2C2E" : "#F9FAFB",
paddingHorizontal: 16,
paddingVertical: 8,
}}
>
<Text
style={{ color: isDark ? "#A3A3A3" : "#6B7280" }}
className="text-xs font-semibold uppercase"
>
App Settings
</Text>
</View>
<TouchableOpacity
onPress={() => setShowLandingPagePicker(true)}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 16,
}}
activeOpacity={0.7}
>
<View className="flex-row items-center">
<Ionicons name="home-outline" size={20} color={colors.text} />
<Text style={{ color: colors.text }} className="ml-3 text-base font-semibold">
First Page
</Text>
</View>
<View className="flex-row items-center">
<View
style={{
marginLeft: 12,
marginRight: 8,
borderRadius: 6,
backgroundColor: isDark ? "#3A3A3C" : "#F3F4F6",
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{ color: isDark ? "#A3A3A3" : "#4B5563" }}
className="text-sm font-medium"
>
{landingPageLabel}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
</View>
{/* Delete Account Link */}
<View
style={{
marginTop: 24,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
<TouchableOpacity
onPress={() =>
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account")
}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 16,
}}
activeOpacity={0.7}
>
<View className="flex-1 flex-row items-center">
<Ionicons name="trash-outline" size={20} color={theme.destructive} />
<Text className="ml-3 text-base font-medium" style={{ color: theme.destructive }}>
Delete Account
</Text>
</View>
<Ionicons name="open-outline" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<View
style={{
marginTop: 16,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundSecondary,
overflow: "hidden",
}}
>
<TouchableOpacity
onPress={handleSignOut}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.backgroundSecondary,
paddingHorizontal: 20,
paddingVertical: 16,
}}
activeOpacity={0.7}
>
<Ionicons name="log-out-outline" size={20} color={theme.destructive} />
<Text className="ml-2 text-base font-medium" style={{ color: theme.destructive }}>
Sign Out
</Text>
</TouchableOpacity>
</View>
{/* Footer Note */}
<Text
style={{ color: isDark ? "#6B7280" : "#9CA3AF" }}
className="mt-6 px-1 text-center text-xs"
>
The companion app is an extension of the web application.{"\n"}
For advanced features, visit{" "}
<Text style={{ color: isDark ? "#D1D5DB" : "#1F2937" }}>app.cal.com</Text>
</Text>
</ScrollView>
{/* Logout Confirmation Modal - kept for consistency */}
<LogoutConfirmModal
visible={showLogoutModal}
onConfirm={() => {
setShowLogoutModal(false);
performLogout();
}}
onCancel={() => setShowLogoutModal(false)}
/>
{/* Landing Page Picker */}
<LandingPagePicker
visible={showLandingPagePicker}
currentValue={preferences.landingPage}
onSelect={handleLandingPageSelect}
onClose={() => setShowLandingPagePicker(false)}
/>
</>
);
}
-269
View File
@@ -1,269 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Alert,
Platform,
ScrollView,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { Header } from "@/components/Header";
import { LandingPagePicker } from "@/components/LandingPagePicker";
import { LogoutConfirmModal } from "@/components/LogoutConfirmModal";
import { useAuth } from "@/contexts/AuthContext";
import { useQueryContext } from "@/contexts/QueryContext";
import { type LandingPage, useUserPreferences } from "@/hooks/useUserPreferences";
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getColors } from "@/constants/colors";
interface MoreMenuItem {
name: string;
icon: keyof typeof Ionicons.glyphMap;
isExternal?: boolean;
href?: string;
onPress?: () => void;
}
export default function More() {
const { logout } = useAuth();
const { clearCache } = useQueryContext();
const { preferences, setLandingPage, landingPageLabel } = useUserPreferences();
const [showLogoutModal, setShowLogoutModal] = useState(false);
const [showLandingPagePicker, setShowLandingPagePicker] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const handleLandingPageSelect = async (value: LandingPage) => {
try {
await setLandingPage(value);
showSuccessAlert("Saved", "First page updated");
} catch {
showErrorAlert("Error", "Failed to save preference. Please try again.");
}
};
const performLogout = async () => {
try {
// Clear in-memory cache before logout
await clearCache();
await logout();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("Logout error", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[More] logout failed", { message, stack });
}
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
};
const handleSignOut = () => {
if (Platform.OS === "web") {
// Use modal for web/extension since Alert.alert doesn't work
setShowLogoutModal(true);
} else {
// Use native Alert for Android
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: performLogout,
},
]);
}
};
const menuItems: MoreMenuItem[] = [
{
name: "Apps",
icon: "grid-outline",
isExternal: true,
onPress: () => openInAppBrowser("https://app.cal.com/apps", "Apps page"),
},
{
name: "Routing",
icon: "git-branch-outline",
isExternal: true,
onPress: () => openInAppBrowser("https://app.cal.com/routing", "Routing page"),
},
{
name: "Workflows",
icon: "flash-outline",
isExternal: true,
onPress: () => openInAppBrowser("https://app.cal.com/workflows", "Workflows page"),
},
{
name: "Insights",
icon: "bar-chart-outline",
isExternal: true,
onPress: () => openInAppBrowser("https://app.cal.com/insights", "Insights page"),
},
{
name: "Support",
icon: "help-circle-outline",
isExternal: true,
onPress: () => openInAppBrowser("https://go.cal.com/support", "Support"),
},
];
return (
<View className="flex-1" style={{ backgroundColor: theme.background }}>
<Header />
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 90 }}
showsVerticalScrollIndicator={false}
>
<View
className="overflow-hidden rounded-lg border border-[#E5E5EA] bg-white"
style={{ borderColor: theme.border, backgroundColor: theme.backgroundSecondary }}
>
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.name}
onPress={item.onPress}
className={`flex-row items-center justify-between bg-white px-5 py-5 active:bg-[#F8F9FA]`}
style={{
backgroundColor: theme.backgroundSecondary,
borderBottomWidth: index < menuItems.length - 1 ? 1 : 0,
borderBottomColor: theme.border,
}}
>
<View className="flex-1 flex-row items-center">
<Ionicons name={item.icon} size={20} color={theme.text} />
<Text className="ml-3 text-base font-semibold" style={{ color: theme.text }}>
{item.name}
</Text>
</View>
{item.isExternal ? (
<Ionicons name="open-outline" size={20} color={theme.textSecondary} />
) : (
<Ionicons name="chevron-forward" size={20} color={theme.textSecondary} />
)}
</TouchableOpacity>
))}
</View>
{/* App Settings */}
<View
className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white"
style={{ borderColor: theme.border, backgroundColor: theme.backgroundSecondary }}
>
<View
className="border-b border-[#E5E5EA] bg-gray-50 px-4 py-2"
style={{ borderBottomColor: theme.border, backgroundColor: isDark ? "#2C2C2E" : undefined }}
>
<Text
className="text-xs font-semibold uppercase text-gray-500"
style={{ color: theme.textSecondary }}
>
App Settings
</Text>
</View>
<TouchableOpacity
onPress={() => setShowLandingPagePicker(true)}
className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-[#F8F9FA]"
style={{ backgroundColor: theme.backgroundSecondary }}
>
<View className="flex-row items-center">
<Ionicons name="home-outline" size={20} color={theme.text} />
<Text className="ml-3 text-base font-semibold" style={{ color: theme.text }}>
First Page
</Text>
</View>
<View className="flex-row items-center">
<View
className="ml-3 rounded-md bg-gray-100 px-2.5 py-1"
style={{ backgroundColor: isDark ? "#3A3A3C" : undefined }}
>
<Text
className="text-sm font-medium text-gray-600"
style={{ color: theme.textSecondary }}
>
{landingPageLabel}
</Text>
</View>
</View>
</TouchableOpacity>
</View>
{/* Delete Account Link */}
<View
className="mt-6 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white"
style={{ borderColor: theme.border, backgroundColor: theme.backgroundSecondary }}
>
<TouchableOpacity
onPress={() =>
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Delete Account")
}
className="flex-row items-center justify-between bg-white px-5 py-4 active:bg-red-50"
style={{ backgroundColor: theme.backgroundSecondary }}
>
<View className="flex-1 flex-row items-center">
<Ionicons name="trash-outline" size={20} color={theme.destructive} />
<Text className="ml-3 text-base font-medium" style={{ color: theme.destructive }}>
Delete Account
</Text>
</View>
<Ionicons name="open-outline" size={20} color={theme.textSecondary} />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<View
className="mt-4 overflow-hidden rounded-lg border border-[#E5E5EA] bg-white"
style={{ borderColor: theme.border, backgroundColor: theme.backgroundSecondary }}
>
<TouchableOpacity
onPress={handleSignOut}
className="flex-row items-center justify-center bg-white px-5 py-4 active:bg-red-50"
style={{ backgroundColor: theme.backgroundSecondary }}
>
<Ionicons name="log-out-outline" size={20} color={theme.destructive} />
<Text className="ml-2 text-base font-medium" style={{ color: theme.destructive }}>
Sign Out
</Text>
</TouchableOpacity>
</View>
{/* Footer Note */}
<Text
className="mt-6 px-1 text-center text-xs text-gray-400"
style={{ color: theme.textMuted }}
>
The companion app is an extension of the web application.{"\n"}
For advanced features, visit{" "}
<Text
style={{ color: theme.text }}
onPress={() => openInAppBrowser("https://app.cal.com", "Cal.com")}
>
app.cal.com
</Text>
</Text>
</ScrollView>
{/* Logout Confirmation Modal for Web */}
<LogoutConfirmModal
visible={showLogoutModal}
onConfirm={() => {
setShowLogoutModal(false);
performLogout();
}}
onCancel={() => setShowLogoutModal(false)}
/>
{/* Landing Page Picker Modal */}
<LandingPagePicker
visible={showLandingPagePicker}
currentValue={preferences.landingPage}
onSelect={handleLandingPageSelect}
onClose={() => setShowLandingPagePicker(false)}
/>
</View>
);
}
-198
View File
@@ -1,198 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { ColorValue, ImageSourcePropType } from "react-native";
import { Tabs, VectorIcon, useRouter } from "expo-router";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useColorScheme } from "react-native";
import { Platform } from "react-native";
import { useEffect, useRef } from "react";
import { getRouteFromPreference, useUserPreferences } from "@/hooks/useUserPreferences";
// Type for vector icon families that support getImageSource
type VectorIconFamily = {
getImageSource: (name: string, size: number, color: ColorValue) => Promise<ImageSourcePropType>;
};
export default function TabLayout() {
const router = useRouter();
const { preferences, isLoading } = useUserPreferences();
const hasNavigated = useRef(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const colors = {
selected: isDark ? "#FFFFFF" : "#000000",
inactive: "#A3A3A3",
background: isDark ? "#000000" : "#FFFFFF",
border: isDark ? "#4D4D4D" : "#C6C6C8",
indicator: isDark ? "#FFFFFF15" : "#00000015",
};
// Navigate to preferred landing page on first load
// Since Bookings is the default first tab, we only navigate if preference is different
useEffect(() => {
if (!isLoading && !hasNavigated.current) {
hasNavigated.current = true;
// Only navigate if preference is not the default (bookings)
if (preferences.landingPage !== "bookings") {
const route = getRouteFromPreference(preferences.landingPage);
console.log("[TabLayout] Navigating to preferred landing page:", route);
// Use a minimal delay to ensure NativeTabs is initialized
setTimeout(() => {
// biome-ignore lint/suspicious/noExplicitAny: expo-router types don't support dynamic route strings from preferences
router.navigate(route as any);
}, 50);
}
}
}, [isLoading, preferences.landingPage, router]);
if (Platform.OS === "web") {
return <WebTabs colors={colors} />;
}
return (
<NativeTabs
tintColor={colors.selected}
iconColor={Platform.select({ android: colors.inactive, ios: undefined })}
indicatorColor={Platform.select({ android: colors.indicator, ios: undefined })}
backgroundColor={Platform.select({ android: colors.background, ios: undefined })}
labelStyle={{
default: { color: colors.inactive, fontSize: Platform.select({ android: 11, ios: 8.5 }) },
selected: { color: colors.selected, fontSize: Platform.select({ android: 12, ios: 10 }) },
}}
disableTransparentOnScrollEdge={true}
>
<NativeTabs.Trigger name="(bookings)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf="calendar" />,
android: (
<NativeTabs.Trigger.Icon
src={<VectorIcon family={Ionicons as VectorIconFamily} name="calendar-outline" />}
selectedColor={colors.selected}
/>
),
})}
<NativeTabs.Trigger.Label>Bookings</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(event-types)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf="link" />,
android: (
<NativeTabs.Trigger.Icon
src={<VectorIcon family={Ionicons as VectorIconFamily} name="link-outline" />}
selectedColor={colors.selected}
/>
),
})}
<NativeTabs.Trigger.Label>Event Types</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(availability)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf={{ default: "clock", selected: "clock.fill" }} />,
android: (
<NativeTabs.Trigger.Icon
src={<VectorIcon family={Ionicons as VectorIconFamily} name="time-outline" />}
selectedColor={colors.selected}
/>
),
})}
<NativeTabs.Trigger.Label>Availability</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(more)">
{Platform.select({
ios: <NativeTabs.Trigger.Icon sf={{ default: "ellipsis", selected: "ellipsis" }} />,
android: (
<NativeTabs.Trigger.Icon
src={<VectorIcon family={Ionicons as VectorIconFamily} name="ellipsis-horizontal" />}
selectedColor={colors.selected}
/>
),
})}
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
type TabColors = {
selected: string;
inactive: string;
background: string;
border: string;
indicator: string;
};
// TODO: Remove this once native tabs are supported on web
function WebTabs({ colors }: { colors: TabColors }) {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.selected,
tabBarInactiveTintColor: colors.inactive,
tabBarStyle: {
backgroundColor: colors.background,
borderTopWidth: 0.5,
borderTopColor: colors.border,
paddingBottom: 4,
paddingTop: 4,
height: Platform.OS === "android" ? 74 : 66,
},
}}
>
<Tabs.Screen
name="index"
options={{
href: null,
}}
/>
<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>
);
}
-21
View File
@@ -1,21 +0,0 @@
import { Redirect } from "expo-router";
import { ActivityIndicator, View } from "react-native";
import { getRouteFromPreference, useUserPreferences } from "@/hooks/useUserPreferences";
export default function TabsIndex() {
const { preferences, isLoading } = useUserPreferences();
if (isLoading) {
return (
<View className="flex-1 items-center justify-center bg-white">
<ActivityIndicator size="small" color="#111827" />
</View>
);
}
const route = getRouteFromPreference(preferences.landingPage);
console.log("[TabsIndex] Redirecting to:", route, "from preference:", preferences.landingPage);
// biome-ignore lint/suspicious/noExplicitAny: expo-router types don't support dynamic route strings from preferences
return <Redirect href={route as any} />;
}
-466
View File
@@ -1,466 +0,0 @@
import "./_webRuntimeGuards";
import "../global.css";
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native";
import { PortalHost } from "@rn-primitives/portal";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack } from "expo-router";
import { Platform, StatusBar, useColorScheme, View } from "react-native";
import { CalComLogo } from "@/components/CalComLogo";
import LoginScreenComponent from "@/components/LoginScreen";
import { NetworkStatusBanner } from "@/components/NetworkStatusBanner";
import { GlobalToast } from "@/components/ui/GlobalToast";
import { WidgetSyncProvider } from "@/components/WidgetSyncProvider";
import { getColors, colors as semanticColors } from "@/constants/colors";
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { QueryProvider } from "@/contexts/QueryContext";
import { ToastProvider } from "@/contexts/ToastContext";
function RootLayoutContent() {
const { isAuthenticated, loading } = useAuth();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const colors = getColors(isDark);
// Show Cal.com logo while checking auth state to prevent login flash
if (loading) {
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
}}
>
<StatusBar
barStyle={isDark ? "light-content" : "dark-content"}
backgroundColor={colors.background}
/>
<CalComLogo
width={120}
height={26}
color={isDark ? semanticColors.dark.text : semanticColors.light.text}
/>
</View>
);
}
const content = isAuthenticated ? (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="profile-sheet"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: Platform.OS === "ios" ? "You" : "Profile",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
// iOS-specific sheet options (ignored on Android)
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.9] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : colors.background,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.background,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="meeting-session-details"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Session Details",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
// iOS-specific sheet options (ignored on Android)
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 0.9] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="edit-location"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Edit Location",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.5, 0.7] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="mark-no-show"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Mark No-Show",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.4, 0.6] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="view-recordings"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Recordings",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 0.9] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="add-guests"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Add Guests",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.8] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="reschedule"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Reschedule",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.8, 0.95] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="edit-availability-name"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Edit Name & Timezone",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.5, 0.7] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="edit-availability-hours"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Working Hours",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 1] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="edit-availability-day"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Edit Day",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.9] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
<Stack.Screen
name="edit-availability-override"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Date Override",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 0.9] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable()
? "transparent"
: colors.backgroundSecondary,
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : colors.backgroundSecondary,
},
headerBlurEffect:
Platform.OS === "ios" && isLiquidGlassAvailable()
? undefined
: isDark
? "dark"
: "light",
}}
/>
</Stack>
) : (
<LoginScreenComponent />
);
const containerClass =
Platform.OS === "web"
? "w-[400px] flex-1 flex-col self-end border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-black"
: "bg-white dark:bg-black";
const containerStyle =
Platform.OS === "web"
? {
width: 400,
flex: 1,
display: "flex" as const,
flexDirection: "column" as const,
}
: { flex: 1 };
return (
<ThemeProvider value={isDark ? DarkTheme : DefaultTheme}>
<View
style={[containerStyle, Platform.OS === "web" && { pointerEvents: "auto" as const }]}
className={containerClass}
>
<StatusBar
barStyle={isDark ? "light-content" : "dark-content"}
backgroundColor={colors.background}
/>
{content}
<NetworkStatusBanner />
<PortalHost />
</View>
</ThemeProvider>
);
}
export default function RootLayout() {
return (
<QueryProvider>
<AuthProvider>
<ToastProvider>
<WidgetSyncProvider>
<RootLayoutContent />
</WidgetSyncProvider>
<GlobalToast />
</ToastProvider>
</AuthProvider>
</QueryProvider>
);
}
-69
View File
@@ -1,69 +0,0 @@
// Web-only runtime guards for Companion.
//
// This runs as early as possible on web to prevent a noisy (but harmless)
// react-native-css-interop error when darkMode is set to "media".
declare const window: unknown;
declare const require: undefined | ((id: string) => unknown);
if (typeof window !== "undefined") {
const w = window as {
addEventListener?: (type: string, listener: (event: unknown) => void) => void;
};
const shouldSuppressCssInteropError = (err: unknown) => {
if (!(err instanceof Error)) return false;
if (!err.message.includes("Cannot manually set color scheme")) return false;
// Some environments omit stack traces; if we have one, ensure it's the expected source.
if (typeof err.stack === "string") {
return err.stack.includes("react-native-css-interop/dist/runtime/web/color-scheme");
}
return true;
};
const suppressIfMatch = (event: unknown, err: unknown) => {
if (!event || typeof event !== "object") return;
const e = event as { preventDefault?: () => void };
if (shouldSuppressCssInteropError(err)) e.preventDefault?.();
};
w.addEventListener?.("error", (event) => {
const e = event as { error?: unknown };
suppressIfMatch(event, e.error);
});
w.addEventListener?.("unhandledrejection", (event) => {
const e = event as { reason?: unknown };
suppressIfMatch(event, e.reason);
});
const patchCssInterop = (mod: unknown) => {
const colorScheme = (mod as { colorScheme?: { set?: (value: unknown) => void } }).colorScheme;
if (!colorScheme?.set) return;
// Completely no-op the set call when darkMode is 'media'.
// Manual color scheme setting is not supported in this mode,
// so we silently ignore attempts to call it.
colorScheme.set = () => {
// No-op: system preference controls the color scheme
};
};
// Patch react-native-css-interop on web to ignore the known error.
// This can happen very early during init when darkMode is "media", so prefer sync require.
try {
if (typeof require === "function") {
patchCssInterop(require("react-native-css-interop/dist/runtime/web/color-scheme"));
} else {
void import("react-native-css-interop/dist/runtime/web/color-scheme")
.then(patchCssInterop)
.catch(() => {
// noop
});
}
} catch {
// noop
}
}
-144
View File
@@ -1,144 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen";
import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Semi-transparent background to prevent black flash while preserving glass effect
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function AddGuestsIOS() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [booking, setBooking] = useState<Booking | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [guestCount, setGuestCount] = useState(0);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const addGuestsScreenRef = useRef<AddGuestsScreenHandle>(null);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const handleSave = useCallback(() => {
addGuestsScreenRef.current?.submit();
}, []);
const handleAddGuestsSuccess = useCallback(() => {
router.back();
}, [router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
const showSaveButton = guestCount > 0;
return (
<>
<Stack.Screen
options={{
title: "Add Guests",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button
onPress={() => router.back()}
tintColor={theme.backgroundEmphasis}
variant="prominent"
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Add Guests</Stack.Header.Title>
<Stack.Header.Right>
{showSaveButton ? (
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor={theme.text}
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
) : null}
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={theme.text} />
</View>
) : (
<AddGuestsScreenComponent
ref={addGuestsScreenRef}
booking={booking}
onSuccess={handleAddGuestsSuccess}
onSavingChange={setIsSaving}
onGuestCountChange={setGuestCount}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-153
View File
@@ -1,153 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { AddGuestsScreenHandle } from "@/components/screens/AddGuestsScreen";
import AddGuestsScreenComponent from "@/components/screens/AddGuestsScreen";
import { getColors } from "@/constants/colors";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
export default function AddGuests() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const [booking, setBooking] = useState<Booking | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const addGuestsScreenRef = useRef<AddGuestsScreenHandle>(null);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const handleSave = useCallback(() => {
addGuestsScreenRef.current?.submit();
}, []);
const handleAddGuestsSuccess = useCallback(() => {
router.back();
}, [router]);
const renderHeaderLeft = useCallback(
() => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[router, theme.border, theme.background, theme.text]
);
const renderHeaderRight = useCallback(
() => (
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`h-10 w-10 items-center justify-center rounded-full border ${
isSaving ? "opacity-50" : ""
}`}
style={{
borderColor: theme.border,
backgroundColor: theme.background,
}}
>
<Ionicons name="checkmark" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[handleSave, isSaving, theme.text, theme.border, theme.background]
);
if (isLoading) {
return (
<>
<Stack.Screen
options={{
title: "Add Guests",
headerBackButtonDisplayMode: "minimal",
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Title>Add Guests</Stack.Header.Title>
</Stack.Header>
)}
<View
className="flex-1 items-center justify-center"
style={{ backgroundColor: theme.background }}
>
<ActivityIndicator size="large" color={theme.text} />
</View>
</>
);
}
return (
<>
<Stack.Screen
options={{
title: "Add Guests",
headerBackButtonDisplayMode: "minimal",
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Add Guests</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
)}
<AddGuestsScreenComponent
ref={addGuestsScreenRef}
booking={booking}
onSuccess={handleAddGuestsSuccess}
onSavingChange={setIsSaving}
/>
</>
);
}
-139
View File
@@ -1,139 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen.ios";
import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen.ios";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
// Semi-transparent background to prevent black flash while preserving glass effect
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function EditAvailabilityDayIOS() {
const { id, day } = useLocalSearchParams<{ id: string; day: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityDayScreenHandle>(null);
const dayIndex = day ? parseInt(day, 10) : 0;
const dayName = DAYS[dayIndex] || "Day";
useEffect(() => {
if (id) {
setIsLoading(true);
CalComAPIService.getScheduleById(Number(id))
.then(setSchedule)
.catch(() => {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Schedule ID is missing");
router.back();
}
}, [id, router]);
const handleSave = useCallback(() => {
screenRef.current?.submit();
}, []);
const handleSuccess = useCallback(() => {
router.back();
}, [router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
return (
<>
<Stack.Screen
options={{
title: dayName,
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.6, 0.9],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button
onPress={() => router.back()}
tintColor={theme.backgroundEmphasis}
variant="prominent"
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>{dayName}</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor={theme.text}
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityDayScreenComponent
ref={screenRef}
schedule={schedule}
dayIndex={dayIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-144
View File
@@ -1,144 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen";
import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen";
import { getColors } from "@/constants/colors";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export default function EditAvailabilityDay() {
const { id, day } = useLocalSearchParams<{ id: string; day: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const screenRef = useRef<EditAvailabilityDayScreenHandle>(null);
const dayIndex = day ? parseInt(day, 10) : 0;
const dayName = DAYS[dayIndex] || "Day";
useEffect(() => {
if (id) {
setIsLoading(true);
CalComAPIService.getScheduleById(Number(id))
.then(setSchedule)
.catch(() => {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Schedule ID is missing");
router.back();
}
}, [id, router]);
const handleSave = useCallback(() => {
if (isSaving) return;
screenRef.current?.submit();
}, [isSaving]);
const handleSuccess = useCallback(() => {
router.back();
}, [router]);
const handleClose = useCallback(() => {
router.back();
}, [router]);
if (isLoading) {
return (
<View
className="flex-1 items-center justify-center"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title: dayName,
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
}}
/>
<ActivityIndicator size="large" color={theme.text} />
</View>
);
}
return (
<View
className="flex-1"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title: dayName,
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
headerLeft: () => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={handleClose}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
headerRight: () => (
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`h-10 w-10 items-center justify-center rounded-full border ${
isSaving ? "opacity-50" : ""
}`}
style={{
borderColor: theme.border,
backgroundColor: theme.background,
}}
>
<Ionicons name="checkmark" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
}}
/>
<EditAvailabilityDayScreenComponent
ref={screenRef}
schedule={schedule}
dayIndex={dayIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
/>
</View>
);
}
@@ -1,110 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen.ios";
import { useScheduleById } from "@/hooks/useSchedules";
import { showErrorAlert } from "@/utils/alerts";
// Semi-transparent background to prevent black flash while preserving glass effect
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function EditAvailabilityHoursIOS() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
// Use React Query hook to read from cache (syncs with mutations)
const { data: schedule, isLoading, isError } = useScheduleById(id ? Number(id) : undefined);
// Handle missing ID or error
useEffect(() => {
if (!id) {
showErrorAlert("Error", "Schedule ID is missing");
router.back();
} else if (isError) {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
}
}, [id, isError, router]);
const handleDayPress = useCallback(
(dayIndex: number) => {
router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never);
},
[router, id]
);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
return (
<>
<Stack.Screen
options={{
title: "Working Hours",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button
onPress={() => router.back()}
tintColor={theme.backgroundEmphasis}
variant="prominent"
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Working Hours</Stack.Header.Title>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityHoursScreenComponent
schedule={schedule ?? null}
onDayPress={handleDayPress}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen";
import { getColors } from "@/constants/colors";
import { useScheduleById } from "@/hooks/useSchedules";
import { showErrorAlert } from "@/utils/alerts";
export default function EditAvailabilityHours() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
// Use React Query hook to read from cache (syncs with mutations)
const { data: schedule, isLoading, isError } = useScheduleById(id ? Number(id) : undefined);
// Handle missing ID or error
useEffect(() => {
if (!id) {
showErrorAlert("Error", "Schedule ID is missing");
router.back();
} else if (isError) {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
}
}, [id, isError, router]);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const handleDayPress = useCallback(
(dayIndex: number) => {
router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never);
},
[router, id]
);
if (isLoading) {
return (
<View
className="flex-1 items-center justify-center"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title: "Working Hours",
presentation: "modal",
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
}}
/>
<ActivityIndicator size="large" color={theme.text} />
</View>
);
}
return (
<View
className="flex-1"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title: "Working Hours",
presentation: "modal",
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
headerLeft: () => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={handleClose}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
}}
/>
<EditAvailabilityHoursScreenComponent
schedule={schedule ?? null}
onDayPress={handleDayPress}
/>
</View>
);
}
@@ -1,129 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen.ios";
import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen.ios";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Semi-transparent background to prevent black flash while preserving glass effect
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function EditAvailabilityNameIOS() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityNameScreenHandle>(null);
useEffect(() => {
if (id) {
setIsLoading(true);
CalComAPIService.getScheduleById(Number(id))
.then(setSchedule)
.catch(() => {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Schedule ID is missing");
router.back();
}
}, [id, router]);
const handleSave = useCallback(() => {
screenRef.current?.submit();
}, []);
const handleSuccess = useCallback(() => {
router.back();
}, [router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
return (
<>
<Stack.Screen
options={{
title: "Edit Name & Timezone",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? glassBackground : theme.backgroundSecondary,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button
onPress={() => router.back()}
tintColor={theme.backgroundEmphasis}
variant="prominent"
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Edit Name & Timezone</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor={theme.text}
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityNameScreenComponent
ref={screenRef}
schedule={schedule}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-140
View File
@@ -1,140 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen";
import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen";
import { getColors } from "@/constants/colors";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
export default function EditAvailabilityName() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const screenRef = useRef<EditAvailabilityNameScreenHandle>(null);
useEffect(() => {
if (id) {
setIsLoading(true);
CalComAPIService.getScheduleById(Number(id))
.then(setSchedule)
.catch(() => {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Schedule ID is missing");
router.back();
}
}, [id, router]);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const handleSave = useCallback(() => {
if (isSaving) return;
screenRef.current?.submit();
}, [isSaving]);
const handleSuccess = useCallback(() => {
router.back();
}, [router]);
if (isLoading) {
return (
<View
className="flex-1 items-center justify-center"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title: "Edit Name & Timezone",
presentation: "modal",
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
}}
/>
<ActivityIndicator size="large" color={theme.text} />
</View>
);
}
return (
<View
className="flex-1"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title: "Edit Name & Timezone",
presentation: "modal",
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
headerLeft: () => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={handleClose}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
headerRight: () => (
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`h-10 w-10 items-center justify-center rounded-full border ${
isSaving ? "opacity-50" : ""
}`}
style={{
borderColor: theme.border,
backgroundColor: theme.background,
}}
>
<Ionicons name="checkmark" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
}}
/>
<EditAvailabilityNameScreenComponent
ref={screenRef}
schedule={schedule}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
/>
</View>
);
}
@@ -1,150 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen.ios";
import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen.ios";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Semi-transparent background to prevent black flash while preserving glass effect
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function EditAvailabilityOverrideIOS() {
const { id, overrideIndex } = useLocalSearchParams<{
id: string;
overrideIndex?: string;
}>();
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityOverrideScreenHandle>(null);
const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined;
const isEditing = editingIndex !== undefined;
useEffect(() => {
if (id) {
setIsLoading(true);
CalComAPIService.getScheduleById(Number(id))
.then(setSchedule)
.catch(() => {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Schedule ID is missing");
router.back();
}
}, [id, router]);
const handleSave = useCallback(() => {
screenRef.current?.submit();
}, []);
const handleSuccess = useCallback(() => {
router.back();
}, [router]);
const handleEditOverride = useCallback(
(index: number) => {
// Push a new edit screen on top of the current one
// This allows user to go back to the override list after editing
router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never);
},
[id, router]
);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
return (
<>
<Stack.Screen
options={{
title: isEditing ? "Edit Override" : "Add Override",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button
onPress={() => router.back()}
tintColor={theme.backgroundEmphasis}
variant="prominent"
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>{isEditing ? "Edit Override" : "Add Override"}</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor={theme.text}
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityOverrideScreenComponent
ref={screenRef}
schedule={schedule}
overrideIndex={editingIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
onEditOverride={handleEditOverride}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
@@ -1,159 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen";
import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen";
import { getColors } from "@/constants/colors";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
export default function EditAvailabilityOverride() {
const { id, overrideIndex } = useLocalSearchParams<{
id: string;
overrideIndex?: string;
}>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const screenRef = useRef<EditAvailabilityOverrideScreenHandle>(null);
const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined;
const isEditing = editingIndex !== undefined;
useEffect(() => {
if (id) {
setIsLoading(true);
CalComAPIService.getScheduleById(Number(id))
.then(setSchedule)
.catch(() => {
showErrorAlert("Error", "Failed to load schedule details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Schedule ID is missing");
router.back();
}
}, [id, router]);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const handleSave = useCallback(() => {
if (isSaving) return;
screenRef.current?.submit();
}, [isSaving]);
const handleSuccess = useCallback(() => {
router.back();
}, [router]);
const handleEditOverride = useCallback(
(index: number) => {
// Push a new edit screen on top of the current one
// This allows user to go back to the override list after editing
router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never);
},
[id, router]
);
const title = isEditing ? "Edit Override" : "Add Override";
if (isLoading) {
return (
<View
className="flex-1 items-center justify-center"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title,
presentation: "modal",
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
}}
/>
<ActivityIndicator size="large" color={theme.text} />
</View>
);
}
return (
<View
className="flex-1"
style={{ paddingBottom: insets.bottom, backgroundColor: theme.background }}
>
<Stack.Screen
options={{
title,
presentation: "modal",
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
headerLeft: () => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={handleClose}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
headerRight: () => (
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`h-10 w-10 items-center justify-center rounded-full border ${
isSaving ? "opacity-50" : ""
}`}
style={{
borderColor: theme.border,
backgroundColor: theme.background,
}}
>
<Ionicons name="checkmark" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
}}
/>
<EditAvailabilityOverrideScreenComponent
ref={screenRef}
schedule={schedule}
overrideIndex={editingIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
onEditOverride={handleEditOverride}
/>
</View>
);
}
-130
View File
@@ -1,130 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen";
import EditLocationScreenComponent from "@/components/screens/EditLocationScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Semi-transparent background to prevent black flash while preserving glass effect
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function EditLocationIOS() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [booking, setBooking] = useState<Booking | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const editLocationScreenRef = useRef<EditLocationScreenHandle>(null);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const handleSave = useCallback(() => {
editLocationScreenRef.current?.submit();
}, []);
const handleUpdateSuccess = useCallback(() => {
router.back();
}, [router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
return (
<>
<Stack.Screen
options={{
title: "Edit Location",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? glassBackground : theme.backgroundSecondary,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button
onPress={() => router.back()}
tintColor={theme.backgroundEmphasis}
variant="prominent"
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Edit Location</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor={theme.text}
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
backgroundColor: useGlassEffect ? glassBackground : theme.backgroundSecondary,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={theme.text} />
</View>
) : (
<EditLocationScreenComponent
ref={editLocationScreenRef}
booking={booking}
onSuccess={handleUpdateSuccess}
onSavingChange={setIsSaving}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-162
View File
@@ -1,162 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { EditLocationScreenHandle } from "@/components/screens/EditLocationScreen";
import EditLocationScreenComponent from "@/components/screens/EditLocationScreen";
import { getColors } from "@/constants/colors";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
export default function EditLocation() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const [booking, setBooking] = useState<Booking | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const editLocationScreenRef = useRef<EditLocationScreenHandle>(null);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const handleSave = useCallback(() => {
editLocationScreenRef.current?.submit();
}, []);
const handleUpdateSuccess = useCallback(() => {
router.back();
}, [router]);
const renderHeaderLeft = useCallback(
() => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[router, theme.border, theme.background, theme.text]
);
const renderHeaderRight = useCallback(
() => (
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`h-10 w-10 items-center justify-center rounded-full border ${
isSaving ? "opacity-50" : ""
}`}
style={{
borderColor: theme.border,
backgroundColor: theme.background,
}}
>
<Ionicons name="checkmark" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[handleSave, isSaving, theme.text, theme.border, theme.background]
);
if (isLoading) {
return (
<>
<Stack.Screen
options={{
title: "Edit Location",
headerBackButtonDisplayMode: "minimal",
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Title>Edit Location</Stack.Header.Title>
</Stack.Header>
)}
<View
className="flex-1 items-center justify-center"
style={{ backgroundColor: theme.background }}
>
<ActivityIndicator size="large" color={theme.text} />
</View>
</>
);
}
return (
<>
<Stack.Screen
options={{
title: "Edit Location",
headerBackButtonDisplayMode: "minimal",
contentStyle: {
backgroundColor: theme.background,
},
headerStyle: {
backgroundColor: theme.background,
},
headerTitleStyle: {
color: theme.text,
},
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Edit Location</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
)}
<EditLocationScreenComponent
ref={editLocationScreenRef}
booking={booking}
onSuccess={handleUpdateSuccess}
onSavingChange={setIsSaving}
/>
</>
);
}
-142
View File
@@ -1,142 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
interface Attendee {
id?: number | string;
email: string;
name: string;
noShow?: boolean;
}
interface BookingAttendee {
id?: number | string;
email: string;
name?: string;
noShow?: boolean;
absent?: boolean;
}
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function MarkNoShowIOS() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [booking, setBooking] = useState<Booking | null>(null);
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [isLoading, setIsLoading] = useState(true);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then((bookingData) => {
setBooking(bookingData);
const bookingAttendees: Attendee[] = [];
if (bookingData.attendees && Array.isArray(bookingData.attendees)) {
bookingData.attendees.forEach((att: BookingAttendee) => {
bookingAttendees.push({
id: att.id,
email: att.email,
name: att.name || att.email,
noShow: att.absent === true || att.noShow === true,
});
});
}
setAttendees(bookingAttendees);
})
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
return (
<>
<Stack.Screen
options={{
title: "Mark No-Show",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? "transparent" : theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
</Stack.Header>
<View
style={{
flex: 1,
backgroundColor: useGlassEffect ? "transparent" : theme.background,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={theme.text} />
</View>
) : (
<MarkNoShowScreenComponent
booking={booking}
attendees={attendees}
onUpdate={setAttendees}
onBookingUpdate={(updatedBooking) => {
setBooking(updatedBooking);
const updatedAttendees: Attendee[] = [];
if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
updatedBooking.attendees.forEach((att: BookingAttendee) => {
updatedAttendees.push({
id: att.id,
email: att.email,
name: att.name || att.email,
noShow: att.absent === true || att.noShow === true,
});
});
}
setAttendees(updatedAttendees);
}}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-158
View File
@@ -1,158 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { getColors } from "@/constants/colors";
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
interface Attendee {
id?: number | string;
email: string;
name: string;
noShow?: boolean;
}
interface BookingAttendee {
id?: number | string;
email: string;
name?: string;
noShow?: boolean;
absent?: boolean;
}
export default function MarkNoShow() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const [booking, setBooking] = useState<Booking | null>(null);
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [isLoading, setIsLoading] = useState(true);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then((bookingData) => {
setBooking(bookingData);
// API may return "absent" or "noShow" depending on endpoint version
const bookingAttendees: Attendee[] = [];
if (bookingData.attendees && Array.isArray(bookingData.attendees)) {
bookingData.attendees.forEach((att: BookingAttendee) => {
bookingAttendees.push({
id: att.id,
email: att.email,
name: att.name || att.email,
noShow: att.absent === true || att.noShow === true,
});
});
}
setAttendees(bookingAttendees);
})
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const renderHeaderLeft = useCallback(
() => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[router, theme.border, theme.background, theme.text]
);
if (isLoading) {
return (
<>
<Stack.Screen
options={{
title: "Mark No-Show",
headerBackButtonDisplayMode: "minimal",
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
</Stack.Header>
)}
<View
className="flex-1 items-center justify-center"
style={{ backgroundColor: theme.background }}
>
<ActivityIndicator size="large" color={theme.text} />
</View>
</>
);
}
return (
<>
<Stack.Screen
options={{
title: "Mark No-Show",
headerBackButtonDisplayMode: "minimal",
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Mark No-Show</Stack.Header.Title>
</Stack.Header>
)}
<MarkNoShowScreenComponent
booking={booking}
attendees={attendees}
onUpdate={setAttendees}
onBookingUpdate={(updatedBooking) => {
setBooking(updatedBooking);
const updatedAttendees: Attendee[] = [];
if (updatedBooking.attendees && Array.isArray(updatedBooking.attendees)) {
updatedBooking.attendees.forEach((att: BookingAttendee) => {
updatedAttendees.push({
id: att.id,
email: att.email,
name: att.name || att.email,
noShow: att.absent === true || att.noShow === true,
});
});
}
setAttendees(updatedAttendees);
}}
/>
</>
);
}
@@ -1,103 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { ConferencingSession } from "@/services/types/bookings.types";
import { showErrorAlert } from "@/utils/alerts";
/**
* Get the presentation style for the meeting session details sheet
* - Uses formSheet on iPhone with liquid glass support
* - Uses modal on iPad or older iOS devices
*/
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function MeetingSessionDetailsIOS() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [sessions, setSessions] = useState<ConferencingSession[]>([]);
const [isLoading, setIsLoading] = useState(true);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getConferencingSessions(uid)
.then((sessionsData) => {
setSessions(sessionsData);
})
.catch(() => {
showErrorAlert("Error", "Failed to load session details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
return (
<>
<Stack.Screen
options={{
title: "Session Details",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? "transparent" : theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Session Details</Stack.Header.Title>
</Stack.Header>
<View
style={{
flex: 1,
backgroundColor: useGlassEffect ? "transparent" : theme.background,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={theme.text} />
</View>
) : (
<MeetingSessionDetailsScreenComponent
sessions={sessions}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-111
View File
@@ -1,111 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { getColors } from "@/constants/colors";
import MeetingSessionDetailsScreenComponent from "@/components/screens/MeetingSessionDetailsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { ConferencingSession } from "@/services/types/bookings.types";
import { showErrorAlert } from "@/utils/alerts";
export default function MeetingSessionDetails() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const [sessions, setSessions] = useState<ConferencingSession[]>([]);
const [isLoading, setIsLoading] = useState(true);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getConferencingSessions(uid)
.then((sessionsData) => {
setSessions(sessionsData);
})
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const renderHeaderLeft = useCallback(
() => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[router, theme.border, theme.background, theme.text]
);
if (isLoading) {
return (
<>
<Stack.Screen
options={{
title: "Session Details",
headerBackButtonDisplayMode: "minimal",
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Title>Session Details</Stack.Header.Title>
</Stack.Header>
)}
<View
className="flex-1 items-center justify-center"
style={{ backgroundColor: theme.background }}
>
<ActivityIndicator size="large" color={theme.text} />
</View>
</>
);
}
return (
<>
<Stack.Screen
options={{
title: "Session Details",
headerBackButtonDisplayMode: "minimal",
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Session Details</Stack.Header.Title>
</Stack.Header>
)}
<MeetingSessionDetailsScreenComponent sessions={sessions} />
</>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect } from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import { useAuth } from "@/contexts";
import { safeLogError } from "@/utils/safeLogger";
export default function OAuthCallback() {
const router = useRouter();
const params = useLocalSearchParams();
const auth = useAuth();
useEffect(() => {
if (Platform.OS === "android") {
if (auth.userInfo) {
router.replace("/");
}
}
if (Platform.OS !== "web") return;
// 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";
safeLogError("OAuth error occurred", { 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("/");
}
}
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("/");
}
}
} else {
safeLogError("No authorization code or state in OAuth callback", { code, state });
// 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("/");
}
}
}
}, [params, router, auth.userInfo]);
return (
<View className="flex-1 items-center justify-center bg-white dark:bg-black">
<ActivityIndicator size="large" color="#2563eb" />
<Text className="mt-4 text-lg text-gray-700 dark:text-gray-300">
Processing OAuth callback...
</Text>
<Text className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Please wait while we complete the authentication.
</Text>
</View>
);
}
-254
View File
@@ -1,254 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import * as Haptics from "expo-haptics";
import { Stack, useRouter } from "expo-router";
import {
ActivityIndicator,
Image,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import { useUserProfile } from "@/hooks";
import { showSuccessAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
interface ProfileMenuItem {
id: string;
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
external?: boolean;
}
/**
* Get the presentation style for the profile sheet
* - Uses formSheet on iPhone with liquid glass support
* - Uses modal on iPad or older iOS devices
*/
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function ProfileSheet() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { data: userProfile, isLoading } = useUserProfile();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const colors = {
background: isDark ? "#000000" : "#FFFFFF",
text: isDark ? "#FFFFFF" : "#111827",
textSecondary: isDark ? "#A3A3A3" : "#6B7280",
icon: isDark ? "#FFFFFF" : "#374151",
iconSecondary: isDark ? "#636366" : "#9CA3AF",
avatarBg: isDark ? "#4D4D4D" : "#E5E7EB",
avatarText: isDark ? "#FFFFFF" : "#4B5563",
};
const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
const menuItems: ProfileMenuItem[] = [
{
id: "profile",
label: "My Profile",
icon: "person-outline",
onPress: () =>
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
external: true,
},
{
id: "settings",
label: "My Settings",
icon: "settings-outline",
onPress: () =>
openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"),
external: true,
},
{
id: "outOfOffice",
label: "Out of Office",
icon: "moon-outline",
onPress: () =>
openInAppBrowser(
"https://app.cal.com/settings/my-account/out-of-office",
"Out of Office page"
),
external: true,
},
{
id: "copyPublicPage",
label: "Copy public page link",
icon: "copy-outline",
onPress: async () => {
if (publicPageUrl) {
await Clipboard.setStringAsync(publicPageUrl);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
showSuccessAlert("Copied!", "Public page link copied to clipboard");
}
},
external: false,
},
{
id: "roadmap",
label: "Roadmap",
icon: "map-outline",
onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"),
external: true,
},
{
id: "help",
label: "Help",
icon: "help-circle-outline",
onPress: () => openInAppBrowser("https://cal.com/help", "Help page"),
external: true,
},
];
const handleClose = () => {
router.back();
};
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
return (
<>
<Stack.Screen
options={{
headerShown: true,
headerTransparent: true,
headerLargeTitle: false,
title: "You",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 0.9],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
},
headerStyle: {
backgroundColor: "transparent",
},
headerBlurEffect: useGlassEffect ? undefined : isDark ? "dark" : "light",
headerTintColor: colors.text,
headerLeft: () => null,
headerRight: () => null,
}}
/>
<Stack.Header>
<Stack.Header.Title>You</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleClose}
variant="prominent"
tintColor={theme.backgroundEmphasis}
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
paddingBottom: insets.bottom,
}}
>
{/* Profile Header */}
<View className="mt-20 px-6">
{isLoading ? (
<View className="items-center py-8">
<ActivityIndicator size="large" color={colors.text} />
</View>
) : (
<View className="flex-row items-center">
{/* Avatar */}
{userProfile?.avatarUrl ? (
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 64, height: 64, borderRadius: 32 }}
/>
) : (
<View
className="items-center justify-center rounded-full"
style={{ width: 64, height: 64, backgroundColor: colors.avatarBg }}
>
<Text className="text-2xl font-semibold" style={{ color: colors.avatarText }}>
{userProfile?.name?.charAt(0).toUpperCase() ||
userProfile?.email?.charAt(0).toUpperCase() ||
"?"}
</Text>
</View>
)}
{/* Name and Status */}
<View className="ml-4 flex-1">
<Text className="text-xl font-semibold" style={{ color: colors.text }}>
{userProfile?.name || "User"}
</Text>
{userProfile?.email ? (
<Text className="mt-1 text-sm" style={{ color: colors.textSecondary }}>
{userProfile.email}
</Text>
) : null}
</View>
</View>
)}
</View>
{/* Menu Items */}
<View className="px-2 py-4">
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.id}
className={`flex-row items-center justify-between rounded-xl px-4 py-4 ${
index < menuItems.length - 1 ? "mb-1" : ""
}`}
style={{ backgroundColor: "transparent" }}
onPress={item.onPress}
>
<View className="flex-row items-center">
<Ionicons name={item.icon} size={22} color={colors.icon} />
<Text className="ml-4 text-base" style={{ color: colors.text }}>
{item.label}
</Text>
</View>
{item.external ? (
<Ionicons name="open-outline" size={18} color={colors.iconSecondary} />
) : (
<Ionicons name="chevron-forward" size={18} color={colors.iconSecondary} />
)}
</TouchableOpacity>
))}
</View>
</View>
</>
);
}
-218
View File
@@ -1,218 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import { Stack, useRouter } from "expo-router";
import {
ActivityIndicator,
Image,
ScrollView,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { useUserProfile } from "@/hooks";
import { showSuccessAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
interface ProfileMenuItem {
id: string;
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
external?: boolean;
}
export default function ProfileSheet() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { data: userProfile, isLoading } = useUserProfile();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const colors = {
background: isDark ? "#000000" : "#FFFFFF",
text: isDark ? "#FFFFFF" : "#111827",
textSecondary: isDark ? "#A3A3A3" : "#6B7280",
icon: isDark ? "#FFFFFF" : "#374151",
iconSecondary: isDark ? "#636366" : "#9CA3AF",
avatarBg: isDark ? "#4D4D4D" : "#E5E7EB",
avatarText: isDark ? "#FFFFFF" : "#4B5563",
activeBackground: isDark ? "#171717" : "#F3F4F6",
};
const publicPageUrl = userProfile?.username ? `https://cal.com/${userProfile.username}` : null;
const menuItems: ProfileMenuItem[] = [
{
id: "profile",
label: "My Profile",
icon: "person-outline",
onPress: () =>
openInAppBrowser("https://app.cal.com/settings/my-account/profile", "Profile page"),
external: true,
},
{
id: "settings",
label: "My Settings",
icon: "settings-outline",
onPress: () =>
openInAppBrowser("https://app.cal.com/settings/my-account/general", "Settings page"),
external: true,
},
{
id: "outOfOffice",
label: "Out of Office",
icon: "moon-outline",
onPress: () =>
openInAppBrowser(
"https://app.cal.com/settings/my-account/out-of-office",
"Out of Office page"
),
external: true,
},
{
id: "publicPage",
label: "View public page",
icon: "globe-outline",
onPress: () => {
if (publicPageUrl) openInAppBrowser(publicPageUrl, "Public page");
},
external: true,
},
{
id: "copyPublicPage",
label: "Copy public page link",
icon: "copy-outline",
onPress: async () => {
if (publicPageUrl) {
await Clipboard.setStringAsync(publicPageUrl);
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
showSuccessAlert("Copied!", "Public page link copied to clipboard");
}
},
external: false,
},
{
id: "roadmap",
label: "Roadmap",
icon: "map-outline",
onPress: () => openInAppBrowser("https://cal.com/roadmap", "Roadmap page"),
external: true,
},
{
id: "help",
label: "Help",
icon: "help-circle-outline",
onPress: () => openInAppBrowser("https://cal.com/help", "Help page"),
external: true,
},
];
const handleClose = () => {
router.back();
};
return (
<>
<Stack.Screen
options={{
headerShown: true,
title: "Profile",
presentation: "modal",
contentStyle: {
backgroundColor: colors.background,
},
headerStyle: {
backgroundColor: colors.background,
},
headerTintColor: colors.text,
headerLeft: () => null,
headerRight: () => (
<HeaderButtonWrapper side="right">
<TouchableOpacity onPress={handleClose} style={{ padding: 8 }}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
</HeaderButtonWrapper>
),
}}
/>
<ScrollView
style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={{ paddingBottom: insets.bottom + 20 }}
>
{/* Profile Header */}
<View className="px-6 py-6">
{isLoading ? (
<View className="items-center py-8">
<ActivityIndicator size="large" color={colors.text} />
</View>
) : (
<View className="flex-row items-center">
{/* Avatar */}
{userProfile?.avatarUrl ? (
<Image
source={{ uri: getAvatarUrl(userProfile.avatarUrl) }}
style={{ width: 64, height: 64, borderRadius: 32 }}
/>
) : (
<View
className="items-center justify-center rounded-full"
style={{ width: 64, height: 64, backgroundColor: colors.avatarBg }}
>
<Text className="text-2xl font-semibold" style={{ color: colors.avatarText }}>
{userProfile?.name?.charAt(0).toUpperCase() ||
userProfile?.email?.charAt(0).toUpperCase() ||
"?"}
</Text>
</View>
)}
{/* Name and Email */}
<View className="ml-4 flex-1">
<Text className="text-xl font-semibold" style={{ color: colors.text }}>
{userProfile?.name || "User"}
</Text>
{userProfile?.email ? (
<Text className="mt-1 text-sm" style={{ color: colors.textSecondary }}>
{userProfile.email}
</Text>
) : null}
</View>
</View>
)}
</View>
{/* Menu Items */}
<View className="px-2 py-4">
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.id}
className={`flex-row items-center justify-between rounded-xl px-4 py-4 ${
index < menuItems.length - 1 ? "mb-1" : ""
}`}
style={{ backgroundColor: "transparent" }}
onPress={item.onPress}
>
<View className="flex-row items-center">
<Ionicons name={item.icon} size={22} color={colors.icon} />
<Text className="ml-4 text-base" style={{ color: colors.text }}>
{item.label}
</Text>
</View>
{item.external ? (
<Ionicons name="open-outline" size={18} color={colors.iconSecondary} />
) : (
<Ionicons name="chevron-forward" size={18} color={colors.iconSecondary} />
)}
</TouchableOpacity>
))}
</View>
</ScrollView>
</>
);
}
-138
View File
@@ -1,138 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen";
import RescheduleScreenComponent from "@/components/screens/RescheduleScreen";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
// Semi-transparent background to prevent black flash while preserving glass effect
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function RescheduleIOS() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [booking, setBooking] = useState<Booking | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const rescheduleScreenRef = useRef<RescheduleScreenHandle>(null);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const handleSave = useCallback(() => {
rescheduleScreenRef.current?.submit();
}, []);
const handleRescheduleSuccess = useCallback(() => {
router.back();
}, [router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
// Semi-transparent background to prevent flashes while preserving glass effect
const glassBackground = isDark ? "rgba(28, 28, 30, 0.01)" : "rgba(248, 248, 250, 0.01)";
return (
<>
<Stack.Screen
options={{
title: "Reschedule",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button
onPress={() => router.back()}
tintColor={theme.backgroundEmphasis}
variant="prominent"
>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Reschedule</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor={theme.text}
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
backgroundColor: useGlassEffect
? glassBackground
: isDark
? theme.backgroundSecondary
: theme.background,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={theme.text} />
</View>
) : (
<RescheduleScreenComponent
ref={rescheduleScreenRef}
booking={booking}
onSuccess={handleRescheduleSuccess}
onSavingChange={setIsSaving}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-154
View File
@@ -1,154 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import type { RescheduleScreenHandle } from "@/components/screens/RescheduleScreen";
import RescheduleScreenComponent from "@/components/screens/RescheduleScreen";
import { getColors } from "@/constants/colors";
import { type Booking, CalComAPIService } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
export default function Reschedule() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const [booking, setBooking] = useState<Booking | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const rescheduleScreenRef = useRef<RescheduleScreenHandle>(null);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getBookingByUid(uid)
.then(setBooking)
.catch(() => {
showErrorAlert("Error", "Failed to load booking details");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const handleSave = useCallback(() => {
rescheduleScreenRef.current?.submit();
}, []);
const handleRescheduleSuccess = useCallback(() => {
router.back();
}, [router]);
const renderHeaderLeft = useCallback(
() => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[router, theme.border, theme.background, theme.text]
);
const renderHeaderRight = useCallback(
() => (
<HeaderButtonWrapper side="right">
<AppPressable
onPress={handleSave}
disabled={isSaving}
className={`h-10 w-10 items-center justify-center rounded-full border ${
isSaving ? "opacity-50" : ""
}`}
style={{
borderColor: theme.border,
backgroundColor: theme.background,
}}
>
<Ionicons name="checkmark" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[handleSave, isSaving, theme.text, theme.border, theme.background]
);
if (isLoading) {
return (
<>
<Stack.Screen
options={{
title: "Reschedule",
headerBackButtonDisplayMode: "minimal",
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Title>Reschedule</Stack.Header.Title>
</Stack.Header>
)}
<View
className="flex-1 items-center justify-center"
style={{ backgroundColor: theme.background }}
>
<ActivityIndicator size="large" color={theme.text} />
</View>
</>
);
}
return (
<>
<Stack.Screen
options={{
title: "Reschedule",
headerBackButtonDisplayMode: "minimal",
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
headerRight: Platform.OS !== "ios" ? renderHeaderRight : undefined,
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Reschedule</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button onPress={handleSave} disabled={isSaving}>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
)}
<RescheduleScreenComponent
ref={rescheduleScreenRef}
booking={booking}
onSuccess={handleRescheduleSuccess}
onSavingChange={setIsSaving}
useNativeHeader
/>
</>
);
}
-98
View File
@@ -1,98 +0,0 @@
import { osName } from "expo-device";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getColors } from "@/constants/colors";
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { BookingRecording } from "@/services/types/bookings.types";
import { showErrorAlert } from "@/utils/alerts";
import { safeLogError } from "@/utils/safeLogger";
function getPresentationStyle(): "formSheet" | "modal" {
if (isLiquidGlassAvailable() && osName !== "iPadOS") {
return "formSheet";
}
return "modal";
}
export default function ViewRecordingsIOS() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const [recordings, setRecordings] = useState<BookingRecording[]>([]);
const [isLoading, setIsLoading] = useState(true);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getRecordings(uid)
.then(setRecordings)
.catch((error) => {
safeLogError("Failed to load recordings:", error);
showErrorAlert("Error", "Failed to load recordings");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const presentationStyle = getPresentationStyle();
const useGlassEffect = isLiquidGlassAvailable();
return (
<>
<Stack.Screen
options={{
title: "Recordings",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? "transparent" : theme.background,
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Recordings</Stack.Header.Title>
</Stack.Header>
<View
style={{
flex: 1,
backgroundColor: useGlassEffect ? "transparent" : theme.background,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={theme.text} />
</View>
) : (
<ViewRecordingsScreenComponent
recordings={recordings}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
-111
View File
@@ -1,111 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Platform, useColorScheme, View } from "react-native";
import { AppPressable } from "@/components/AppPressable";
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
import { getColors } from "@/constants/colors";
import ViewRecordingsScreenComponent from "@/components/screens/ViewRecordingsScreen";
import { CalComAPIService } from "@/services/calcom";
import type { BookingRecording } from "@/services/types/bookings.types";
import { showErrorAlert } from "@/utils/alerts";
import { safeLogError } from "@/utils/safeLogger";
export default function ViewRecordings() {
const { uid } = useLocalSearchParams<{ uid: string }>();
const router = useRouter();
const [recordings, setRecordings] = useState<BookingRecording[]>([]);
const [isLoading, setIsLoading] = useState(true);
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
useEffect(() => {
if (uid) {
setIsLoading(true);
CalComAPIService.getRecordings(uid)
.then(setRecordings)
.catch((error) => {
safeLogError("Failed to load recordings:", error);
showErrorAlert("Error", "Failed to load recordings");
router.back();
})
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
showErrorAlert("Error", "Booking ID is missing");
router.back();
}
}, [uid, router]);
const renderHeaderLeft = useCallback(
() => (
<HeaderButtonWrapper side="left">
<AppPressable
onPress={() => router.back()}
className="h-10 w-10 items-center justify-center rounded-full border"
style={{
borderColor: theme.border,
backgroundColor: theme.background,
marginRight: 8,
}}
>
<Ionicons name="close" size={20} color={theme.text} />
</AppPressable>
</HeaderButtonWrapper>
),
[router, theme.border, theme.background, theme.text]
);
if (isLoading) {
return (
<>
<Stack.Screen
options={{
title: "Recordings",
headerBackButtonDisplayMode: "minimal",
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Title>Recordings</Stack.Header.Title>
</Stack.Header>
)}
<View
className="flex-1 items-center justify-center"
style={{ backgroundColor: theme.background }}
>
<ActivityIndicator size="large" color={theme.text} />
</View>
</>
);
}
return (
<>
<Stack.Screen
options={{
title: "Recordings",
headerBackButtonDisplayMode: "minimal",
headerLeft: Platform.OS !== "ios" ? renderHeaderLeft : undefined,
}}
/>
{Platform.OS === "ios" && (
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Recordings</Stack.Header.Title>
</Stack.Header>
)}
<ViewRecordingsScreenComponent recordings={recordings} />
</>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

@@ -1,5 +0,0 @@
<svg width="40" height="21" viewBox="0 0 40 21" 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"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-50
View File
@@ -1,50 +0,0 @@
{
"fill": "automatic",
"groups": [
{
"layers": [
{
"blend-mode-specializations": [
{
"appearance": "tinted",
"value": "normal"
}
],
"fill-specializations": [
{
"appearance": "dark",
"value": {
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
}
},
{
"appearance": "tinted",
"value": {
"solid": "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"glass": false,
"image-name": "cal.svg",
"name": "cal",
"position": {
"scale": 19.57,
"translation-in-points": [-17, 2]
}
}
],
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

-9
View File
@@ -1,9 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

-17
View File
@@ -1,17 +0,0 @@
module.exports = (api) => {
api.cache(true);
return {
presets: [
[
"babel-preset-expo",
{
jsxImportSource: "nativewind",
"react-compiler": {
panicThreshold: "all_errors",
},
},
],
"nativewind/babel",
],
};
};
-86
View File
@@ -1,86 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
"root": false,
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": [
"**/*.js",
"**/*.jsx",
"**/*.ts",
"**/*.tsx",
"**/*.json",
"!**/node_modules",
"!**/.expo",
"!**/dist",
"!**/.output",
"!**/*.d.ts",
"!**/bun.lock",
"!**/android",
"!**/ios",
"!**/safari-extension"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error",
"noArrayIndexKey": "error",
"useIterableCallbackReturn": "error",
"noImplicitAnyLet": "off"
},
"correctness": {
"useExhaustiveDependencies": "error",
"useHookAtTopLevel": "error",
"noInvalidUseBeforeDeclaration": "off",
"noUnusedFunctionParameters": "error",
"noUnusedVariables": "error"
},
"complexity": {
"noStaticOnlyClass": "error"
},
"style": {
"noNonNullAssertion": "error"
},
"a11y": {
"noStaticElementInteractions": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "es5",
"semicolons": "always",
"arrowParentheses": "always"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["extension/**/*.ts", "extension/**/*.tsx"],
"javascript": {
"globals": ["chrome", "browser"]
}
}
]
}
-2897
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
-106
View File
@@ -1,106 +0,0 @@
import * as Haptics from "expo-haptics";
import React, { useCallback } from "react";
import type { GestureResponderEvent, PressableProps, ViewStyle } from "react-native";
import { Pressable } from "react-native";
import Animated, {
type SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
type AppPressableProps = PressableProps & {
className?: string;
style?: ViewStyle;
enableHaptics?: boolean;
hapticStyle?: Haptics.ImpactFeedbackStyle;
/** @deprecated Use the built-in animation instead. Kept for backward compatibility. */
activeOpacity?: number;
};
function usePressAnimations(activeOpacity: number) {
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
const animatePressIn = useCallback(
(op: SharedValue<number>, sc: SharedValue<number>) => {
op.value = withTiming(activeOpacity, { duration: 100 });
sc.value = withTiming(0.98, { duration: 100 });
},
[activeOpacity]
);
const animatePressOut = useCallback((op: SharedValue<number>, sc: SharedValue<number>) => {
op.value = withTiming(1, { duration: 100 });
sc.value = withTiming(1, { duration: 100 });
}, []);
return { animatedStyle, animatePressIn, animatePressOut, opacity, scale };
}
export const AppPressable = React.forwardRef<React.ElementRef<typeof Pressable>, AppPressableProps>(
(
{
className,
style,
enableHaptics = true,
hapticStyle = Haptics.ImpactFeedbackStyle.Light,
activeOpacity = 0.7,
onPressIn,
onPressOut,
onPress,
...props
},
ref
) => {
const { animatedStyle, animatePressIn, animatePressOut, opacity, scale } =
usePressAnimations(activeOpacity);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
animatePressIn(opacity, scale);
onPressIn?.(event);
},
[animatePressIn, onPressIn, opacity, scale]
);
const handlePressOut = useCallback(
(event: GestureResponderEvent) => {
animatePressOut(opacity, scale);
onPressOut?.(event);
},
[animatePressOut, onPressOut, opacity, scale]
);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
if (enableHaptics) {
Haptics.impactAsync(hapticStyle);
}
onPress?.(event);
},
[enableHaptics, hapticStyle, onPress]
);
return (
<AnimatedPressable
ref={ref}
className={className}
style={[style, animatedStyle]}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
{...props}
/>
);
}
);
AppPressable.displayName = "AppPressable";
@@ -1,19 +0,0 @@
import { useEffect } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { CalComAPIService } from "@/services/calcom";
export function AuthTokenProvider({ children }: { children: React.ReactNode }) {
const { accessToken, isAuthenticated, isWebSession: _isWebSession } = useAuth();
useEffect(() => {
if (isAuthenticated) {
if (accessToken) {
CalComAPIService.setAccessToken(accessToken);
}
} else {
CalComAPIService.clearAuth();
}
}, [isAuthenticated, accessToken]);
return <>{children}</>;
}
@@ -1,341 +0,0 @@
/**
* BookingActionsModal Component - iOS Implementation
*
* iOS-specific modal for booking actions with Glass UI styling.
* This provides a native iOS action sheet experience with Glass UI design.
*/
import { Ionicons } from "@expo/vector-icons";
import { ScrollView, Text, TouchableOpacity, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { Booking } from "@/services/calcom";
import type { BookingActionsResult } from "@/utils/booking-actions";
import { FullScreenModal } from "./FullScreenModal";
import { getColors } from "@/constants/colors";
export interface BookingActionsModalProps {
visible: boolean;
onClose: () => void;
booking: Booking | null;
actions: BookingActionsResult;
onReschedule: () => void;
onRequestReschedule?: () => void;
onEditLocation: () => void;
onAddGuests: () => void;
onViewRecordings: () => void;
onMeetingSessionDetails: () => void;
onMarkNoShow: () => void;
onReportBooking: () => void;
onCancelBooking: () => void;
}
// Icon mapping for actions
const ACTION_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
reschedule: "calendar",
rescheduleRequest: "send",
changeLocation: "location",
addGuests: "person-add",
viewRecordings: "videocam",
meetingSessionDetails: "information-circle",
markNoShow: "eye-off",
report: "flag",
cancel: "close-circle",
};
interface ActionButtonProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
visible: boolean;
enabled: boolean;
isDanger?: boolean;
isLast?: boolean;
}
function ActionButton({
icon,
label,
onPress,
visible,
enabled,
isDanger = false,
isLast = false,
}: ActionButtonProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
if (!visible) return null;
const iconColor = !enabled ? "#C7C7CC" : isDanger ? theme.destructive : "#007AFF";
const textColor = !enabled ? "#C7C7CC" : isDanger ? theme.destructive : theme.text;
return (
<TouchableOpacity
onPress={() => {
if (!enabled) return;
onPress();
}}
disabled={!enabled}
className={`flex-row items-center px-4 py-3.5 active:bg-[#F8F9FA] dark:active:bg-[#262626] ${
!isLast ? "border-b border-gray-100 dark:border-[#4D4D4D]" : ""
}`}
activeOpacity={0.7}
>
<View className="mr-3 h-7 w-7 items-center justify-center">
<Ionicons name={icon} size={22} color={iconColor} />
</View>
<Text
className={`flex-1 text-[17px] ${!enabled ? "text-[#C7C7CC]" : ""}`}
style={{ color: textColor }}
>
{label}
</Text>
{!enabled && (
<View className="rounded bg-gray-100 px-2 py-0.5 dark:bg-[#262626]">
<Text className="text-xs text-gray-500 dark:text-[#A3A3A3]">Unavailable</Text>
</View>
)}
</TouchableOpacity>
);
}
interface SectionHeaderProps {
title: string;
}
function SectionHeader({ title }: SectionHeaderProps) {
return (
<View className="bg-[#F2F2F7] px-4 py-2 dark:bg-[#262626]">
<Text className="text-[13px] font-medium uppercase tracking-wide text-gray-500 dark:text-[#A3A3A3]">
{title}
</Text>
</View>
);
}
export function BookingActionsModal({
visible,
onClose,
booking,
actions,
onReschedule,
onRequestReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}: BookingActionsModalProps) {
const insets = useSafeAreaInsets();
if (!booking) return null;
const handleAction = (action: () => void) => {
onClose();
action();
};
// Check if any edit event actions are visible
const hasEditEventActions =
actions.reschedule.visible ||
actions.rescheduleRequest.visible ||
actions.changeLocation.visible ||
actions.addGuests.visible;
// Check if any after event actions are visible
const hasAfterEventActions =
actions.viewRecordings.visible ||
actions.meetingSessionDetails.visible ||
actions.markNoShow.visible;
// Define edit event actions
const editEventActions = [
{
key: "reschedule",
icon: ACTION_ICONS.reschedule,
label: "Reschedule Booking",
onPress: () => handleAction(onReschedule),
visible: actions.reschedule.visible,
enabled: actions.reschedule.enabled,
},
...(onRequestReschedule
? [
{
key: "rescheduleRequest",
icon: ACTION_ICONS.rescheduleRequest,
label: "Request Reschedule",
onPress: () => handleAction(onRequestReschedule),
visible: actions.rescheduleRequest.visible,
enabled: actions.rescheduleRequest.enabled,
},
]
: []),
{
key: "changeLocation",
icon: ACTION_ICONS.changeLocation,
label: "Edit Location",
onPress: () => handleAction(onEditLocation),
visible: actions.changeLocation.visible,
enabled: actions.changeLocation.enabled,
},
{
key: "addGuests",
icon: ACTION_ICONS.addGuests,
label: "Add Guests",
onPress: () => handleAction(onAddGuests),
visible: actions.addGuests.visible,
enabled: actions.addGuests.enabled,
},
].filter((action) => action.visible);
// Define after event actions
const afterEventActions = [
{
key: "viewRecordings",
icon: ACTION_ICONS.viewRecordings,
label: "View Recordings",
onPress: () => handleAction(onViewRecordings),
visible: actions.viewRecordings.visible,
enabled: actions.viewRecordings.enabled,
},
{
key: "meetingSessionDetails",
icon: ACTION_ICONS.meetingSessionDetails,
label: "Meeting Session Details",
onPress: () => handleAction(onMeetingSessionDetails),
visible: actions.meetingSessionDetails.visible,
enabled: actions.meetingSessionDetails.enabled,
},
{
key: "markNoShow",
icon: ACTION_ICONS.markNoShow,
label: "Mark as No-Show",
onPress: () => handleAction(onMarkNoShow),
visible: actions.markNoShow.visible,
enabled: actions.markNoShow.enabled,
},
].filter((action) => action.visible);
// Define danger zone actions
const dangerZoneActions = [
{
key: "report",
icon: ACTION_ICONS.report,
label: "Report Booking",
onPress: () => handleAction(onReportBooking),
visible: true,
enabled: true,
isDanger: true,
},
{
key: "cancel",
icon: ACTION_ICONS.cancel,
label: "Cancel Event",
onPress: () => handleAction(onCancelBooking),
visible: actions.cancel.visible,
enabled: actions.cancel.enabled,
isDanger: true,
},
].filter((action) => action.visible);
return (
<FullScreenModal visible={visible} animationType="fade" onRequestClose={onClose}>
<TouchableOpacity
className="flex-1 items-center justify-end bg-black/40"
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
className="w-full"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
style={{ paddingBottom: insets.bottom || 16 }}
>
{/* Actions Card */}
<View className="mx-4 mb-3 overflow-hidden rounded-2xl bg-white dark:bg-[#171717]">
{/* Booking Title Header */}
<View className="border-b border-gray-100 px-4 py-4 dark:border-[#4D4D4D]">
<Text
className="text-center text-[13px] font-medium text-gray-500 dark:text-[#A3A3A3]"
numberOfLines={1}
>
{booking.title}
</Text>
</View>
<ScrollView className="max-h-[400px]">
{/* Edit Event Section */}
{hasEditEventActions && (
<>
<SectionHeader title="Edit Event" />
{editEventActions.map((action, index) => (
<ActionButton
key={action.key}
icon={action.icon}
label={action.label}
onPress={action.onPress}
visible={action.visible}
enabled={action.enabled}
isLast={index === editEventActions.length - 1}
/>
))}
</>
)}
{/* After Event Section */}
{hasAfterEventActions && (
<>
<SectionHeader title="After Event" />
{afterEventActions.map((action, index) => (
<ActionButton
key={action.key}
icon={action.icon}
label={action.label}
onPress={action.onPress}
visible={action.visible}
enabled={action.enabled}
isLast={index === afterEventActions.length - 1}
/>
))}
</>
)}
{/* Danger Zone Section */}
{dangerZoneActions.length > 0 && (
<>
<SectionHeader title="Danger Zone" />
{dangerZoneActions.map((action, index) => (
<ActionButton
key={action.key}
icon={action.icon}
label={action.label}
onPress={action.onPress}
visible={action.visible}
enabled={action.enabled}
isDanger={action.isDanger}
isLast={index === dangerZoneActions.length - 1}
/>
))}
</>
)}
</ScrollView>
</View>
{/* Cancel Button */}
<TouchableOpacity
className="mx-4 overflow-hidden rounded-2xl bg-white dark:bg-[#171717]"
onPress={onClose}
activeOpacity={0.7}
>
<View className="px-4 py-4">
<Text className="text-center text-[17px] font-semibold text-[#007AFF]">Cancel</Text>
</View>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
);
}
@@ -1,340 +0,0 @@
/**
* BookingActionsModal Component - Android/Web Implementation
*
* A reusable modal component for booking actions that can be used in both
* the bookings list screen and the booking detail screen.
*
* This component uses the centralized action gating utility for consistent
* action visibility and enabled state across the app.
*
* Note: iOS uses BookingActionsModal.ios.tsx with native Glass UI styling.
*/
import { Ionicons } from "@expo/vector-icons";
import { ScrollView, Text, TouchableOpacity, useColorScheme, View } from "react-native";
import { getColors } from "@/constants/colors";
import type { Booking } from "@/services/calcom";
import type { BookingActionsResult } from "@/utils/booking-actions";
import { FullScreenModal } from "./FullScreenModal";
export interface BookingActionsModalProps {
visible: boolean;
onClose: () => void;
booking: Booking | null;
actions: BookingActionsResult;
onReschedule: () => void;
onRequestReschedule?: () => void;
onEditLocation: () => void;
onAddGuests: () => void;
onViewRecordings: () => void;
onMeetingSessionDetails: () => void;
onMarkNoShow: () => void;
onReportBooking: () => void;
onCancelBooking: () => void;
}
// Icon mapping for actions
const ACTION_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
reschedule: "calendar-outline",
rescheduleRequest: "send-outline",
changeLocation: "location-outline",
addGuests: "person-add-outline",
viewRecordings: "videocam-outline",
meetingSessionDetails: "information-circle-outline",
markNoShow: "eye-off-outline",
report: "flag-outline",
cancel: "close-circle-outline",
};
interface ActionButtonProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
visible: boolean;
enabled: boolean;
isDanger?: boolean;
isLast?: boolean;
}
function ActionButton({
icon,
label,
onPress,
visible,
enabled,
isDanger = false,
isLast = false,
}: ActionButtonProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
if (!visible) return null;
const iconColor = !enabled ? "#D1D5DB" : isDanger ? theme.destructive : "#6B7280";
const textColor = !enabled ? "#D1D5DB" : isDanger ? theme.destructive : "#111827";
return (
<TouchableOpacity
onPress={() => {
if (!enabled) return;
onPress();
}}
disabled={!enabled}
className={`flex-row items-center px-4 py-3 active:bg-gray-50 dark:active:bg-[#262626] ${
!isLast ? "border-b border-gray-100 dark:border-[#4D4D4D]" : ""
}`}
activeOpacity={0.7}
>
<View className="mr-3 h-6 w-6 items-center justify-center">
<Ionicons name={icon} size={20} color={iconColor} />
</View>
<Text className="flex-1 text-[16px]" style={{ color: textColor }}>
{label}
</Text>
{!enabled && (
<View className="rounded bg-gray-100 px-2 py-0.5 dark:bg-[#262626]">
<Text className="text-xs text-gray-500 dark:text-[#A3A3A3]">Unavailable</Text>
</View>
)}
</TouchableOpacity>
);
}
interface SectionHeaderProps {
title: string;
}
function SectionHeader({ title }: SectionHeaderProps) {
return (
<View className="bg-gray-50 px-4 py-2 dark:bg-[#262626]">
<Text className="text-[12px] font-semibold uppercase tracking-wide text-gray-500 dark:text-[#A3A3A3]">
{title}
</Text>
</View>
);
}
export function BookingActionsModal({
visible,
onClose,
booking,
actions,
onReschedule,
onRequestReschedule,
onEditLocation,
onAddGuests,
onViewRecordings,
onMeetingSessionDetails,
onMarkNoShow,
onReportBooking,
onCancelBooking,
}: BookingActionsModalProps) {
if (!booking) return null;
const handleAction = (action: () => void) => {
onClose();
action();
};
// Check if any edit event actions are visible
const hasEditEventActions =
actions.reschedule.visible ||
actions.rescheduleRequest.visible ||
actions.changeLocation.visible ||
actions.addGuests.visible;
// Check if any after event actions are visible
const hasAfterEventActions =
actions.viewRecordings.visible ||
actions.meetingSessionDetails.visible ||
actions.markNoShow.visible;
// Define edit event actions
const editEventActions = [
{
key: "reschedule",
icon: ACTION_ICONS.reschedule,
label: "Reschedule Booking",
onPress: () => handleAction(onReschedule),
visible: actions.reschedule.visible,
enabled: actions.reschedule.enabled,
},
...(onRequestReschedule
? [
{
key: "rescheduleRequest",
icon: ACTION_ICONS.rescheduleRequest,
label: "Request Reschedule",
onPress: () => handleAction(onRequestReschedule),
visible: actions.rescheduleRequest.visible,
enabled: actions.rescheduleRequest.enabled,
},
]
: []),
{
key: "changeLocation",
icon: ACTION_ICONS.changeLocation,
label: "Edit Location",
onPress: () => handleAction(onEditLocation),
visible: actions.changeLocation.visible,
enabled: actions.changeLocation.enabled,
},
{
key: "addGuests",
icon: ACTION_ICONS.addGuests,
label: "Add Guests",
onPress: () => handleAction(onAddGuests),
visible: actions.addGuests.visible,
enabled: actions.addGuests.enabled,
},
].filter((action) => action.visible);
// Define after event actions
const afterEventActions = [
{
key: "viewRecordings",
icon: ACTION_ICONS.viewRecordings,
label: "View Recordings",
onPress: () => handleAction(onViewRecordings),
visible: actions.viewRecordings.visible,
enabled: actions.viewRecordings.enabled,
},
{
key: "meetingSessionDetails",
icon: ACTION_ICONS.meetingSessionDetails,
label: "Meeting Session Details",
onPress: () => handleAction(onMeetingSessionDetails),
visible: actions.meetingSessionDetails.visible,
enabled: actions.meetingSessionDetails.enabled,
},
{
key: "markNoShow",
icon: ACTION_ICONS.markNoShow,
label: "Mark as No-Show",
onPress: () => handleAction(onMarkNoShow),
visible: actions.markNoShow.visible,
enabled: actions.markNoShow.enabled,
},
].filter((action) => action.visible);
// Define danger zone actions
const dangerZoneActions = [
{
key: "report",
icon: ACTION_ICONS.report,
label: "Report Booking",
onPress: () => handleAction(onReportBooking),
visible: true,
enabled: true,
isDanger: true,
},
{
key: "cancel",
icon: ACTION_ICONS.cancel,
label: "Cancel Event",
onPress: () => handleAction(onCancelBooking),
visible: actions.cancel.visible,
enabled: actions.cancel.enabled,
isDanger: true,
},
].filter((action) => action.visible);
return (
<FullScreenModal visible={visible} animationType="fade" onRequestClose={onClose}>
<TouchableOpacity
className="flex-1 items-center justify-center bg-black/50 p-4"
activeOpacity={1}
onPress={onClose}
>
<TouchableOpacity
className="w-full max-w-md"
activeOpacity={1}
onPress={(e) => e.stopPropagation()}
>
{/* Actions Card */}
<View className="mb-4 overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-[#171717]">
{/* Booking Title Header */}
<View className="border-b border-gray-100 px-4 py-3 dark:border-[#4D4D4D]">
<Text
className="text-center text-[14px] font-medium text-gray-600 dark:text-[#A3A3A3]"
numberOfLines={1}
>
{booking.title}
</Text>
</View>
<ScrollView className="max-h-[400px]">
{/* Edit Event Section */}
{hasEditEventActions && (
<>
<SectionHeader title="Edit Event" />
{editEventActions.map((action, index) => (
<ActionButton
key={action.key}
icon={action.icon}
label={action.label}
onPress={action.onPress}
visible={action.visible}
enabled={action.enabled}
isLast={index === editEventActions.length - 1}
/>
))}
</>
)}
{/* After Event Section */}
{hasAfterEventActions && (
<>
<SectionHeader title="After Event" />
{afterEventActions.map((action, index) => (
<ActionButton
key={action.key}
icon={action.icon}
label={action.label}
onPress={action.onPress}
visible={action.visible}
enabled={action.enabled}
isLast={index === afterEventActions.length - 1}
/>
))}
</>
)}
{/* Danger Zone Section */}
{dangerZoneActions.length > 0 && (
<>
<SectionHeader title="Danger Zone" />
{dangerZoneActions.map((action, index) => (
<ActionButton
key={action.key}
icon={action.icon}
label={action.label}
onPress={action.onPress}
visible={action.visible}
enabled={action.enabled}
isDanger={action.isDanger}
isLast={index === dangerZoneActions.length - 1}
/>
))}
</>
)}
</ScrollView>
</View>
{/* Cancel Button */}
<TouchableOpacity
className="overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-[#171717]"
onPress={onClose}
activeOpacity={0.7}
>
<View className="px-4 py-3">
<Text className="text-center text-[16px] font-semibold text-gray-700 dark:text-white">
Cancel
</Text>
</View>
</TouchableOpacity>
</TouchableOpacity>
</TouchableOpacity>
</FullScreenModal>
);
}
@@ -1,145 +0,0 @@
/**
* CacheStatusIndicator Component
*
* A subtle, non-intrusive component that displays cache status information:
* - "Last updated: X minutes ago" when online
* - "Offline - showing cached data" when offline
*
* This component is designed to be placed in screen headers or footers
* to provide users with transparency about data freshness.
*/
import { Ionicons } from "@expo/vector-icons";
import { StyleSheet, Text, View } from "react-native";
import { useQueryContext } from "@/contexts/QueryContext";
/**
* Props for the CacheStatusIndicator component
*/
interface CacheStatusIndicatorProps {
/** Timestamp when the data was last fetched (from query.dataUpdatedAt) */
dataUpdatedAt?: number;
/** Whether data is currently being fetched */
isFetching?: boolean;
/** Optional custom style */
style?: object;
/** Show compact version (icon only when online) */
compact?: boolean;
}
/**
* Format the time difference in a human-readable way
*/
const formatTimeAgo = (timestamp: number): string => {
const now = Date.now();
const diffMs = now - timestamp;
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
if (diffSeconds < 10) {
return "Just now";
}
if (diffSeconds < 60) {
return `${diffSeconds}s ago`;
}
if (diffMinutes < 60) {
return `${diffMinutes}m ago`;
}
if (diffHours < 24) {
return `${diffHours}h ago`;
}
return "Over a day ago";
};
/**
* CacheStatusIndicator component
*
* @example
* ```tsx
* const { dataUpdatedAt, isFetching } = useBookings();
*
* <CacheStatusIndicator
* dataUpdatedAt={dataUpdatedAt}
* isFetching={isFetching}
* />
* ```
*/
export function CacheStatusIndicator({
dataUpdatedAt,
isFetching = false,
style,
compact = false,
}: CacheStatusIndicatorProps) {
const { isOnline } = useQueryContext();
// Don't show anything if no data has been fetched yet
if (!dataUpdatedAt && !isFetching && isOnline) {
return null;
}
// Offline state
if (!isOnline) {
return (
<View style={[styles.container, styles.offlineContainer, style]}>
<Ionicons name="cloud-offline-outline" size={12} color="#FF9500" />
<Text style={styles.offlineText}>Offline - showing cached data</Text>
</View>
);
}
// Fetching state
if (isFetching) {
return (
<View style={[styles.container, style]}>
<Ionicons name="sync-outline" size={12} color="#A3A3A3" />
{!compact && <Text style={styles.text}>Updating...</Text>}
</View>
);
}
// Normal state with last updated time
if (dataUpdatedAt) {
if (compact) {
return (
<View style={[styles.container, style]}>
<Ionicons name="time-outline" size={12} color="#A3A3A3" />
</View>
);
}
return (
<View style={[styles.container, style]}>
<Ionicons name="time-outline" size={12} color="#A3A3A3" />
<Text style={styles.text}>Updated {formatTimeAgo(dataUpdatedAt)}</Text>
</View>
);
}
return null;
}
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingVertical: 4,
paddingHorizontal: 8,
},
offlineContainer: {
backgroundColor: "rgba(255, 149, 0, 0.1)",
borderRadius: 4,
},
text: {
fontSize: 11,
color: "#A3A3A3",
},
offlineText: {
fontSize: 11,
color: "#FF9500",
fontWeight: "500",
},
});
export default CacheStatusIndicator;
-39
View File
@@ -1,39 +0,0 @@
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>
);
}
-86
View File
@@ -1,86 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, useColorScheme, View } from "react-native";
type IoniconName = keyof typeof Ionicons.glyphMap;
interface EmptyScreenProps {
icon: IoniconName;
headline: string;
description: string;
buttonText?: string;
onButtonPress?: () => void;
className?: string;
}
export function EmptyScreen({
icon,
headline,
description,
buttonText,
onButtonPress,
className,
}: EmptyScreenProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const colors = {
text: isDark ? "#FFFFFF" : "#111827",
textSecondary: isDark ? "#A3A3A3" : "#6B7280",
iconBackground: isDark ? "#2C2C2E" : "#F3F4F6",
icon: isDark ? "#E5E5EA" : "#374151",
buttonBg: isDark ? "#FFFFFF" : "#111827",
buttonText: isDark ? "#000000" : "#FFFFFF",
};
return (
<View
style={{
borderRadius: 8,
padding: 28,
width: "100%",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
className={className}
>
<View
style={{
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.iconBackground,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name={icon} size={40} color={colors.icon} />
</View>
<View className="mt-6 max-w-[420px] flex-col items-center">
<Text style={{ color: colors.text }} className="text-center text-2xl font-semibold">
{headline}
</Text>
<Text
style={{ color: colors.textSecondary }}
className="mb-8 mt-3 text-center text-sm font-normal leading-6"
>
{description}
</Text>
{buttonText && onButtonPress ? (
<TouchableOpacity
style={{ backgroundColor: colors.buttonBg }}
className="flex-row items-center justify-center gap-1 rounded-lg px-4 py-2.5"
onPress={onButtonPress}
>
<Text style={{ color: colors.buttonText }} className="text-base font-medium">
{buttonText}
</Text>
</TouchableOpacity>
) : null}
</View>
</View>
);
}
-10
View File
@@ -1,10 +0,0 @@
import type React from "react";
import type { ModalProps } from "react-native";
export interface FullScreenModalProps extends ModalProps {
visible: boolean;
onRequestClose: () => void;
children: React.ReactNode;
}
export declare function FullScreenModal(props: FullScreenModalProps): JSX.Element;
-66
View File
@@ -1,66 +0,0 @@
import type React from "react";
import { useEffect } from "react";
import { Modal, type ModalProps, Platform } 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>
);
}
-89
View File
@@ -1,89 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { ActivityIndicator, Text, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "./AppPressable";
interface GlassModalHeaderProps {
title: string;
onClose: () => void;
onAction?: () => void;
actionLabel?: string;
actionDisabled?: boolean;
actionLoading?: boolean;
/** Use icon button style (like three-dot menu) instead of text */
actionIcon?: keyof typeof Ionicons.glyphMap;
}
export function GlassModalHeader({
title,
onClose,
onAction,
actionLabel = "Done",
actionDisabled = false,
actionLoading = false,
actionIcon,
}: GlassModalHeaderProps) {
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const iconColor = isDark ? "#fff" : "#000";
const isDisabled = actionDisabled || actionLoading;
return (
<View
style={{
paddingTop: insets.top,
}}
className="bg-white dark:bg-black"
>
<View className="min-h-[44px] flex-row items-center justify-between px-4">
{/* Back Button - matching native iOS style */}
<AppPressable
className="h-11 w-11 items-center justify-center"
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="chevron-back" size={28} color={iconColor} />
</AppPressable>
{/* Title - centered */}
<Text
className="flex-1 text-center text-[17px] font-semibold text-black dark:text-white"
numberOfLines={1}
>
{title}
</Text>
{/* Action Button - matching senior's Stack.Header.Menu style */}
{onAction ? (
<AppPressable
className={`h-11 min-w-[44px] items-center justify-center rounded-full ${
actionIcon ? "bg-[#F2F2F7] dark:bg-[#262626]" : ""
} ${isDisabled ? "opacity-40" : ""}`}
onPress={onAction}
disabled={isDisabled}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={actionIcon ? { width: 44, height: 44 } : undefined}
>
{actionLoading ? (
<ActivityIndicator size="small" color={iconColor} />
) : actionIcon ? (
// Icon button style (like the three-dot menu)
<Ionicons name={actionIcon} size={22} color={iconColor} />
) : (
// Text button style (like "Save", "Done")
<View className="items-center rounded-full bg-black px-4 py-2 dark:bg-white">
<Text className="text-[15px] font-semibold text-white dark:text-black">
{actionLabel}
</Text>
</View>
)}
</AppPressable>
) : (
<View style={{ width: 44 }} />
)}
</View>
</View>
);
}
-483
View File
@@ -1,483 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Image,
Platform,
Text,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CalComAPIService, type UserProfile } from "@/services/calcom";
import { getAvatarUrl } from "@/utils/getAvatarUrl";
import { CalComLogo } from "./CalComLogo";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppPressable } from "@/components/AppPressable";
import type { EventTypeFilters, EventTypeSortOption } from "@/hooks/useEventTypeFilter";
interface FilterOption {
key: string;
label: string;
}
interface EventTypeFilterConfig {
sortBy: EventTypeSortOption;
filters: EventTypeFilters;
onSortChange: (sort: EventTypeSortOption) => void;
onToggleFilter: (filterKey: keyof EventTypeFilters) => void;
onResetFilters: () => void;
activeFilterCount: number;
}
interface HeaderProps {
/** Optional: Filter options for dropdown menu (e.g., booking status filter) */
filterOptions?: FilterOption[];
/** Optional: Currently active filter key */
activeFilter?: string;
/** Optional: Callback when filter changes */
onFilterChange?: (filterKey: string) => void;
/** Optional: Event type filter/sort config for Android */
eventTypeFilterConfig?: EventTypeFilterConfig;
}
export function Header({
filterOptions,
activeFilter,
onFilterChange,
eventTypeFilterConfig,
}: HeaderProps) {
const router = useRouter();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
// iOS system colors for dark mode
const colors = {
text: isDark ? "#FFFFFF" : "#000000",
textSecondary: isDark ? "#A3A3A3" : "#666666",
background: isDark ? "#000000" : "#FFFFFF",
border: isDark ? "#4D4D4D" : "#E5E5EA",
logoColor: isDark ? "#FFFFFF" : "#333333",
};
const fetchUserProfile = useCallback(async () => {
try {
const profile = await CalComAPIService.getUserProfile();
setUserProfile(profile);
setLoading(false);
} catch (err) {
console.error("Failed to fetch user profile");
if (__DEV__) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
console.debug("[Header] fetchUserProfile failed", { message, stack });
}
setLoading(false);
}
}, []);
useEffect(() => {
fetchUserProfile();
}, [fetchUserProfile]);
const handleProfile = () => {
// Navigate to profile sheet on all platforms (Android, Web, Extension)
router.push("/profile-sheet");
};
const activeFilterLabel =
filterOptions?.find((opt) => opt.key === activeFilter)?.label ?? "Filter";
return (
<View
className="flex-row items-center justify-between border-b border-[#E5E5EA] bg-white px-2 dark:border-[#4D4D4D] dark:bg-black md:px-4"
style={{ paddingTop: insets.top + 4, paddingBottom: 4 }}
>
{/* Left: Cal.com Logo */}
<View className="ms-1">
<CalComLogo width={101} height={22} color={colors.logoColor} />
</View>
{/* Right: Filter Dropdown + Profile */}
<View
className="flex-row items-center gap-2"
style={Platform.OS === "web" ? { marginRight: 8 } : {}}
>
{/* Booking status filter dropdown (for bookings page) */}
{filterOptions && filterOptions.length > 0 && onFilterChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="h-10 flex-row items-center justify-center gap-2 rounded-full border border-[#E5E5E5] bg-white px-4 dark:border-[#262626] dark:bg-[#171717]">
<Text className="text-[15px] font-medium text-[#000000] dark:text-white">
{activeFilterLabel}
</Text>
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-44"
align="end"
>
{filterOptions.map((option) => {
const isSelected = activeFilter === option.key;
// Map filter keys to appropriate icons
const getFilterIcon = (key: string) => {
switch (key) {
case "upcoming":
return "calendar-outline";
case "unconfirmed":
return "help-circle-outline";
case "recurring":
return "repeat-outline";
case "past":
return "checkmark-circle-outline";
case "cancelled":
return "close-circle-outline";
default:
return "calendar-outline";
}
};
return (
<DropdownMenuItem key={option.key} onPress={() => onFilterChange(option.key)}>
<View className="flex-row items-center gap-2">
<Ionicons
name={isSelected ? "checkmark-circle" : getFilterIcon(option.key)}
size={16}
color={isSelected ? colors.text : colors.textSecondary}
/>
<Text
className={
isSelected
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
{option.label}
</Text>
</View>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Event type filter/sort menu (for event types page - Android and Web/Extension) */}
{eventTypeFilterConfig && (Platform.OS === "android" || Platform.OS === "web") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppPressable className="relative p-2">
<Ionicons name="options-outline" size={22} color={colors.text} />
{/* Badge for active filters */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<View
className="absolute -top-0.5 -right-0.5 items-center justify-center rounded-full bg-[#000000] dark:bg-white"
style={{ width: 16, height: 16 }}
>
<Text className="text-[10px] font-bold text-white dark:text-black">
{eventTypeFilterConfig.activeFilterCount}
</Text>
</View>
)}
</AppPressable>
</DropdownMenuTrigger>
<DropdownMenuContent
insets={{ top: 60, bottom: 20, left: 12, right: 12 }}
sideOffset={8}
className="w-56"
align="end"
>
{/* Sort by Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<View className="flex-row items-center gap-2">
<Ionicons name="swap-vertical-outline" size={16} color={colors.textSecondary} />
<Text className="text-base dark:text-gray-300">Sort by</Text>
</View>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onSortChange("alphabetical")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "alphabetical"
? "checkmark-circle"
: "text-outline"
}
size={16}
color={
eventTypeFilterConfig.sortBy === "alphabetical"
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "alphabetical"
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Alphabetical
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("newest")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "newest"
? "checkmark-circle"
: "calendar-outline"
}
size={16}
color={
eventTypeFilterConfig.sortBy === "newest"
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "newest"
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Newest First
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onSortChange("duration")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.sortBy === "duration"
? "checkmark-circle"
: "time-outline"
}
size={16}
color={
eventTypeFilterConfig.sortBy === "duration"
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.sortBy === "duration"
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
By Duration
</Text>
</View>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
{/* Filter Label */}
<DropdownMenuLabel>
<View className="flex-row items-center gap-2">
<Ionicons name="filter-outline" size={16} color={colors.textSecondary} />
<Text className="text-sm font-medium text-gray-500 dark:text-gray-400">
Filters
</Text>
</View>
</DropdownMenuLabel>
{/* Filter Toggles (multi-select) */}
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("hiddenOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.hiddenOnly
? "checkmark-circle"
: "eye-off-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.hiddenOnly ? colors.text : colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.hiddenOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Hidden Only
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("paidOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.paidOnly ? "checkmark-circle" : "cash-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.paidOnly ? colors.text : colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.paidOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Paid Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem onPress={() => eventTypeFilterConfig.onToggleFilter("seatedOnly")}>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.seatedOnly
? "checkmark-circle"
: "people-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.seatedOnly ? colors.text : colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.seatedOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Seated Events
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("requiresConfirmationOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "checkmark-circle"
: "checkmark-circle-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.requiresConfirmationOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Requires Confirmation
</Text>
</View>
</DropdownMenuItem>
<DropdownMenuItem
onPress={() => eventTypeFilterConfig.onToggleFilter("recurringOnly")}
>
<View className="flex-row items-center gap-2">
<Ionicons
name={
eventTypeFilterConfig.filters.recurringOnly
? "checkmark-circle"
: "repeat-outline"
}
size={16}
color={
eventTypeFilterConfig.filters.recurringOnly
? colors.text
: colors.textSecondary
}
/>
<Text
className={
eventTypeFilterConfig.filters.recurringOnly
? "text-base font-semibold text-[#000000] dark:text-white"
: "text-base dark:text-gray-300"
}
>
Recurring
</Text>
</View>
</DropdownMenuItem>
{/* Clear All - only show when filters are active */}
{eventTypeFilterConfig.activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onPress={eventTypeFilterConfig.onResetFilters}>
<View className="flex-row items-center gap-2">
<Ionicons name="close-circle-outline" size={16} color="#FF3B30" />
<Text className="text-base text-[#FF3B30]">Clear All Filters</Text>
</View>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Profile Picture */}
<TouchableOpacity onPress={handleProfile} className="p-1">
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : userProfile?.avatarUrl ? (
<Image
source={{ uri: getAvatarUrl(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] dark:bg-[#4D4D4D]"
style={{ width: 32, height: 32 }}
>
<Ionicons name="person-outline" size={20} color={colors.textSecondary} />
</View>
)}
</TouchableOpacity>
</View>
</View>
);
}
@@ -1,19 +0,0 @@
import { Platform, View } from "react-native";
interface HeaderButtonWrapperProps {
children: React.ReactNode;
side: "left" | "right";
}
const WEB_HEADER_INSET = 12;
export function HeaderButtonWrapper({ children, side }: HeaderButtonWrapperProps) {
if (Platform.OS !== "web") {
return <>{children}</>;
}
const style =
side === "left" ? { marginLeft: WEB_HEADER_INSET } : { marginRight: WEB_HEADER_INSET };
return <View style={style}>{children}</View>;
}
@@ -1,53 +0,0 @@
import { useEffect, useRef } from "react";
import { ActionSheetIOS } from "react-native";
import { LANDING_PAGE_OPTIONS, type LandingPage } from "@/hooks/useUserPreferences";
interface LandingPagePickerProps {
visible: boolean;
currentValue: LandingPage;
onSelect: (value: LandingPage) => void;
onClose: () => void;
}
export function LandingPagePicker({
visible,
currentValue: _currentValue,
onSelect,
onClose,
}: LandingPagePickerProps) {
const sheetShownRef = useRef(false);
useEffect(() => {
if (visible && !sheetShownRef.current) {
sheetShownRef.current = true;
const options = [...LANDING_PAGE_OPTIONS.map((opt) => opt.label), "Cancel"];
const cancelButtonIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{
title: "First Page",
message: "Choose which page opens when you launch the app",
options,
cancelButtonIndex,
destructiveButtonIndex: undefined,
},
(buttonIndex) => {
if (buttonIndex !== cancelButtonIndex && buttonIndex < LANDING_PAGE_OPTIONS.length) {
const selectedOption = LANDING_PAGE_OPTIONS[buttonIndex];
if (selectedOption) {
onSelect(selectedOption.value);
}
}
onClose();
}
);
}
if (!visible) {
sheetShownRef.current = false;
}
}, [visible, onSelect, onClose]);
return null;
}
@@ -1,75 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { FullScreenModal } from "./FullScreenModal";
import {
LANDING_PAGE_OPTIONS,
type LandingPage,
type LandingPageOption,
} from "@/hooks/useUserPreferences";
interface LandingPagePickerProps {
visible: boolean;
currentValue: LandingPage;
onSelect: (value: LandingPage) => void;
onClose: () => void;
}
export function LandingPagePicker({
visible,
currentValue,
onSelect,
onClose,
}: LandingPagePickerProps) {
const handleSelect = (option: LandingPageOption) => {
onSelect(option.value);
onClose();
};
return (
<FullScreenModal visible={visible} onRequestClose={onClose}>
<View className="flex-1 items-center justify-center bg-black/50">
<View className="mx-4 min-w-[320px] max-w-[400px] rounded-lg bg-white shadow-lg">
<View className="border-b border-gray-200 px-4 py-3">
<Text className="text-lg font-semibold text-gray-900">First Page</Text>
<Text className="mt-1 text-sm text-gray-500">
Choose which page opens when you launch the app
</Text>
</View>
<View className="py-2">
{LANDING_PAGE_OPTIONS.map((option) => {
const isSelected = currentValue === option.value;
return (
<TouchableOpacity
key={option.value}
onPress={() => handleSelect(option)}
className="flex-row items-center justify-between px-4 py-3 active:bg-gray-100"
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<Text
className={`text-base ${isSelected ? "font-semibold text-gray-900" : "text-gray-700"}`}
>
{option.label}
</Text>
{isSelected && <Ionicons name="checkmark" size={20} color="#111827" />}
</TouchableOpacity>
);
})}
</View>
<View className="border-t border-gray-200 px-4 py-3">
<TouchableOpacity
onPress={onClose}
className="items-center rounded-md border border-gray-300 bg-white px-4 py-2"
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<Text className="font-medium text-gray-700">Cancel</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
);
}
-64
View File
@@ -1,64 +0,0 @@
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { ActivityIndicator, Platform, StyleSheet, View } from "react-native";
interface LoadingSpinnerProps {
size?: "small" | "large";
color?: string;
showBackground?: boolean;
}
export function LoadingSpinner({
size = "large",
color,
showBackground = true,
}: LoadingSpinnerProps) {
const supportsGlass = isLiquidGlassAvailable();
if (supportsGlass && Platform.OS === "ios") {
return (
<GlassView style={styles.glassContainer} glassEffectStyle="regular">
<ActivityIndicator size={size} color={color || "#000000"} />
</GlassView>
);
}
if (showBackground) {
return (
<View style={styles.styledContainer}>
<ActivityIndicator size={size} color={color || "#000000"} />
</View>
);
}
return <ActivityIndicator size={size} color={color || "#000000"} />;
}
const styles = StyleSheet.create({
glassContainer: {
alignItems: "center",
justifyContent: "center",
padding: 24,
borderRadius: 20,
},
styledContainer: {
alignItems: "center",
justifyContent: "center",
padding: 24,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.9)",
...Platform.select({
web: {
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
},
default: {
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
}),
},
});
export default LoadingSpinner;
-343
View File
@@ -1,343 +0,0 @@
/**
* LocationsList Component
* Reusable component for displaying and managing multiple event type locations
*/
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useState } from "react";
import {
Modal,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
useColorScheme,
View,
} from "react-native";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
import {
createLocationItemFromOption,
getLocationInputLabel,
getLocationInputPlaceholder,
locationRequiresInput,
} from "@/utils/locationHelpers";
import { LocationsListIOSPicker } from "./LocationsListIOSPicker";
import { SvgImage } from "./SvgImage";
interface LocationsListProps {
/** Array of current locations */
locations: LocationItem[];
/** Callback when a location is added */
onAdd: (location: LocationItem) => void;
/** Callback when a location is removed */
onRemove: (locationId: string) => void;
/** Callback when a location is updated (for input fields) */
onUpdate: (locationId: string, updates: Partial<LocationItem>) => void;
/** Available location options grouped by category */
locationOptions: LocationOptionGroup[];
/** Whether the component is disabled */
disabled?: boolean;
/** Whether locations are loading */
loading?: boolean;
/** Whether to hide the internal add button */
hideAddButton?: boolean;
/** External control for the add modal */
showAddModal?: boolean;
/** Callback to change modal visibility */
onShowAddModalChange?: (visible: boolean) => void;
}
function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string): boolean {
return locations.some((loc) => {
if (loc.type === "integration" && optionValue.startsWith("integrations:")) {
return loc.integration === optionValue.replace("integrations:", "");
}
if (["address", "link", "phone"].includes(loc.type)) {
return false;
}
return loc.type === optionValue;
});
}
export const AddLocationTrigger = ({
isHeader = false,
locationOptions,
locations,
onSelectOption,
disabled = false,
loading = false,
onPressFallback,
}: {
isHeader?: boolean;
locationOptions: LocationOptionGroup[];
locations: LocationItem[];
onSelectOption: (value: string, label: string) => void;
disabled?: boolean;
loading?: boolean;
onPressFallback?: () => void;
}) => {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const trigger = isHeader ? (
<TouchableOpacity
disabled={disabled || loading}
onPress={Platform.OS !== "ios" ? onPressFallback : undefined}
hitSlop={{ top: 15, bottom: 15, left: 15, right: 15 }}
className="flex-row items-center rounded-full border border-[#E5E5E5] bg-[#F2F2F7] p-1 dark:border-[#4D4D4D] dark:bg-[#262626]"
>
<Ionicons name="add" size={18} color={isDark ? "#FFFFFF" : "#000000"} />
</TouchableOpacity>
) : (
<TouchableOpacity
disabled={disabled || loading}
onPress={Platform.OS !== "ios" ? onPressFallback : undefined}
className={`flex-row items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-[#4D4D4D] dark:bg-[#262626] ${
disabled ? "opacity-50" : "active:bg-gray-100 dark:active:bg-[#171717]"
}`}
>
{loading ? (
<Text className="text-sm text-gray-500 dark:text-[#A3A3A3]">Loading options...</Text>
) : (
<>
<Ionicons name="add-circle-outline" size={20} color="#6B7280" />
<Text className="ml-2 text-sm font-medium text-gray-600 dark:text-[#A3A3A3]">
Add Location
</Text>
</>
)}
</TouchableOpacity>
);
if (Platform.OS !== "ios") {
return trigger;
}
const iosPicker = (
<LocationsListIOSPicker
isHeader={isHeader}
locationOptions={locationOptions}
locations={locations}
onSelectOption={onSelectOption}
trigger={trigger}
/>
);
return iosPicker ?? trigger;
};
export const LocationsList: React.FC<LocationsListProps> = ({
locations,
onAdd,
onRemove,
onUpdate,
locationOptions,
disabled = false,
loading = false,
hideAddButton = false,
showAddModal: controlledShowAddModal,
onShowAddModalChange,
}) => {
const [internalShowAddModal, setInternalShowAddModal] = useState(false);
const showAddModal = controlledShowAddModal ?? internalShowAddModal;
const setShowAddModal = onShowAddModalChange ?? setInternalShowAddModal;
const handleAddLocation = () => {
setShowAddModal(true);
};
const handleSelectOption = (optionValue: string, optionLabel: string) => {
const newLocation = createLocationItemFromOption(optionValue, optionLabel);
onAdd(newLocation);
setShowAddModal(false);
};
const renderLocationIcon = (location: LocationItem) => {
if (location.iconUrl) {
return (
<View className="mr-3 h-8 w-8 items-center justify-center rounded-lg bg-[#F2F2F7] dark:bg-[#262626]">
<SvgImage uri={location.iconUrl} width={20} height={20} />
</View>
);
}
return (
<View className="mr-3 h-8 w-8 items-center justify-center rounded-lg bg-[#F2F2F7] dark:bg-[#262626]">
<Ionicons name="location" size={18} color="#000000" />
</View>
);
};
const renderLocationInput = (location: LocationItem) => {
if (!locationRequiresInput(location.type)) {
return null;
}
const placeholder = getLocationInputPlaceholder(location.type);
const label = getLocationInputLabel(location.type);
let value = "";
let fieldKey: "address" | "link" | "phone" = "address";
switch (location.type) {
case "address":
value = location.address || "";
fieldKey = "address";
break;
case "link":
value = location.link || "";
fieldKey = "link";
break;
case "phone":
value = location.phone || "";
fieldKey = "phone";
break;
}
return (
<View className="mt-2">
<Text className="mb-1 text-[13px] text-[#6D6D72] dark:text-[#A3A3A3]">{label}</Text>
<TextInput
className="rounded-lg bg-[#F2F2F7] px-3 py-2 text-[17px] text-black dark:bg-[#262626] dark:text-white"
placeholder={placeholder}
value={value}
onChangeText={(text) => onUpdate(location.id, { [fieldKey]: text })}
editable={!disabled}
keyboardType={location.type === "phone" ? "phone-pad" : "default"}
autoCapitalize={location.type === "link" ? "none" : "sentences"}
/>
</View>
);
};
return (
<View className="bg-white dark:bg-[#171717]">
{/* Locations List */}
{locations.length > 0 ? (
<View className="mb-2">
{locations.map((location, index) => (
<View
key={location.id}
className={`pl-4 ${
index !== locations.length - 1
? "border-b border-[#E5E5E5] dark:border-[#4D4D4D]"
: ""
}`}
>
<View
className={`pr-4 ${
index === 0 ? "pt-4 pb-3" : index === locations.length - 1 ? "pt-3 pb-4" : "py-3"
}`}
>
<View className="flex-row items-center justify-between">
<View className="flex-1 flex-row items-center">
{renderLocationIcon(location)}
<Text
className="flex-1 text-[17px] text-black font-normal dark:text-white"
numberOfLines={1}
>
{location.displayName}
</Text>
</View>
{!disabled ? (
<TouchableOpacity
onPress={() => onRemove(location.id)}
className="ml-2"
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close-circle" size={20} color="#C7C7CC" />
</TouchableOpacity>
) : null}
</View>
{renderLocationInput(location)}
</View>
</View>
))}
</View>
) : null}
{/* Add Location Button */}
{!hideAddButton && (
<View className="pb-2 pt-1 items-center justify-center">
<AddLocationTrigger
locationOptions={locationOptions}
locations={locations}
onSelectOption={handleSelectOption}
onPressFallback={handleAddLocation}
disabled={disabled}
loading={loading}
/>
</View>
)}
{/* Add Location Modal (for non-iOS) */}
<Modal
visible={showAddModal}
transparent
animationType="slide"
onRequestClose={() => setShowAddModal(false)}
>
<View className="flex-1 justify-end bg-black/50">
<View className="max-h-[70%] rounded-t-3xl bg-white dark:bg-[#171717]">
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-[#4D4D4D]">
<Text className="text-lg font-semibold text-gray-900 dark:text-white">
Add Location
</Text>
<TouchableOpacity onPress={() => setShowAddModal(false)} className="p-1">
<Ionicons name="close" size={24} color="#6B7280" />
</TouchableOpacity>
</View>
{/* Options List */}
<ScrollView className="px-4 py-2">
{locationOptions.map((group, groupIndex) => (
<View key={group.category} className={groupIndex > 0 ? "mt-4" : ""}>
<Text className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-[#A3A3A3]">
{group.category}
</Text>
{group.options.map((option) => {
const alreadyAdded = isLocationAlreadyAdded(locations, option.value);
return (
<TouchableOpacity
key={option.value}
onPress={() => handleSelectOption(option.value, option.label)}
disabled={alreadyAdded}
className={`flex-row items-center rounded-lg px-2 py-3 ${
alreadyAdded
? "opacity-40"
: "active:bg-gray-100 dark:active:bg-[#262626]"
}`}
>
{option.iconUrl ? (
<SvgImage
uri={option.iconUrl}
width={24}
height={24}
style={{ marginRight: 12 }}
/>
) : (
<View style={{ marginRight: 12 }}>
<Ionicons name="location-outline" size={24} color="#6B7280" />
</View>
)}
<Text className="flex-1 text-base text-gray-900 dark:text-white">
{option.label}
</Text>
{alreadyAdded && <Ionicons name="checkmark" size={20} color="#10B981" />}
</TouchableOpacity>
);
})}
</View>
))}
{/* Bottom padding for safe area */}
<View className="h-8" />
</ScrollView>
</View>
</View>
</Modal>
</View>
);
};
@@ -1,71 +0,0 @@
import { Button, ContextMenu, Host } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import type React from "react";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
const getSFSymbolForType = (type: string) => {
switch (type) {
case "address":
return "mappin.and.ellipse";
case "link":
return "link";
case "phone":
return "phone";
default:
return "video";
}
};
function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string): boolean {
return locations.some((loc) => {
if (loc.type === "integration" && optionValue.startsWith("integrations:")) {
return loc.integration === optionValue.replace("integrations:", "");
}
if (["address", "link", "phone"].includes(loc.type)) {
return false;
}
return loc.type === optionValue;
});
}
interface LocationsListIOSPickerProps {
isHeader?: boolean;
locationOptions: LocationOptionGroup[];
locations: LocationItem[];
onSelectOption: (value: string, label: string) => void;
trigger: React.ReactNode;
}
export function LocationsListIOSPicker({
isHeader = false,
locationOptions,
locations,
onSelectOption,
trigger,
}: LocationsListIOSPickerProps) {
return (
<Host matchContents>
<ContextMenu
modifiers={!isHeader ? [buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")] : []}
activationMethod="singlePress"
>
<ContextMenu.Items>
{locationOptions.flatMap((group) =>
group.options
.filter((opt) => !isLocationAlreadyAdded(locations, opt.value))
.map((option) => (
<Button
key={option.value}
onPress={() => onSelectOption(option.value, option.label)}
label={option.label}
systemImage={getSFSymbolForType(option.value)}
/>
))
)}
</ContextMenu.Items>
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
}
@@ -1,14 +0,0 @@
import type React from "react";
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
interface LocationsListIOSPickerProps {
isHeader?: boolean;
locationOptions: LocationOptionGroup[];
locations: LocationItem[];
onSelectOption: (value: string, label: string) => void;
trigger: React.ReactNode;
}
export function LocationsListIOSPicker(_props: LocationsListIOSPickerProps) {
return null;
}
-93
View File
@@ -1,93 +0,0 @@
import { Platform, Text, TouchableOpacity, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAuth } from "@/contexts/AuthContext";
import { showErrorAlert } from "@/utils/alerts";
import { openInAppBrowser } from "@/utils/browser";
import { CalComLogo } from "./CalComLogo";
export function LoginScreen() {
const { loginWithOAuth, loading } = useAuth();
const insets = useSafeAreaInsets();
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const handleOAuthLogin = async () => {
try {
await loginWithOAuth();
} catch (error) {
console.error("OAuth login error");
showErrorAlert(
"Login Failed",
error instanceof Error
? error.message
: "Failed to login with OAuth. Please check your configuration and try again."
);
}
};
const handleSignUp = async () => {
await openInAppBrowser("https://app.cal.com/signup", "Sign up page");
};
return (
<View className="flex-1 bg-white dark:bg-black">
{/* Logo centered in the middle */}
<View className="flex-1 items-center justify-center">
<CalComLogo width={180} height={40} color={isDark ? "#FFFFFF" : "#111827"} />
</View>
{/* Bottom section with button */}
<View className="px-6" style={{ paddingBottom: insets.bottom + 28 }}>
{/* Primary CTA button */}
<TouchableOpacity
onPress={handleOAuthLogin}
disabled={loading}
className="flex-row items-center justify-center rounded-2xl py-[18px]"
style={[
{ backgroundColor: loading ? "#9CA3AF" : isDark ? "#FFFFFF" : "#000000" },
Platform.select({
web: {
boxShadow: loading ? "none" : "0 4px 12px rgba(0, 0, 0, 0.2)",
},
default: {
shadowColor: isDark ? "#FFF" : "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: loading ? 0 : 0.2,
shadowRadius: 12,
elevation: loading ? 0 : 6,
},
}),
]}
activeOpacity={0.9}
>
<Text
className="text-[17px] font-semibold"
style={{ color: isDark ? "#000000" : "#FFFFFF" }}
>
Continue with Cal.com
</Text>
</TouchableOpacity>
{/* Sign up link - hidden on iOS */}
{Platform.OS !== "ios" && (
<TouchableOpacity
onPress={handleSignUp}
className="mt-3 items-center justify-center py-1"
style={Platform.OS === "web" ? { cursor: "pointer" } : undefined}
activeOpacity={0.7}
>
<View>
<Text className="text-[15px] text-gray-500 dark:text-[#A3A3A3]">
Don't have an account?{" "}
<Text className="font-semibold text-gray-900 dark:text-white">Sign up</Text>
</Text>
<View className="h-px bg-gray-400 dark:bg-[#4D4D4D]" style={{ marginTop: 2 }} />
</View>
</TouchableOpacity>
)}
</View>
</View>
);
}
export default LoginScreen;
-78
View File
@@ -1,78 +0,0 @@
import { useState } from "react";
import { Alert, Platform, Text, TouchableOpacity } from "react-native";
import { useAuth } from "@/contexts/AuthContext";
import { showErrorAlert } from "@/utils/alerts";
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) {
const message = error instanceof Error ? error.message : String(error);
console.error("Logout error", message);
if (__DEV__) {
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[LogoutButton] logout failed", { message, stack });
}
showErrorAlert("Error", "Failed to sign out. Please try again.");
}
};
const handleConfirmLogout = () => {
setShowConfirmModal(false);
performLogout();
};
const handleCancelLogout = () => {
setShowConfirmModal(false);
};
return (
<>
<TouchableOpacity
onPress={handleLogoutPress}
className={`rounded-lg bg-gray-600 px-4 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;
@@ -1,43 +0,0 @@
import { useEffect, useRef } from "react";
import { Alert } from "react-native";
interface LogoutConfirmModalProps {
visible: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function LogoutConfirmModal({ visible, onConfirm, onCancel }: LogoutConfirmModalProps) {
// Track if alert has been shown for current visible=true state to prevent re-triggering
// when parent passes unstable callback references
const alertShownRef = useRef(false);
useEffect(() => {
if (visible && !alertShownRef.current) {
alertShownRef.current = true;
Alert.alert(
"Sign Out",
"Are you sure you want to sign out?",
[
{
text: "Cancel",
style: "cancel",
onPress: onCancel,
},
{
text: "Sign Out",
style: "destructive",
onPress: onConfirm,
},
],
{ cancelable: true, onDismiss: onCancel }
);
}
if (!visible) {
alertShownRef.current = false;
}
}, [visible, onConfirm, onCancel]);
return null;
}
@@ -1,71 +0,0 @@
import { Platform, Text, TouchableOpacity, useColorScheme, View } from "react-native";
import { FullScreenModal } from "./FullScreenModal";
interface LogoutConfirmModalProps {
visible: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function LogoutConfirmModal({ visible, onConfirm, onCancel }: LogoutConfirmModalProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const colors = {
background: isDark ? "#171717" : "#FFFFFF",
text: isDark ? "#FFFFFF" : "#111827",
textSecondary: isDark ? "#A3A3A3" : "#4B5563",
border: isDark ? "#4D4D4D" : "#1F2937",
cancelBg: isDark ? "#2C2C2E" : "#FFFFFF",
};
return (
<FullScreenModal visible={visible} onRequestClose={onCancel}>
<View className="flex-1 items-center justify-center bg-black bg-opacity-50">
<View
style={{
backgroundColor: colors.background,
minWidth: 300,
borderRadius: 12,
padding: 24,
}}
className="mx-4 shadow-lg"
>
<Text style={{ color: colors.text }} className="mb-2 text-lg font-semibold">
Sign Out
</Text>
<Text style={{ color: colors.textSecondary }} className="mb-6">
Are you sure you want to sign out?
</Text>
<View className="flex-row justify-end space-x-3">
<TouchableOpacity
onPress={onCancel}
style={{
backgroundColor: colors.cancelBg,
borderColor: colors.border,
borderWidth: 1,
...(Platform.OS === "web" ? { cursor: "pointer" as const } : {}),
}}
className="rounded-md px-4 py-2"
activeOpacity={0.7}
>
<Text style={{ color: colors.text }} className="font-medium">
Cancel
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onConfirm}
style={Platform.OS === "web" ? { cursor: "pointer" as const } : undefined}
className="rounded-md border border-red-800 bg-white px-4 py-2 dark:bg-red-900/30"
activeOpacity={0.7}
>
<Text className="font-medium text-red-800">Sign Out</Text>
</TouchableOpacity>
</View>
</View>
</View>
</FullScreenModal>
);
}
@@ -1,112 +0,0 @@
/**
* NetworkStatusBanner Component
*
* Shows a minimal, classy popup when the device is offline.
* - Shows popup when going offline
* - Auto-dismisses when internet comes back
* - Shows again on next disconnect
*/
import { Ionicons } from "@expo/vector-icons";
import NetInfo, { type NetInfoState } from "@react-native-community/netinfo";
import { useCallback, useEffect, useRef, useState } from "react";
import { Animated, Modal, Text, TouchableOpacity, View } from "react-native";
export function NetworkStatusBanner() {
// eslint-disable-next-line react-compiler/react-compiler -- Animated.Value ref access in JSX is incompatible with React Compiler
"use no memo";
const [showModal, setShowModal] = useState(false);
const fadeAnimRef = useRef(new Animated.Value(0));
// Simple refs to track state
const previousOfflineRef = useRef<boolean | null>(null);
const userDismissedRef = useRef(false);
const checkIfOffline = useCallback((state: NetInfoState): boolean => {
if (state.isConnected === false) return true;
if (state.isInternetReachable === false) return true;
return false;
}, []);
useEffect(() => {
const handleNetworkChange = (state: NetInfoState) => {
const currentlyOffline = checkIfOffline(state);
const wasOffline = previousOfflineRef.current;
// Transition: Online → Offline (only show if user hasn't dismissed)
if (currentlyOffline && wasOffline === false && !userDismissedRef.current) {
setShowModal(true);
}
// Transition: Offline → Online
if (!currentlyOffline && wasOffline === true) {
setShowModal(false); // Auto-dismiss
userDismissedRef.current = false; // Reset for next offline event
}
previousOfflineRef.current = currentlyOffline;
};
// Get initial state
NetInfo.fetch().then((state) => {
const offline = checkIfOffline(state);
previousOfflineRef.current = offline;
if (offline) {
setShowModal(true);
}
});
// Listen for changes
const unsubscribe = NetInfo.addEventListener(handleNetworkChange);
return () => unsubscribe();
}, [checkIfOffline]);
useEffect(() => {
if (showModal) {
fadeAnimRef.current.setValue(0);
Animated.timing(fadeAnimRef.current, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}).start();
}
}, [showModal]);
const handleDismiss = () => {
userDismissedRef.current = true;
Animated.timing(fadeAnimRef.current, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}).start(() => {
setShowModal(false);
});
};
if (!showModal) return null;
return (
<Modal transparent visible={showModal} animationType="none" onRequestClose={handleDismiss}>
<Animated.View
style={{ opacity: fadeAnimRef.current }}
className="flex-1 items-center justify-center bg-black/40 px-8"
>
<View className="w-full max-w-xs items-center rounded-2xl bg-white px-6 py-8">
<Ionicons name="cloud-offline-outline" size={36} color="#292929" />
<Text className="mt-4 text-lg font-semibold text-gray-900">You're offline</Text>
<Text className="mt-2 text-center text-sm leading-5 text-gray-500">
The internet left the chat. Don't panic! You can still browse around.
</Text>
<TouchableOpacity
onPress={handleDismiss}
className="mt-6 rounded-full bg-gray-900 px-8 py-3 active:bg-gray-700"
>
<Text className="text-sm font-medium text-white">Cool, got it</Text>
</TouchableOpacity>
</View>
</Animated.View>
</Modal>
);
}
export default NetworkStatusBanner;
-132
View File
@@ -1,132 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { ReactNode } from "react";
import { Text, TouchableOpacity, useColorScheme, View } from "react-native";
import { EmptyScreen } from "./EmptyScreen";
import { Header } from "./Header";
import { LoadingSpinner } from "./LoadingSpinner";
import { getColors } from "@/constants/colors";
type IoniconName = keyof typeof Ionicons.glyphMap;
interface EmptyStateConfig {
icon: IoniconName;
headline: string;
description: string;
buttonText?: string;
onButtonPress?: () => void;
}
interface ScreenWrapperProps {
/** Whether data is currently loading */
loading?: boolean;
/** Error message to display, if any */
error?: string | null;
/** Callback for retry button in error state */
onRetry?: () => void;
/** Custom title for error state (default: "Unable to load data") */
errorTitle?: string;
/** Whether to show empty state */
isEmpty?: boolean;
/** Configuration for empty state display */
emptyProps?: EmptyStateConfig;
/** Whether to show the header (default: true) */
showHeader?: boolean;
/** Content to render when not in loading/error/empty state */
children: ReactNode;
}
/**
* Wrapper component that handles common screen states: loading, error, and empty.
* Renders the appropriate UI based on the current state.
*
* @example
* ```tsx
* <ScreenWrapper
* loading={isLoading}
* error={error}
* onRetry={refetch}
* errorTitle="Unable to load event types"
* isEmpty={eventTypes.length === 0}
* emptyProps={{
* icon: "calendar-outline",
* headline: "No event types",
* description: "Create your first event type to get started.",
* buttonText: "New",
* onButtonPress: handleCreate,
* }}
* >
* <EventTypesList data={eventTypes} />
* </ScreenWrapper>
* ```
*/
export function ScreenWrapper({
loading,
error,
onRetry,
errorTitle = "Unable to load data",
isEmpty,
emptyProps,
showHeader = true,
children,
}: ScreenWrapperProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
const errorIconColor = theme.destructive;
if (loading) {
return (
<View className="flex-1 bg-gray-100 dark:bg-black">
{showHeader && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 p-5 dark:bg-[#171717]">
<LoadingSpinner size="large" />
</View>
</View>
);
}
if (error) {
return (
<View className="flex-1 bg-gray-100 dark:bg-black">
{showHeader && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 p-5 dark:bg-[#171717]">
<Ionicons name="alert-circle" size={64} color={errorIconColor} />
<Text className="mb-2 mt-4 text-center text-xl font-bold text-gray-800 dark:text-gray-100">
{errorTitle}
</Text>
<Text className="mb-6 text-center text-base text-gray-500 dark:text-gray-400">
{error}
</Text>
{onRetry && (
<TouchableOpacity
className="rounded-lg bg-black px-6 py-3 dark:bg-white"
onPress={onRetry}
>
<Text className="text-base font-semibold text-white dark:text-black">Retry</Text>
</TouchableOpacity>
)}
</View>
</View>
);
}
if (isEmpty && emptyProps) {
return (
<View className="flex-1 bg-gray-100 dark:bg-black">
{showHeader && <Header />}
<View className="flex-1 items-center justify-center bg-gray-50 p-5 dark:bg-[#171717]">
<EmptyScreen {...emptyProps} />
</View>
</View>
);
}
return (
<View className="flex-1 bg-gray-100 dark:bg-black">
{showHeader && <Header />}
{children}
</View>
);
}
export type { EmptyStateConfig, ScreenWrapperProps };
-63
View File
@@ -1,63 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TextInput, TouchableOpacity, useColorScheme, View } from "react-native";
interface SearchHeaderProps {
/** Current search query value */
searchQuery: string;
/** Callback when search query changes */
onSearchChange: (query: string) => void;
/** Placeholder text for search input (default: "Search") */
placeholder?: string;
/** Callback when New button is pressed */
onNewPress: () => void;
/** Text for the New button (default: "New") */
newButtonText?: string;
}
/**
* Reusable search header component with a search input and "New" button.
* Used consistently across Android list screens.
*
* @example
* ```tsx
* <SearchHeader
* searchQuery={searchQuery}
* onSearchChange={setSearchQuery}
* placeholder="Search event types"
* onNewPress={handleCreateNew}
* />
* ```
*/
export function SearchHeader({
searchQuery,
onSearchChange,
placeholder = "Search",
onNewPress,
newButtonText = "New",
}: SearchHeaderProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return (
<View className="flex-row items-center gap-3 border-b border-gray-300 bg-gray-100 px-4 py-2 dark:border-[#4D4D4D] dark:bg-black">
<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 dark:border-[#4D4D4D] dark:bg-[#171717] dark:text-white"
placeholder={placeholder}
placeholderTextColor="#9CA3AF"
value={searchQuery}
onChangeText={onSearchChange}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
className="min-w-[60px] flex-row items-center justify-center gap-1 rounded-lg bg-black px-2.5 py-2 dark:bg-white"
onPress={onNewPress}
>
<Ionicons name="add" size={18} color={isDark ? "#000" : "#fff"} />
<Text className="text-base font-semibold text-white dark:text-black">{newButtonText}</Text>
</TouchableOpacity>
</View>
);
}
export type { SearchHeaderProps };
-56
View File
@@ -1,56 +0,0 @@
import { Image } from "expo-image";
import type React from "react";
import { useState } from "react";
import { View } 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 expo-image for non-SVG or if SVG failed (better performance with caching)
return (
<View style={style}>
<Image
source={{ uri }}
style={{ width, height }}
contentFit="contain"
transition={200}
onError={() => {
// Image load errors are expected for invalid/missing URLs - silently handled
}}
/>
</View>
);
}
// Use SvgUri for SVG files
return (
<View style={[{ width, height }, style]}>
<SvgUri
uri={uri}
width={width}
height={height}
onError={() => {
// SVG load errors trigger fallback to regular Image component
setUseFallback(true);
}}
/>
</View>
);
};
-10
View File
@@ -1,10 +0,0 @@
interface TooltipProps {
text: string;
children: React.ReactElement;
}
export function Tooltip({ text, children }: TooltipProps) {
// Native Tooltip is a no-op; web implementation lives in Tooltip.web.tsx
void text;
return children;
}
-53
View File
@@ -1,53 +0,0 @@
import type React from "react";
import { useState } from "react";
interface TooltipProps {
text: string;
children: React.ReactElement;
}
export function Tooltip({ text, children }: TooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
const handleMouseEnter = () => {
setShowTooltip(true);
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Tooltip wrapper only shows/hides content on hover, not a clickable element
<span
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{ position: "relative", display: "inline-flex" }}
>
{children}
{showTooltip ? (
<span
role="tooltip"
style={{
position: "absolute",
left: "50%",
bottom: "100%",
transform: "translate(-50%, -8px)",
backgroundColor: "#1a1a1a",
padding: "6px 10px",
borderRadius: 6,
zIndex: 10002,
pointerEvents: "none",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
color: "white",
fontSize: 13,
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{text}
</span>
) : null}
</span>
);
}
@@ -1,6 +0,0 @@
import { useWidgetSync } from "@/hooks/useWidgetSync";
export function WidgetSyncProvider({ children }: { children: React.ReactNode }) {
useWidgetSync();
return <>{children}</>;
}
@@ -1,135 +0,0 @@
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
import { isLiquidGlassAvailable } from "expo-glass-effect";
import { Pressable, useColorScheme, View } from "react-native";
import type { SFSymbols7_0 } from "sf-symbols-typescript";
import type { AvailabilityListItemProps } from "./AvailabilityListItem";
import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
export const AvailabilityListItem = ({
item: schedule,
handleSchedulePress,
onDuplicate,
onDelete,
onSetAsDefault,
}: AvailabilityListItemProps) => {
const scheduleActions: {
label: string;
icon: SFSymbols7_0;
onPress: () => void;
role: "default" | "destructive";
}[] = [
...(!schedule.isDefault && onSetAsDefault
? [
{
label: "Set as Default",
icon: "star" as const,
onPress: () => onSetAsDefault(schedule),
role: "default" as const,
},
]
: []),
{
label: "Duplicate",
icon: "square.on.square" as const,
onPress: () => onDuplicate?.(schedule),
role: "default" as const,
},
{
label: "Delete",
icon: "trash" as const,
onPress: () => onDelete?.(schedule),
role: "destructive" as const,
},
];
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return (
<View
style={{
backgroundColor: isDark ? "#000000" : "#FFFFFF",
borderBottomWidth: 1,
borderBottomColor: isDark ? "#4D4D4D" : "#E5E5EA",
}}
>
{/* Native iOS Context Menu for long-press */}
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="longPress"
>
<ContextMenu.Items>
{scheduleActions.map((scheduleAction) => (
<Button
key={scheduleAction.label}
systemImage={scheduleAction.icon}
onPress={scheduleAction.onPress}
role={scheduleAction.role}
label={scheduleAction.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<View
className="flex-row items-center"
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<Pressable
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
accessibilityRole="button"
style={{ minWidth: 0 }}
>
<View style={{ flex: 1 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots
availability={schedule.availability}
scheduleId={schedule.id}
/>
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
</Pressable>
{/* Three dots menu - fixed hit target so it doesn't get squeezed off-screen */}
<View
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32, flexShrink: 0 }}
>
<Host matchContents>
<ContextMenu
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{scheduleActions.map((scheduleAction) => (
<Button
key={scheduleAction.label}
systemImage={scheduleAction.icon}
onPress={scheduleAction.onPress}
role={scheduleAction.role}
label={scheduleAction.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="ellipsis"
color="primary"
size={24}
modifiers={[frame({ height: 24, width: 17 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</View>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
);
};
@@ -1,173 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Pressable, useColorScheme, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Text } from "@/components/ui/text";
import type { Schedule } from "@/hooks";
import { AvailabilitySlots, ScheduleName, TimeZoneRow } from "./AvailabilityListItemParts";
import { getColors } from "@/constants/colors";
export interface AvailabilityListItemProps {
item: Schedule;
index: number;
handleSchedulePress: (schedule: Schedule) => void;
onDuplicate?: (schedule: Schedule) => void;
onDelete?: (schedule: Schedule) => void;
onSetAsDefault?: (schedule: Schedule) => void;
}
export const AvailabilityListItem = ({
item: schedule,
handleSchedulePress,
onDuplicate,
onDelete,
onSetAsDefault,
}: AvailabilityListItemProps) => {
const insets = useSafeAreaInsets();
const contentInsets = {
top: insets.top,
bottom: insets.bottom,
left: 12,
right: 12,
};
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
type DropdownAction = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
variant?: "default" | "destructive";
};
const scheduleActions: DropdownAction[] = [
...(!schedule.isDefault && onSetAsDefault
? [
{
label: "Set as Default",
icon: "star-outline" as const,
onPress: () => onSetAsDefault(schedule),
variant: "default" as const,
},
]
: []),
...(onDuplicate
? [
{
label: "Duplicate",
icon: "copy-outline" as const,
onPress: () => onDuplicate(schedule),
variant: "default" as const,
},
]
: []),
...(onDelete
? [
{
label: "Delete",
icon: "trash-outline" as const,
onPress: () => onDelete(schedule),
variant: "destructive" as const,
},
]
: []),
];
const destructiveStartIndex = scheduleActions.findIndex(
(action) => action.variant === "destructive"
);
return (
<View
style={{
backgroundColor: isDark ? "#000000" : "#FFFFFF",
borderBottomWidth: 1,
borderBottomColor: isDark ? "#4D4D4D" : "#E5E5EA",
}}
>
<View
className="flex-row items-center"
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<Pressable
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
style={{ minWidth: 0 }}
>
<View style={{ flex: 1 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
</Pressable>
{scheduleActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Pressable
style={{
width: 32,
height: 32,
flexShrink: 0,
borderWidth: 1,
borderColor: isDark ? "#4D4D4D" : "#E5E5EA",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons
name="ellipsis-horizontal"
size={18}
color={isDark ? "#E5E5EA" : "#3C3F44"}
/>
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent insets={contentInsets} sideOffset={8} className="w-44" align="end">
{scheduleActions.map((action, index) => (
<React.Fragment key={action.label}>
{index === destructiveStartIndex && destructiveStartIndex > 0 && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem variant={action.variant} onPress={action.onPress}>
<Ionicons
name={action.icon}
size={18}
color={
action.variant === "destructive"
? theme.destructive
: isDark
? "#E5E5EA"
: "#374151"
}
style={{ marginRight: 8 }}
/>
<Text
style={
action.variant === "destructive" ? { color: theme.destructive } : undefined
}
>
{action.label}
</Text>
</DropdownMenuItem>
</React.Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</View>
</View>
);
};
@@ -1,138 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, useColorScheme, View } from "react-native";
import type { Schedule } from "@/hooks";
// 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;
return `${hour12}:${min} ${period}`;
};
interface ScheduleNameProps {
name: string;
isDefault?: boolean;
}
export function ScheduleName({ name, isDefault }: ScheduleNameProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return (
<View className="mb-1 flex-row flex-wrap items-center">
<Text style={{ color: isDark ? "#FFFFFF" : "#333333" }} className="text-base font-semibold">
{name}
</Text>
{isDefault && (
<View
className="ml-2 rounded px-2 py-0.5"
style={{ backgroundColor: isDark ? "#A3A3A3" : "#666666" }}
>
<Text className="text-xs font-semibold text-white">Default</Text>
</View>
)}
</View>
);
}
interface AvailabilitySlotsProps {
availability?: Schedule["availability"];
scheduleId: number;
}
export function AvailabilitySlots({ availability, scheduleId }: AvailabilitySlotsProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const textColor = isDark ? "#A3A3A3" : "#666666";
if (!availability || availability.length === 0) {
return (
<Text style={{ color: textColor }} className="text-sm">
No availability set
</Text>
);
}
const MAX_VISIBLE_SLOTS = 2;
const visibleSlots = availability.slice(0, MAX_VISIBLE_SLOTS);
const remainingCount = availability.length - MAX_VISIBLE_SLOTS;
return (
<View>
{visibleSlots.map((slot, slotIndex) => (
<View
key={`${scheduleId}-${slot.days.join("-")}-${slotIndex}`}
className={slotIndex > 0 ? "mt-1" : ""}
>
<Text style={{ color: textColor }} className="text-sm" numberOfLines={1}>
{slot.days.join(", ")} {formatTime12Hour(slot.startTime)} -{" "}
{formatTime12Hour(slot.endTime)}
</Text>
</View>
))}
{remainingCount > 0 && (
<Text style={{ color: textColor }} className="mt-1 text-sm">
+{remainingCount} more {remainingCount === 1 ? "slot" : "slots"}
</Text>
)}
</View>
);
}
interface TimeZoneRowProps {
timeZone: string;
}
export function TimeZoneRow({ timeZone }: TimeZoneRowProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const iconColor = isDark ? "#A3A3A3" : "#666666";
return (
<View className="mt-2 flex-row items-center">
<Ionicons name="globe-outline" size={14} color={iconColor} />
<Text style={{ color: iconColor }} className="ml-1.5 text-sm">
{timeZone}
</Text>
</View>
);
}
interface ScheduleActionsButtonProps {
schedule: Schedule;
setSelectedSchedule: (schedule: Schedule) => void;
setShowActionsModal: (show: boolean) => void;
}
export function ScheduleActionsButton({
schedule,
setSelectedSchedule,
setShowActionsModal,
}: ScheduleActionsButtonProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
return (
<TouchableOpacity
style={{
width: 32,
height: 32,
borderWidth: 1,
borderColor: isDark ? "#4D4D4D" : "#E5E5EA",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
}}
onPress={(e) => {
e.stopPropagation();
setSelectedSchedule(schedule);
setShowActionsModal(true);
}}
>
<Ionicons name="ellipsis-horizontal" size={18} color={isDark ? "#E5E5EA" : "#3C3F44"} />
</TouchableOpacity>
);
}
@@ -1,128 +0,0 @@
import { ScrollView, useColorScheme, View } from "react-native";
import { Skeleton } from "@/components/ui/skeleton";
import { getColors } from "@/constants/colors";
interface AvailabilityListItemSkeletonProps {
isLast?: boolean;
}
export function AvailabilityListItemSkeleton({
isLast = false,
}: AvailabilityListItemSkeletonProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
return (
<View
style={{
backgroundColor: theme.background,
borderBottomWidth: !isLast ? 1 : 0,
borderBottomColor: theme.border,
}}
>
<View style={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 12 }}>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
marginBottom: 4,
}}
>
<Skeleton style={{ height: 18, width: 100, borderRadius: 4 }} />
<Skeleton style={{ height: 20, width: 55, borderRadius: 4, marginLeft: 8 }} />
</View>
<View style={{ marginBottom: 4 }}>
<Skeleton style={{ height: 14, width: "85%", borderRadius: 4, marginBottom: 4 }} />
<Skeleton style={{ height: 14, width: "80%", borderRadius: 4, marginBottom: 4 }} />
<Skeleton style={{ height: 14, width: 80, borderRadius: 4 }} />
</View>
<View style={{ flexDirection: "row", alignItems: "center", marginTop: 8 }}>
<Skeleton style={{ height: 14, width: 14, borderRadius: 7 }} />
<Skeleton style={{ height: 14, width: 90, borderRadius: 4, marginLeft: 6 }} />
</View>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: 16,
paddingBottom: 16,
}}
>
<Skeleton style={{ height: 32, width: 32, borderRadius: 8 }} />
</View>
</View>
);
}
interface AvailabilityListSkeletonProps {
count?: number;
iosStyle?: boolean;
}
export function AvailabilityListSkeleton({
count = 4,
iosStyle = false,
}: AvailabilityListSkeletonProps) {
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const theme = getColors(isDark);
if (iosStyle) {
return (
<ScrollView
style={{ backgroundColor: theme.background }}
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
>
<View
style={{
flex: 1,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
marginHorizontal: 8,
marginVertical: 4,
overflow: "hidden",
}}
>
{Array.from({ length: count }).map((_, index) => (
<AvailabilityListItemSkeleton
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder
key={`availability-skeleton-${index}`}
isLast={index === count - 1}
/>
))}
</View>
</ScrollView>
);
}
return (
<View
style={{
flex: 1,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.background,
paddingHorizontal: 8,
paddingVertical: 4,
}}
>
{Array.from({ length: count }).map((_, index) => (
<AvailabilityListItemSkeleton
// biome-ignore lint/suspicious/noArrayIndexKey: Static skeleton items don't reorder
key={`availability-skeleton-${index}`}
isLast={index === count - 1}
/>
))}
</View>
);
}

Some files were not shown because too many files have changed in this diff Show More