chore: remove companion app (moved to calcom/companion) (#27957)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
cd companion
|
||||
npx lint-staged
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 354 KiB |
@@ -1,17 +0,0 @@
|
||||
module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
"babel-preset-expo",
|
||||
{
|
||||
jsxImportSource: "nativewind",
|
||||
"react-compiler": {
|
||||
panicThreshold: "all_errors",
|
||||
},
|
||||
},
|
||||
],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||