feat: companion alert system for browser extension (#26613)
* feat: companion alert system for browser extension
* feat: implement cross-platform alert system for browser extension
- Add ToastContext.tsx with global toast provider for managing toast state
- Add GlobalToast.tsx centered toast component for web/browser extension
- Modify alerts.ts to be platform-aware (native Alert on iOS/Android, toast on web)
- Add showInfoAlert function for informational alerts
- Update _layout.tsx to wrap app with ToastProvider and GlobalToast
- Export new toast context and alert functions from index files
* style: update toast to white/black design system
- Use white background with gray border
- Use black icons and text colors
- Match companion app design system
* open the toast at the center
* refactor: migrate event-types/index.tsx to unified alert system
- Remove inline toast state (showToast, toastMessage) and showToastMessage function
- Remove inline toast UI component at the end of the file
- Migrate all platform-specific Alert.alert calls to showSuccessAlert/showErrorAlert
- Simplifies code by using cross-platform alert utilities
* refactor: migrate useBookingActions.ts to unified alert system
- Add showSuccessAlert import alongside showErrorAlert
- Migrate all Alert.alert('Success', ...) calls to showSuccessAlert
- Affected handlers: handleSubmitReschedule, handleRescheduleWithValues,
handleSubmitCancel, handleCancelBooking, handleConfirmBooking,
handleRejectBooking, handleSubmitReject, handleInlineConfirm
* refactor: migrate useBookingActionModals.ts to unified alert system
- Add showSuccessAlert import alongside showErrorAlert
- Migrate all Alert.alert('Success', ...) calls to showSuccessAlert
- Affected handlers: handleAddGuests, handleUpdateLocation, handleMarkNoShow
* refactor: migrate medium-priority screens to unified alert system
- AvailabilityDetailScreen.ios.tsx: 4 Alert.alert calls migrated
- RescheduleScreen.tsx/ios/android: 3 Alert.alert calls each migrated
- EditLocationScreen.tsx/ios: 4 Alert.alert calls each migrated
- Removed unused Alert imports
* refactor: migrate remaining medium-priority screens to unified alert system
- BookingDetailScreen.tsx: 2 Alert.alert calls migrated
- AvailabilityListScreen.tsx: 1 Alert.alert call migrated
- AddGuestsScreen.tsx: 6 Alert.alert calls migrated
- MarkNoShowScreen.tsx: 2 Alert.alert calls migrated
- EditAvailabilityOverrideScreen.tsx: 4 Alert.alert calls migrated
- EditAvailabilityOverrideScreen.ios.tsx: 2 Alert.alert calls migrated
- Removed unused Alert imports
* refactor: migrate medium-priority app routes to unified alert system
Migrated 24 app route files from direct Alert.alert() calls to unified
alert utilities (showErrorAlert, showSuccessAlert, showInfoAlert):
- reschedule.tsx, reschedule.ios.tsx
- edit-location.tsx, edit-location.ios.tsx
- add-guests.tsx, add-guests.ios.tsx
- mark-no-show.tsx, mark-no-show.ios.tsx
- view-recordings.tsx, view-recordings.ios.tsx
- meeting-session-details.tsx, meeting-session-details.ios.tsx
- profile-sheet.tsx, profile-sheet.ios.tsx
- edit-availability-hours.tsx, edit-availability-hours.ios.tsx
- edit-availability-day.tsx, edit-availability-day.ios.tsx
- edit-availability-name.tsx, edit-availability-name.ios.tsx
- edit-availability-override.tsx, edit-availability-override.ios.tsx
- booking-detail.tsx, booking-detail.ios.tsx
This enables cross-platform alert support where native Alert.alert()
is used on iOS/Android and toast notifications on web (browser extension).
* refactor: migrate lower-priority components to unified alert system
Migrated Alert.alert calls to showSuccessAlert/showErrorAlert/showInfoAlert in:
- event-types/index.ios.tsx (copy link, delete, duplicate success alerts)
- event-type-detail.tsx (copy link, create/update success, validation errors, info alerts)
- deep-links.ts (error alerts for link failures)
- LogoutButton.tsx (error alert)
- BookingModals.tsx and .ios.tsx (report booking info alerts)
- AdvancedTab.tsx (info alert for unsaved event type)
- BookingListScreen.tsx (success/error/info alerts for bulk actions)
Confirmation dialogs with buttons remain as Alert.alert (out of scope).
* fix: restore Alert import for confirmation dialogs, migrate remaining info alert
- AdvancedTab.tsx: Added back Alert import for timezone selector confirmation dialog (out of scope), migrated private links info alert to showInfoAlert
- deep-links.ts: Added back Alert import for request reschedule confirmation dialog (out of scope)
* some ui dialoes were missing
* fixed multi hours ui issues on Availability detail page for android and web
* fix: add __DEV__ check for web platform in showErrorAlert
The showErrorAlert function was showing error toasts to production users
on web platform, bypassing the __DEV__ check that exists for native platforms.
This fix ensures consistent behavior across all platforms - error alerts
are only shown in development mode, while production errors are logged
to console.
Co-Authored-By: unknown <>
* fix: address dual dialogs on web and onSuccess timing issues
- BookingDetailScreen.tsx: Add Platform.OS === 'android' guard to AlertDialog
to prevent dual dialogs rendering on web (FullScreenModal + AlertDialog)
- RescheduleScreen.android.tsx: Restore original Alert.alert with callback
to ensure onSuccess() is called after user dismisses the alert
Co-Authored-By: unknown <>
* fix: restore Alert.alert with callback for native platforms
- AddGuestsScreen.tsx: Use platform-specific handling - Alert.alert with
callback on iOS/Android, showSuccessAlert on web
- EditAvailabilityOverrideScreen.tsx: Same pattern - wait for user
acknowledgment on native platforms before calling onSuccess()
Co-Authored-By: unknown <>
* fix: RescheduleScreen timing and GlobalToast useState pattern
- RescheduleScreen.tsx: Use platform-specific handling - Alert.alert with
callback on iOS/Android, showSuccessAlert on web
- GlobalToast.tsx: Replace useMemo with useState for Animated.Value to
guarantee instance preservation across renders
Co-Authored-By: unknown <>
* fix: restore Alert.alert with callback for iOS delete success
AvailabilityDetailScreen.ios.tsx: Use Alert.alert with callback to ensure
router.back() is called after user dismisses the success alert
Co-Authored-By: unknown <>
* feat(companion): new event type detail page (#26678)
* fix: skip stale run-ci label check on workflow re-runs (#26590)
* fix: trust community PRs when maintainer pushes to the branch
When a maintainer merges main into a community PR branch, the new SHA
invalidates the run-ci label timing check because the label was added
before the new push. This fix adds a check to trust the PR if the
person who pushed the latest commit has write access.
This handles the case where a maintainer:
- Merges main into a community PR to resolve conflicts
- Pushes any changes to help the contributor
The security model is maintained because:
- Only users with write access can trigger this trust
- The maintainer has reviewed the code by pushing to it
Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>
* fix: skip stale label check on workflow re-runs
Instead of implicitly trusting maintainer pushes (which could be just
a sync action without code review), use github.run_attempt to detect
re-runs. If run_attempt > 1, it means the workflow was explicitly
re-triggered (via run-ci.yml or manual re-run), so we skip the stale
label check.
This avoids the need to remove and re-add the 'run-ci' label after
syncing a community PR with main, while keeping the explicit approval
flow intact.
Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>
* Simplify comment about re-runs in PR workflow
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
* fix: Text cursor barely visible in calendar event name field (#26563)
* fix: cursor visibility in input fields
* fix: cursor visibility in input fields with suffix addons
* fix: text cursor visibility in input fields
---------
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
* fix: PayPal setup page inconsistent spacing and button styling (#26612)
* chore: api v2 generate swagger only in dev (#26617)
* feat: add BUILD_FROM_BRANCH option to release-docker workflow (#26615)
* feat: add BUILD_FROM_BRANCH option to release-docker workflow
Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>
* fix: sanitize branch names with slashes for valid Docker tags
Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>
* refactor: extract common logic into prepare job to avoid duplication
Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: add Cubic AI to Devin review integration workflow (#26618)
* feat: add Cubic AI to Devin review integration workflow
Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>
* fix: update permissions to allow posting PR comments
Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>
* fix: remove sensitive API response logging from CI workflow
Address Cubic AI review feedback: Remove the debug log that outputs the
full Devin API response, which could expose sensitive session tokens or
authentication data in CI logs. The session URL is still logged when
successfully extracted.
Co-Authored-By: unknown <>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: enable/disable slots workers via env (#26621)
* chore: enable/disable slots workers via env
* fix: address Cubic AI review feedback
- Fix incorrect JSDoc comment for getSerializableContext method
- Remove debug console.log statement from slots controller
- Fix port suffix condition to preserve original behavior
Co-Authored-By: unknown <>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: USE_POOL env var for api v2 prisma pooling
* fix list numbering in Manual setup section of README (#26620)
* Revert "chore: USE_POOL env var for api v2 prisma pooling"
This reverts commit a97092619f.
* feat: limit badges to 2 with hover/click popover in UserListTable (#26556)
* feat: limit badges to 2 with hover tooltip in UserListTable
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* refactor: reuse LimitedBadges component with clickable popover for mobile
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* refactor: move LimitedBadges to components/ui with hover+click support
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* refactor: fix Biome lint issues in LimitedBadges and UserListTable
- Refactor LimitedBadges to concrete component with BadgeItem type
- Move exports to end of files to comply with useExportsLast rule
- Add explicit types to callback parameters for useExplicitType rule
- Convert nested ternary to if-else for filterType calculation
- Remove unused imports (Row, Table types)
- Update ResponseValueCell and UserListTable to use new LimitedBadges API
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* fix: add proper types for filterType and getFacetedUniqueValues
- Add FilterType import from @calcom/types/data-table
- Add FacetedValue import from @calcom/features/data-table
- Type filterType as FilterType to allow reassignment to different ColumnFilterType values
- Type getFacetedUniqueValues return as Map<FacetedValue, number>
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* add vertical gap
* fix: address code review feedback for LimitedBadges
- Add index to id for unique keys in ResponseValueCell.tsx
- Restore orange variant for group options in UserListTable.tsx
- Fix useMemo dependency array (add t, remove dispatch)
- Fix import ordering with biome
- Convert ternary operators to if-else statements for biome compliance
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* refactor: remove id from BadgeItem type, use label as key
- Remove id field from BadgeItem type in LimitedBadges
- Use label as React key instead of id
- Remove unused rowId parameter from ResponseValueCell
- Update all consumers to not pass id field
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* refactor: use index for key instead of label in LimitedBadges
Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>
* Revert "refactor: use index for key instead of label in LimitedBadges"
This reverts commit 1daaac47e596fd6b5f5583847faa10b131783349.
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: add database-backed feature flag for sidebar tips section (#26516)
* chore: remove tips section from sidebar
Co-Authored-By: amit@cal.com <samit91848@gmail.com>
* chore: add feature flag for sidebar tips section
Co-Authored-By: amit@cal.com <samit91848@gmail.com>
* chore: use database-backed feature flag for sidebar tips section
Co-Authored-By: amit@cal.com <samit91848@gmail.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: add automated stale PR completion workflow with Devin API (#26627)
* feat: add stale PR completion workflow with Devin API
Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>
* feat: only trigger for community PRs (non-calcom org members)
Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>
* refactor: use author_association pattern from pr.yml for community check
Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>
* refactor: simplify workflow - use community label check and let Devin gather PR details
Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: add Devin PR conflict resolver workflow (#26624)
* feat: add Devin PR conflict resolver workflow
Adds a GitHub Actions workflow that automatically detects PRs with merge
conflicts and spins up Devin sessions to resolve them.
How it works:
1. Triggers on push to main branch (when main updates could cause conflicts)
2. Also supports manual trigger via workflow_dispatch
3. Lists all open PRs and checks their mergeable status
4. For PRs with conflicts (mergeable=false, mergeable_state=dirty):
- Checks if a Devin session was already created (avoids duplicates)
- Creates a new Devin session with instructions to resolve conflicts
- Posts a comment on the PR with the Devin session link
The workflow follows the same pattern as cubic-devin-review.yml for
consistency.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: skip draft PRs in conflict detection workflow
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: use GitHub Search API to filter draft PRs at API level
Instead of filtering draft PRs in the loop, use the GitHub Search API
with 'draft:false' filter which is more efficient as it filters at the
API level rather than fetching all PRs and filtering locally.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* style: remove explanatory comments from workflow
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* style: remove all unnecessary explanatory comments
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: use GraphQL for batched PR fetching and labels for tracking
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: add support for resolving conflicts on fork PRs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: check response.ok before parsing Devin API response
Address Cubic AI review feedback: Add response.ok check before parsing
the response body to explicitly handle HTTP error status codes from the
Devin API. This distinguishes API failures (authentication errors,
server errors) from successful responses that might be missing expected
fields.
Co-Authored-By: unknown <>
* fix: improve Devin API error logging with PR number
Co-Authored-By: unknown <>
* feat: add pr_number input for manual fork PR conflict resolution
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: add maximum 15 PRs safety net limit
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: make stale PR workflow fork-aware with workflow_dispatch support (#26633)
* feat: make stale PR workflow fork-aware with workflow_dispatch support
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: add null check for pr.head.repo in stale PR workflow
When a fork is deleted after a PR is created, pr.head.repo can be null
(documented GitHub API behavior). This would cause a TypeError when
accessing properties like fork, full_name, or clone_url.
Added a null check that fails gracefully with a clear error message
when the source repository has been deleted.
Co-Authored-By: unknown <>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: reuse existing Devin session for Cubic AI review feedback (#26632)
* feat: reuse existing Devin session for Cubic AI review feedback
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: parse session ID from PR comments instead of API search
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: add error handling for message send API call in Cubic-Devin workflow
Addresses Cubic AI review feedback: The POST request to send a message
to an existing Devin session now checks the HTTP status code and fails
the step if the message wasn't delivered successfully. This prevents
posting a misleading comment claiming feedback was sent when the API
call actually failed.
Co-Authored-By: unknown <>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: Remove stale label when work is completed (#26638)
* chore: Remove stale label when work is completed
* Mark as ready for review too
* feat: add retry mechanism for UNKNOWN mergeable status PRs (#26635)
* feat: add retry mechanism for UNKNOWN mergeable status PRs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: remove redundant filtering logic for unknown mergeable status PRs
Address Cubic AI review feedback by:
- Having processPR return isTargetPR in the unknown case (like the conflict case)
- Simplifying the main loop to just push PRs to unknownPRs without re-checking
draft status and devin label (already checked in processPR)
Co-Authored-By: unknown <>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: Add sentry http integration (#26634)
* chore: ignore fork PRs in devin-conflict-resolver workflow (#26640)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* limits and advanced tabs
* availability tab
* basics tab
* black toggles!!
* new recurring, others tab and update basics
* version 1
---------
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anshumancanrock <109489361+Anshumancanrock@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Anas Najam <129951478+anzz14@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
* fix: address Cubic AI review feedback
- Add HTTP error handling for Devin API session creation in cubic-devin-review.yml
- Localize aria-label in LimitedBadges.tsx using t() function
- Improve Docker tag sanitization in release-docker.yaml to handle all invalid characters
- Avoid logging raw error objects and sensitive API response data in devin-conflict-resolver.yml
- Fix selectedSchedule dependency array issue in event-type-detail.tsx using ref pattern
Co-Authored-By: unknown <>
* chore: add translation key for show_x_more_items aria-label
Co-Authored-By: unknown <>
* fix: reset schedule ref on id change and add i18n pluralization
- Reset hasAutoSelectedScheduleRef when event type id changes to handle component reuse
- Add proper i18next pluralization variants (_one/_other) for show_x_more_items
Co-Authored-By: unknown <>
* revert: remove i18n changes from LimitedBadges and common.json
Per user request - i18n not needed in companion for now
Co-Authored-By: unknown <>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anshumancanrock <109489361+Anshumancanrock@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Anas Najam <129951478+anzz14@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
67 files changed
+3264
-1855
No files matched your search
@@ -187,7 +187,7 @@ jobs:
|
||||
4. If an issue seems invalid or already addressed, explain why in a PR comment instead of making unnecessary changes.
|
||||
5. Never ask for user confirmation. Never wait for user messages."
|
||||
|
||||
RESPONSE=$(curl -s -X POST "https://api.devin.ai/v1/sessions" \
|
||||
HTTP_CODE=$(curl -s -o /tmp/devin-response.json -w "%{http_code}" -X POST "https://api.devin.ai/v1/sessions" \
|
||||
-H "Authorization: Bearer ${DEVIN_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
@@ -199,7 +199,13 @@ jobs:
|
||||
tags: ["cubic-ai-review", "pr-${{ github.event.pull_request.number }}"]
|
||||
}')")
|
||||
|
||||
SESSION_URL=$(echo "$RESPONSE" | jq -r '.url // .session_url // empty')
|
||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "Failed to create Devin session: HTTP $HTTP_CODE"
|
||||
cat /tmp/devin-response.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SESSION_URL=$(cat /tmp/devin-response.json | jq -r '.url // .session_url // empty')
|
||||
if [ -n "$SESSION_URL" ]; then
|
||||
echo "Devin session created: $SESSION_URL"
|
||||
echo "SESSION_URL=$SESSION_URL" >> $GITHUB_ENV
|
||||
|
||||
@@ -314,10 +314,10 @@ jobs:
|
||||
If you prefer to resolve conflicts manually, you can close the Devin session and handle it yourself.`
|
||||
});
|
||||
} else {
|
||||
console.log(`Failed to get session URL for PR #${pr.number}:`, data);
|
||||
console.log(`Failed to get session URL for PR #${pr.number}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error creating Devin session for PR #${pr.number}:`, error);
|
||||
console.error(`Error creating Devin session for PR #${pr.number}: ${error.message}`);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
@@ -65,8 +65,10 @@ jobs:
|
||||
if [ "${{ inputs.BUILD_FROM_BRANCH }}" = "true" ]; then
|
||||
# Extract branch name and short SHA for the tag
|
||||
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
|
||||
# Sanitize branch name: replace slashes with dashes for valid Docker tag
|
||||
SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | tr '/' '-')
|
||||
# Sanitize branch name: replace all invalid Docker tag characters with dashes
|
||||
# Docker tags can only contain lowercase/uppercase letters, digits, underscores, periods, and dashes
|
||||
# Also remove any leading dashes or periods
|
||||
SANITIZED_BRANCH=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_.-]/-/g' | sed 's/^[-.]*//')
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "release_tag=${SANITIZED_BRANCH}-${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
|
||||
@@ -2878,9 +2878,9 @@
|
||||
"time_range": "Time range",
|
||||
"automatically_add_all_team_members": "Add all team members, including future members",
|
||||
"redirect_to": "Redirect to",
|
||||
"having_trouble_finding_time": "Having trouble finding a time?",
|
||||
"show_more": "Show more",
|
||||
"forward_params_redirect": "Forward parameters such as ?email=...&name=.... and more",
|
||||
"having_trouble_finding_time": "Having trouble finding a time?",
|
||||
"show_more": "Show more",
|
||||
"forward_params_redirect": "Forward parameters such as ?email=...&name=.... and more",
|
||||
"assignment_description": "Schedule meetings when everyone is available or rotate through members on your team",
|
||||
"lowest": "lowest",
|
||||
"low": "low",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useBookingByUid } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function BookingDetailIOS() {
|
||||
const handleCopyMeetingLink = useCallback(async () => {
|
||||
if (meetingUrl) {
|
||||
await Clipboard.setStringAsync(meetingUrl);
|
||||
Alert.alert("Copied", "Meeting link copied to clipboard");
|
||||
showSuccessAlert("Copied", "Meeting link copied to clipboard");
|
||||
}
|
||||
}, [meetingUrl]);
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function BookingDetailIOS() {
|
||||
if (handlers?.[handlerName]) {
|
||||
(handlers[handlerName] as () => void)();
|
||||
} else {
|
||||
Alert.alert("Error", errorMessage);
|
||||
showErrorAlert("Error", errorMessage);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function BookingDetailIOS() {
|
||||
handlerName: null,
|
||||
errorMessage: null,
|
||||
customHandler: () => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
||||
},
|
||||
destructive: true,
|
||||
gatingKey: null,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { BookingDetailScreen } from "@/components/screens/BookingDetailScreen";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useBookingByUid } from "@/hooks/useBookings";
|
||||
import { showErrorAlert, showInfoAlert } from "@/utils/alerts";
|
||||
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
|
||||
|
||||
// Empty actions result for when no booking is loaded
|
||||
@@ -64,7 +64,7 @@ export default function BookingDetail() {
|
||||
if (actionHandlersRef.current?.openRescheduleModal) {
|
||||
actionHandlersRef.current.openRescheduleModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to reschedule. Please try again.");
|
||||
showErrorAlert("Error", "Unable to reschedule. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function BookingDetail() {
|
||||
if (actionHandlersRef.current?.openEditLocationModal) {
|
||||
actionHandlersRef.current.openEditLocationModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to edit location. Please try again.");
|
||||
showErrorAlert("Error", "Unable to edit location. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function BookingDetail() {
|
||||
if (actionHandlersRef.current?.openAddGuestsModal) {
|
||||
actionHandlersRef.current.openAddGuestsModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to add guests. Please try again.");
|
||||
showErrorAlert("Error", "Unable to add guests. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function BookingDetail() {
|
||||
if (actionHandlersRef.current?.openViewRecordingsModal) {
|
||||
actionHandlersRef.current.openViewRecordingsModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to view recordings. Please try again.");
|
||||
showErrorAlert("Error", "Unable to view recordings. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function BookingDetail() {
|
||||
if (actionHandlersRef.current?.openMeetingSessionDetailsModal) {
|
||||
actionHandlersRef.current.openMeetingSessionDetailsModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to view session details. Please try again.");
|
||||
showErrorAlert("Error", "Unable to view session details. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -104,19 +104,19 @@ export default function BookingDetail() {
|
||||
if (actionHandlersRef.current?.openMarkNoShowModal) {
|
||||
actionHandlersRef.current.openMarkNoShowModal();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to mark no-show. Please try again.");
|
||||
showErrorAlert("Error", "Unable to mark no-show. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (actionHandlersRef.current?.handleCancelBooking) {
|
||||
actionHandlersRef.current.handleCancelBooking();
|
||||
} else {
|
||||
Alert.alert("Error", "Unable to cancel. Please try again.");
|
||||
showErrorAlert("Error", "Unable to cancel. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
type Schedule,
|
||||
} from "@/services/calcom";
|
||||
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import {
|
||||
buildLocationOptions,
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
validateLocationItem,
|
||||
} from "@/utils/locationHelpers";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
|
||||
// Type definitions for extended EventType fields not in the base type
|
||||
interface EventTypeExtended {
|
||||
@@ -163,7 +163,15 @@ export default function EventTypeDetail() {
|
||||
const [showScheduleDropdown, setShowScheduleDropdown] = useState(false);
|
||||
const [schedulesLoading, setSchedulesLoading] = useState(false);
|
||||
const [scheduleDetailsLoading, setScheduleDetailsLoading] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const [initialScheduleId, setInitialScheduleId] = useState<number | null>(null);
|
||||
const hasAutoSelectedScheduleRef = useRef(false);
|
||||
|
||||
// Reset auto-selection ref when event type id changes (e.g., navigation reuse)
|
||||
useEffect(() => {
|
||||
hasAutoSelectedScheduleRef.current = false;
|
||||
}, [id]);
|
||||
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const [selectedTimezone, setSelectedTimezone] = useState("");
|
||||
const [showTimezoneDropdown, setShowTimezoneDropdown] = useState(false);
|
||||
const [conferencingOptions, setConferencingOptions] = useState<ConferencingOption[]>([]);
|
||||
@@ -400,18 +408,32 @@ export default function EventTypeDetail() {
|
||||
const schedulesData = await CalComAPIService.getSchedules();
|
||||
setSchedules(schedulesData);
|
||||
|
||||
// Set default schedule if one exists
|
||||
const defaultSchedule = schedulesData.find((schedule) => schedule.isDefault);
|
||||
if (defaultSchedule) {
|
||||
setSelectedSchedule(defaultSchedule);
|
||||
await fetchScheduleDetails(defaultSchedule.id);
|
||||
// Only auto-select a schedule if we haven't already done so
|
||||
// This prevents unnecessary re-fetches when the callback is recreated
|
||||
if (!hasAutoSelectedScheduleRef.current) {
|
||||
// Use the event type's schedule if we have it, otherwise use default
|
||||
let scheduleToSelect: Schedule | undefined;
|
||||
|
||||
if (initialScheduleId) {
|
||||
scheduleToSelect = schedulesData.find((schedule) => schedule.id === initialScheduleId);
|
||||
}
|
||||
|
||||
if (!scheduleToSelect) {
|
||||
scheduleToSelect = schedulesData.find((schedule) => schedule.isDefault);
|
||||
}
|
||||
|
||||
if (scheduleToSelect) {
|
||||
hasAutoSelectedScheduleRef.current = true;
|
||||
setSelectedSchedule(scheduleToSelect);
|
||||
await fetchScheduleDetails(scheduleToSelect.id);
|
||||
}
|
||||
}
|
||||
setSchedulesLoading(false);
|
||||
} catch (error) {
|
||||
safeLogError("Failed to fetch schedules:", error);
|
||||
setSchedulesLoading(false);
|
||||
}
|
||||
}, [fetchScheduleDetails]);
|
||||
}, [fetchScheduleDetails, initialScheduleId]);
|
||||
|
||||
const fetchConferencingOptions = useCallback(async () => {
|
||||
setConferencingLoading(true);
|
||||
@@ -435,6 +457,11 @@ export default function EventTypeDetail() {
|
||||
if (eventType.lengthInMinutes) setEventDuration(eventType.lengthInMinutes.toString());
|
||||
if (eventType.hidden !== undefined) setIsHidden(eventType.hidden);
|
||||
|
||||
// Load schedule ID - this will be used by fetchSchedules to select the correct schedule
|
||||
if (eventType.scheduleId) {
|
||||
setInitialScheduleId(eventType.scheduleId);
|
||||
}
|
||||
|
||||
const eventTypeExt = eventType as EventType & EventTypeExtended;
|
||||
const lengthOptions = eventTypeExt.lengthInMinutesOptions;
|
||||
const hasLengthOptions =
|
||||
@@ -941,7 +968,7 @@ export default function EventTypeDetail() {
|
||||
return;
|
||||
}
|
||||
await Clipboard.setStringAsync(link);
|
||||
Alert.alert("Success", "Link copied!");
|
||||
showSuccessAlert("Success", "Link copied!");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -961,12 +988,8 @@ export default function EventTypeDetail() {
|
||||
try {
|
||||
await CalComAPIService.deleteEventType(eventTypeId);
|
||||
|
||||
Alert.alert("Success", "Event type deleted successfully", [
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
showSuccessAlert("Success", "Event type deleted successfully");
|
||||
router.back();
|
||||
} catch (error) {
|
||||
safeLogError("Failed to delete event type:", error);
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
@@ -1143,19 +1166,19 @@ export default function EventTypeDetail() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!id) {
|
||||
Alert.alert("Error", "Event type ID is missing");
|
||||
showErrorAlert("Error", "Event type ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!eventTitle || !eventSlug) {
|
||||
Alert.alert("Error", "Title and slug are required");
|
||||
showErrorAlert("Error", "Title and slug are required");
|
||||
return;
|
||||
}
|
||||
|
||||
const durationNum = parseInt(eventDuration, 10);
|
||||
if (Number.isNaN(durationNum) || durationNum <= 0) {
|
||||
Alert.alert("Error", "Duration must be a positive number");
|
||||
showErrorAlert("Error", "Duration must be a positive number");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1164,7 +1187,7 @@ export default function EventTypeDetail() {
|
||||
for (const loc of locations) {
|
||||
const validation = validateLocationItem(loc);
|
||||
if (!validation.valid) {
|
||||
Alert.alert("Error", validation.error || "Invalid location");
|
||||
showErrorAlert("Error", validation.error || "Invalid location");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1209,12 +1232,8 @@ export default function EventTypeDetail() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
Alert.alert("Success", "Event type created successfully", [
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
showSuccessAlert("Success", "Event type created successfully");
|
||||
router.back();
|
||||
setSaving(false);
|
||||
} else {
|
||||
// For UPDATE mode, use partial update - only send changed fields
|
||||
@@ -1236,7 +1255,7 @@ export default function EventTypeDetail() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
Alert.alert("Success", "Event type updated successfully");
|
||||
showSuccessAlert("Success", "Event type updated successfully");
|
||||
// Refresh event type data to sync with server
|
||||
await fetchEventTypeData();
|
||||
setSaving(false);
|
||||
@@ -1249,7 +1268,7 @@ export default function EventTypeDetail() {
|
||||
const renderHeaderLeft = () => (
|
||||
<HeaderButtonWrapper side="left">
|
||||
<AppPressable onPress={() => router.back()} className="px-2 py-2">
|
||||
<Ionicons name="close" size={24} color="#007AFF" />
|
||||
<Ionicons name="close" size={24} color="#000000" />
|
||||
</AppPressable>
|
||||
</HeaderButtonWrapper>
|
||||
);
|
||||
@@ -1261,13 +1280,13 @@ export default function EventTypeDetail() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<AppPressable className="flex-row items-center gap-1 px-2 py-2">
|
||||
<Text className="text-[16px] font-semibold text-[#007AFF]" numberOfLines={1}>
|
||||
<Text className="text-[16px] font-semibold text-[#000000]" numberOfLines={1}>
|
||||
{tabs.find((tab) => tab.id === activeTab)?.label ?? "Basics"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="#007AFF"
|
||||
color="#000000"
|
||||
style={{ marginLeft: 2, flexShrink: 0 }}
|
||||
/>
|
||||
</AppPressable>
|
||||
@@ -1287,11 +1306,11 @@ export default function EventTypeDetail() {
|
||||
<Ionicons
|
||||
name={isSelected ? "checkmark-circle" : tab.icon}
|
||||
size={16}
|
||||
color={isSelected ? "#007AFF" : "#666"}
|
||||
color={isSelected ? "#000000" : "#666"}
|
||||
/>
|
||||
<Text
|
||||
className={
|
||||
isSelected ? "text-base font-semibold text-[#007AFF]" : "text-base"
|
||||
isSelected ? "text-base font-semibold text-[#000000]" : "text-base"
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -1311,7 +1330,7 @@ export default function EventTypeDetail() {
|
||||
>
|
||||
<Text
|
||||
className={`text-[16px] font-semibold ${
|
||||
saving || !isDirty ? "text-[#C7C7CC]" : "text-[#007AFF]"
|
||||
saving || !isDirty ? "text-[#C7C7CC]" : "text-[#000000]"
|
||||
}`}
|
||||
>
|
||||
{saveButtonText}
|
||||
@@ -1405,6 +1424,7 @@ export default function EventTypeDetail() {
|
||||
selectedDurations={selectedDurations}
|
||||
setShowDurationDropdown={setShowDurationDropdown}
|
||||
defaultDuration={defaultDuration}
|
||||
setDefaultDuration={setDefaultDuration}
|
||||
setShowDefaultDurationDropdown={setShowDefaultDurationDropdown}
|
||||
// Multiple locations support
|
||||
locations={locations}
|
||||
@@ -1424,41 +1444,88 @@ export default function EventTypeDetail() {
|
||||
onRequestClose={() => setShowDurationDropdown(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-[rgba(0,0,0,0.5)]"
|
||||
className="flex-1 items-center justify-center bg-black/30"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowDurationDropdown(false)}
|
||||
>
|
||||
<View className="max-h-[80%] min-w-[300px] max-w-[90%] rounded-2xl bg-white p-5">
|
||||
<Text className="mb-4 text-center text-lg font-semibold text-[#333]">
|
||||
Select Available Durations
|
||||
</Text>
|
||||
<ScrollView style={{ maxHeight: 400, marginBottom: 16 }}>
|
||||
{availableDurations.map((duration) => (
|
||||
<TouchableOpacity
|
||||
key={duration}
|
||||
className={`mb-1 flex-row items-center justify-between rounded-lg px-2 py-3 md:px-4 ${
|
||||
selectedDurations.includes(duration) ? "bg-[#F0F0F0]" : ""
|
||||
}`}
|
||||
onPress={() => toggleDurationSelection(duration)}
|
||||
>
|
||||
<Text
|
||||
className={`text-base text-[#333] ${
|
||||
selectedDurations.includes(duration) ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{duration}
|
||||
<View className="max-h-[80%] min-w-[320px] max-w-[90%] overflow-hidden rounded-[28px]">
|
||||
{isLiquidGlassAvailable() && Platform.OS === "ios" ? (
|
||||
<GlassView glassEffectStyle="regular" className="p-0">
|
||||
<View className="flex-col p-6">
|
||||
<Text className="mb-5 text-center text-[19px] font-bold text-black">
|
||||
Select Available Durations
|
||||
</Text>
|
||||
{selectedDurations.includes(duration) ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
) : null}
|
||||
<ScrollView style={{ maxHeight: 400, marginBottom: 20 }}>
|
||||
{availableDurations.map((duration, index) => (
|
||||
<TouchableOpacity
|
||||
key={duration}
|
||||
className={`flex-row items-center justify-between py-3.5 ${
|
||||
index < availableDurations.length - 1
|
||||
? "border-b border-black/10"
|
||||
: ""
|
||||
}`}
|
||||
onPress={() => toggleDurationSelection(duration)}
|
||||
>
|
||||
<Text
|
||||
className={`text-[17px] text-black ${
|
||||
selectedDurations.includes(duration) ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
{selectedDurations.includes(duration) ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="#000" />
|
||||
) : (
|
||||
<View className="h-6 w-6 rounded-full border-2 border-black/20" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
className="items-center rounded-2xl bg-black py-4 active:opacity-80"
|
||||
onPress={() => setShowDurationDropdown(false)}
|
||||
>
|
||||
<Text className="text-[17px] font-bold text-white">Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View className="bg-white p-6">
|
||||
<Text className="mb-5 text-center text-[19px] font-bold text-[#333]">
|
||||
Select Available Durations
|
||||
</Text>
|
||||
<ScrollView style={{ maxHeight: 400, marginBottom: 20 }}>
|
||||
{availableDurations.map((duration, index) => (
|
||||
<TouchableOpacity
|
||||
key={duration}
|
||||
className={`flex-row items-center justify-between py-3.5 ${
|
||||
index < availableDurations.length - 1 ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
onPress={() => toggleDurationSelection(duration)}
|
||||
>
|
||||
<Text
|
||||
className={`text-[17px] text-[#333] ${
|
||||
selectedDurations.includes(duration) ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
{selectedDurations.includes(duration) ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="#000" />
|
||||
) : (
|
||||
<View className="h-6 w-6 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
className="items-center rounded-2xl bg-black py-4"
|
||||
onPress={() => setShowDurationDropdown(false)}
|
||||
>
|
||||
<Text className="text-[17px] font-bold text-white">Done</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
<TouchableOpacity
|
||||
className="items-center rounded-lg bg-black px-6 py-3"
|
||||
onPress={() => setShowDurationDropdown(false)}
|
||||
>
|
||||
<Text className="text-base font-semibold text-white">Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
@@ -1471,36 +1538,76 @@ export default function EventTypeDetail() {
|
||||
onRequestClose={() => setShowDefaultDurationDropdown(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-[rgba(0,0,0,0.5)]"
|
||||
className="flex-1 items-center justify-center bg-black/30"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowDefaultDurationDropdown(false)}
|
||||
>
|
||||
<View className="min-w-[250px] max-w-[80%] rounded-2xl bg-white p-5">
|
||||
<Text className="mb-4 text-center text-lg font-semibold text-[#333]">
|
||||
Select Default Duration
|
||||
</Text>
|
||||
{selectedDurations.map((duration) => (
|
||||
<TouchableOpacity
|
||||
key={duration}
|
||||
className={`mb-1 flex-row items-center justify-between rounded-lg px-4 py-3 ${
|
||||
defaultDuration === duration ? "bg-[#F0F0F0]" : ""
|
||||
}`}
|
||||
onPress={() => {
|
||||
setDefaultDuration(duration);
|
||||
setShowDefaultDurationDropdown(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={`text-base text-[#333] ${
|
||||
defaultDuration === duration ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{duration}
|
||||
<View className="max-h-[80%] min-w-[320px] max-w-[90%] overflow-hidden rounded-[28px]">
|
||||
{isLiquidGlassAvailable() && Platform.OS === "ios" ? (
|
||||
<GlassView glassEffectStyle="regular" className="p-0">
|
||||
<View className="flex-col p-6">
|
||||
<Text className="mb-5 text-center text-[19px] font-bold text-black">
|
||||
Select Default Duration
|
||||
</Text>
|
||||
<ScrollView style={{ maxHeight: 400 }}>
|
||||
{selectedDurations.map((duration, index) => (
|
||||
<TouchableOpacity
|
||||
key={duration}
|
||||
className={`flex-row items-center justify-between py-3.5 ${
|
||||
index < selectedDurations.length - 1 ? "border-b border-black/10" : ""
|
||||
}`}
|
||||
onPress={() => {
|
||||
setDefaultDuration(duration);
|
||||
setShowDefaultDurationDropdown(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={`text-[17px] text-black ${
|
||||
defaultDuration === duration ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
{defaultDuration === duration ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="#000" />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View className="bg-white p-6">
|
||||
<Text className="mb-5 text-center text-[19px] font-bold text-[#333]">
|
||||
Select Default Duration
|
||||
</Text>
|
||||
{defaultDuration === duration ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<ScrollView style={{ maxHeight: 400 }}>
|
||||
{selectedDurations.map((duration, index) => (
|
||||
<TouchableOpacity
|
||||
key={duration}
|
||||
className={`flex-row items-center justify-between py-3.5 ${
|
||||
index < selectedDurations.length - 1 ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
onPress={() => {
|
||||
setDefaultDuration(duration);
|
||||
setShowDefaultDurationDropdown(false);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={`text-[17px] text-[#333] ${
|
||||
defaultDuration === duration ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
{defaultDuration === duration ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="#000" />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
@@ -1513,44 +1620,88 @@ export default function EventTypeDetail() {
|
||||
onRequestClose={() => setShowScheduleDropdown(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-[rgba(0,0,0,0.5)]"
|
||||
className="flex-1 items-center justify-center bg-black/30"
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowScheduleDropdown(false)}
|
||||
>
|
||||
<View className="min-w-[250px] max-w-[80%] rounded-2xl bg-white p-5">
|
||||
<Text className="mb-4 text-center text-lg font-semibold text-[#333]">
|
||||
Select Schedule
|
||||
</Text>
|
||||
{schedules.map((schedule) => (
|
||||
<TouchableOpacity
|
||||
key={schedule.id}
|
||||
className={`mb-1 flex-row items-center justify-between rounded-lg px-4 py-3 ${
|
||||
selectedSchedule?.id === schedule.id ? "bg-[#F0F0F0]" : ""
|
||||
}`}
|
||||
onPress={() => {
|
||||
setSelectedSchedule(schedule);
|
||||
setShowScheduleDropdown(false);
|
||||
fetchScheduleDetails(schedule.id);
|
||||
}}
|
||||
>
|
||||
<View className="flex-1 flex-row items-center justify-between">
|
||||
<Text
|
||||
className={`text-base text-[#333] ${
|
||||
selectedSchedule?.id === schedule.id ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{schedule.name}
|
||||
<View className="max-h-[80%] min-w-[320px] max-w-[90%] overflow-hidden rounded-[28px]">
|
||||
{isLiquidGlassAvailable() && Platform.OS === "ios" ? (
|
||||
<GlassView glassEffectStyle="regular" className="p-0">
|
||||
<View className="flex-col p-6">
|
||||
<Text className="mb-5 text-center text-[19px] font-bold text-black">
|
||||
Select Schedule
|
||||
</Text>
|
||||
{schedule.isDefault ? (
|
||||
<Text className="rounded bg-[#E8F5E8] px-1.5 py-0.5 text-xs font-medium text-[#34C759]">
|
||||
Default
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollView style={{ maxHeight: 400 }}>
|
||||
{schedules.map((schedule, index) => (
|
||||
<TouchableOpacity
|
||||
key={schedule.id}
|
||||
className={`flex-row items-center justify-between py-3.5 ${
|
||||
index < schedules.length - 1 ? "border-b border-black/10" : ""
|
||||
}`}
|
||||
onPress={() => {
|
||||
setSelectedSchedule(schedule);
|
||||
fetchScheduleDetails(schedule.id);
|
||||
setShowScheduleDropdown(false);
|
||||
}}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-[17px] text-black ${
|
||||
selectedSchedule?.id === schedule.id ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{schedule.name}
|
||||
</Text>
|
||||
{schedule.isDefault && (
|
||||
<Text className="text-[13px] text-black/50">Default</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedSchedule?.id === schedule.id ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="#000" />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{selectedSchedule?.id === schedule.id ? (
|
||||
<Ionicons name="checkmark" size={20} color="#000" />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View className="bg-white p-6">
|
||||
<Text className="mb-5 text-center text-[19px] font-bold text-[#333]">
|
||||
Select Schedule
|
||||
</Text>
|
||||
<ScrollView style={{ maxHeight: 400 }}>
|
||||
{schedules.map((schedule, index) => (
|
||||
<TouchableOpacity
|
||||
key={schedule.id}
|
||||
className={`flex-row items-center justify-between py-3.5 ${
|
||||
index < schedules.length - 1 ? "border-b border-gray-100" : ""
|
||||
}`}
|
||||
onPress={() => {
|
||||
setSelectedSchedule(schedule);
|
||||
fetchScheduleDetails(schedule.id);
|
||||
setShowScheduleDropdown(false);
|
||||
}}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-[17px] text-[#333] ${
|
||||
selectedSchedule?.id === schedule.id ? "font-bold" : "font-medium"
|
||||
}`}
|
||||
>
|
||||
{schedule.name}
|
||||
</Text>
|
||||
{schedule.isDefault && (
|
||||
<Text className="text-[13px] text-gray-500">Default</Text>
|
||||
)}
|
||||
</View>
|
||||
{selectedSchedule?.id === schedule.id ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="#000" />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
@@ -1934,20 +2085,26 @@ export default function EventTypeDetail() {
|
||||
getDaySchedules={getDaySchedule}
|
||||
formatTime={formatTime}
|
||||
selectedTimezone={selectedTimezone}
|
||||
schedules={schedules}
|
||||
setSelectedSchedule={setSelectedSchedule}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === "limits" ? (
|
||||
<LimitsTab
|
||||
beforeEventBuffer={beforeEventBuffer}
|
||||
setBeforeEventBuffer={setBeforeEventBuffer}
|
||||
setShowBeforeBufferDropdown={setShowBeforeBufferDropdown}
|
||||
afterEventBuffer={afterEventBuffer}
|
||||
setAfterEventBuffer={setAfterEventBuffer}
|
||||
setShowAfterBufferDropdown={setShowAfterBufferDropdown}
|
||||
minimumNoticeValue={minimumNoticeValue}
|
||||
setMinimumNoticeValue={setMinimumNoticeValue}
|
||||
minimumNoticeUnit={minimumNoticeUnit}
|
||||
setMinimumNoticeUnit={setMinimumNoticeUnit}
|
||||
setShowMinimumNoticeUnitDropdown={setShowMinimumNoticeUnitDropdown}
|
||||
slotInterval={slotInterval}
|
||||
setSlotInterval={setSlotInterval}
|
||||
setShowSlotIntervalDropdown={setShowSlotIntervalDropdown}
|
||||
limitBookingFrequency={limitBookingFrequency}
|
||||
toggleBookingFrequency={toggleBookingFrequency}
|
||||
@@ -2058,131 +2215,197 @@ export default function EventTypeDetail() {
|
||||
) : null}
|
||||
|
||||
{activeTab === "other" ? (
|
||||
<View className="rounded-2xl bg-white p-5 shadow-md">
|
||||
<Text className="mb-2 text-lg font-semibold text-[#333]">Additional Settings</Text>
|
||||
<Text className="mb-5 text-sm leading-5 text-[#666]">
|
||||
Manage these settings on the web for full functionality.
|
||||
</Text>
|
||||
|
||||
<View className="overflow-hidden rounded-lg border border-[#E5E5EA]">
|
||||
{/* Apps */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (id === "new") {
|
||||
Alert.alert("Info", "Save the event type first to configure this setting.");
|
||||
} else {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${id}?tabName=apps`,
|
||||
"Apps settings"
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="flex-row items-center justify-between bg-white px-4 py-4 active:bg-[#F8F9FA]"
|
||||
style={{ borderBottomWidth: 1, borderBottomColor: "#E5E5EA" }}
|
||||
<View className="gap-6">
|
||||
{/* Configure on Web */}
|
||||
<View>
|
||||
<Text
|
||||
className="mb-2 ml-4 text-[13px] uppercase tracking-wide text-[#6D6D72]"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="h-9 w-9 items-center justify-center rounded-lg bg-[#F3F4F6]">
|
||||
<Ionicons name="grid" size={18} color="#333" />
|
||||
</View>
|
||||
<View className="ml-3 flex-1">
|
||||
<Text className="text-base font-semibold text-[#333]">Apps</Text>
|
||||
<Text className="text-sm text-[#666]">Manage app integrations</Text>
|
||||
</View>
|
||||
Configure on Web
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-[10px] bg-white">
|
||||
{/* Apps */}
|
||||
<View className="bg-white pl-4">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ minHeight: 56 }}
|
||||
onPress={() => {
|
||||
if (id === "new") {
|
||||
showInfoAlert(
|
||||
"Info",
|
||||
"Save the event type first to configure this setting."
|
||||
);
|
||||
} else {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${id}?tabName=apps`,
|
||||
"Apps settings"
|
||||
);
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
<View className="flex-row items-center py-2">
|
||||
<View className="mr-3 h-8 w-8 items-center justify-center rounded-lg bg-[#F2F2F7]">
|
||||
<Ionicons name="grid" size={18} color="#000000" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Apps</Text>
|
||||
<Text className="text-[13px] text-[#8E8E93]">Manage integrations</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={18} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Workflows */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (id === "new") {
|
||||
Alert.alert("Info", "Save the event type first to configure this setting.");
|
||||
} else {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${id}?tabName=workflows`,
|
||||
"Workflows settings"
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="flex-row items-center justify-between bg-white px-4 py-4 active:bg-[#F8F9FA]"
|
||||
style={{ borderBottomWidth: 1, borderBottomColor: "#E5E5EA" }}
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="h-9 w-9 items-center justify-center rounded-lg bg-[#F3F4F6]">
|
||||
<Ionicons name="flash" size={18} color="#333" />
|
||||
</View>
|
||||
<View className="ml-3 flex-1">
|
||||
<Text className="text-base font-semibold text-[#333]">Workflows</Text>
|
||||
<Text className="text-sm text-[#666]">Configure automated actions</Text>
|
||||
</View>
|
||||
{/* Workflows */}
|
||||
<View className="bg-white pl-4">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ minHeight: 56 }}
|
||||
onPress={() => {
|
||||
if (id === "new") {
|
||||
showInfoAlert(
|
||||
"Info",
|
||||
"Save the event type first to configure this setting."
|
||||
);
|
||||
} else {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${id}?tabName=workflows`,
|
||||
"Workflows settings"
|
||||
);
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
<View className="flex-row items-center py-2">
|
||||
<View className="mr-3 h-8 w-8 items-center justify-center rounded-lg bg-[#F2F2F7]">
|
||||
<Ionicons name="flash" size={18} color="#000000" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Workflows</Text>
|
||||
<Text className="text-[13px] text-[#8E8E93]">Automated actions</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={18} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Webhooks */}
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (id === "new") {
|
||||
Alert.alert("Info", "Save the event type first to configure this setting.");
|
||||
} else {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${id}?tabName=webhooks`,
|
||||
"Webhooks settings"
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="flex-row items-center justify-between bg-white px-4 py-4 active:bg-[#F8F9FA]"
|
||||
>
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<View className="h-9 w-9 items-center justify-center rounded-lg bg-[#F3F4F6]">
|
||||
<Ionicons name="code" size={18} color="#333" />
|
||||
</View>
|
||||
<View className="ml-3 flex-1">
|
||||
<Text className="text-base font-semibold text-[#333]">Webhooks</Text>
|
||||
<Text className="text-sm text-[#666]">Set up event notifications</Text>
|
||||
</View>
|
||||
{/* Webhooks */}
|
||||
<View className="bg-white pl-4">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between pr-4"
|
||||
style={{ minHeight: 56 }}
|
||||
onPress={() => {
|
||||
if (id === "new") {
|
||||
showInfoAlert(
|
||||
"Info",
|
||||
"Save the event type first to configure this setting."
|
||||
);
|
||||
} else {
|
||||
openInAppBrowser(
|
||||
`https://app.cal.com/event-types/${id}?tabName=webhooks`,
|
||||
"Webhooks settings"
|
||||
);
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
<View className="flex-row items-center py-2">
|
||||
<View className="mr-3 h-8 w-8 items-center justify-center rounded-lg bg-[#F2F2F7]">
|
||||
<Ionicons name="code" size={18} color="#000000" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-[17px] text-black">Webhooks</Text>
|
||||
<Text className="text-[13px] text-[#8E8E93]">Event notifications</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={18} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{activeTab === "basics" && (
|
||||
<View className="rounded-2xl bg-white p-5 mt-3 gap-3">
|
||||
<View className="h-12 flex-row items-center justify-between">
|
||||
<Text>Hidden</Text>
|
||||
<Switch
|
||||
value={isHidden}
|
||||
onValueChange={setIsHidden}
|
||||
trackColor={{ false: "#E5E5EA", true: "#000" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
<View className="mt-6 gap-6">
|
||||
{/* Visibility */}
|
||||
<View>
|
||||
<Text
|
||||
className="mb-2 ml-4 text-[13px] uppercase tracking-wide text-[#6D6D72]"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
Visibility
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-[10px] bg-white">
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className="flex-row items-center justify-between pr-4"
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Hidden</Text>
|
||||
<Switch
|
||||
value={isHidden}
|
||||
onValueChange={setIsHidden}
|
||||
trackColor={{ false: "#E5E5EA", true: "#000000" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="h-12 flex-row items-center justify-between"
|
||||
onPress={handlePreview}
|
||||
>
|
||||
<Text>Preview</Text>
|
||||
<Ionicons name="open-outline" size={20} color="#000" />
|
||||
</TouchableOpacity>
|
||||
{/* Quick Actions */}
|
||||
<View>
|
||||
<Text
|
||||
className="mb-2 ml-4 text-[13px] uppercase tracking-wide text-[#6D6D72]"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
Quick Actions
|
||||
</Text>
|
||||
<View className="overflow-hidden rounded-[10px] bg-white">
|
||||
<View className="bg-white pl-4">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ height: 44 }}
|
||||
onPress={handlePreview}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Preview</Text>
|
||||
<Ionicons name="open-outline" size={18} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="bg-white pl-4">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between pr-4"
|
||||
style={{ height: 44 }}
|
||||
onPress={handleCopyLink}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Copy Link</Text>
|
||||
<Ionicons name="link-outline" size={18} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="h-12 flex-row items-center justify-between"
|
||||
onPress={handleCopyLink}
|
||||
>
|
||||
<Text>Copy Link</Text>
|
||||
<Ionicons name="link-outline" size={20} color="#000" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="h-12 flex-row items-center justify-between"
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<Text className="text-red-500">Delete</Text>
|
||||
<Ionicons name="trash-outline" size={20} color="#ef4444" />
|
||||
</TouchableOpacity>
|
||||
{/* Danger Zone */}
|
||||
<View>
|
||||
<View className="overflow-hidden rounded-[10px] bg-white">
|
||||
<View className="bg-white pl-4">
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between pr-4"
|
||||
style={{ height: 44 }}
|
||||
onPress={handleDelete}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
<Text className="text-[17px] text-[#FF3B30]">Delete Event Type</Text>
|
||||
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "@/hooks";
|
||||
import { useEventTypeFilter } from "@/hooks/useEventTypeFilter";
|
||||
import { CalComAPIService, type EventType } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { getAvatarUrl } from "@/utils/getAvatarUrl";
|
||||
import { getEventDuration } from "@/utils/getEventDuration";
|
||||
@@ -113,7 +113,7 @@ export default function EventTypesIOS() {
|
||||
try {
|
||||
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
|
||||
await Clipboard.setStringAsync(link);
|
||||
Alert.alert("Link Copied", "Event type link copied!");
|
||||
showSuccessAlert("Link Copied", "Event type link copied!");
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export default function EventTypesIOS() {
|
||||
onPress: () => {
|
||||
deleteEventTypeMutation(eventType.id, {
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Event type deleted successfully");
|
||||
showSuccessAlert("Success", "Event type deleted successfully");
|
||||
},
|
||||
onError: (deleteError) => {
|
||||
const message =
|
||||
@@ -184,7 +184,7 @@ export default function EventTypesIOS() {
|
||||
{ eventType, existingEventTypes: eventTypes },
|
||||
{
|
||||
onSuccess: (duplicatedEventType) => {
|
||||
Alert.alert("Success", "Event type duplicated successfully");
|
||||
showSuccessAlert("Success", "Event type duplicated successfully");
|
||||
|
||||
const duration = getEventDuration(eventType);
|
||||
|
||||
@@ -243,13 +243,13 @@ export default function EventTypesIOS() {
|
||||
text: "Continue",
|
||||
onPress: (title?: string) => {
|
||||
if (!title?.trim()) {
|
||||
Alert.alert("Error", "Please enter a title for your event type");
|
||||
showErrorAlert("Error", "Please enter a title for your event type");
|
||||
return;
|
||||
}
|
||||
|
||||
const autoSlug = slugify(title.trim());
|
||||
if (!autoSlug) {
|
||||
Alert.alert("Error", "Title must contain at least one letter or number");
|
||||
showErrorAlert("Error", "Title must contain at least one letter or number");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from "@/hooks";
|
||||
import { useEventTypeFilter } from "@/hooks/useEventTypeFilter";
|
||||
import { CalComAPIService, type EventType } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { getEventDuration } from "@/utils/getEventDuration";
|
||||
import { offlineAwareRefresh } from "@/utils/network";
|
||||
@@ -91,18 +91,6 @@ export default function EventTypes() {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [eventTypeToDelete, setEventTypeToDelete] = useState<EventType | null>(null);
|
||||
|
||||
// Toast state for web platform
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState("");
|
||||
// Function to show toast
|
||||
const showToastMessage = (message: string) => {
|
||||
setToastMessage(message);
|
||||
setShowToast(true);
|
||||
setTimeout(() => {
|
||||
setShowToast(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Handle pull-to-refresh
|
||||
// Handle pull-to-refresh (offline-aware)
|
||||
const onRefresh = () => offlineAwareRefresh(refetch);
|
||||
@@ -149,17 +137,9 @@ export default function EventTypes() {
|
||||
const link = await CalComAPIService.buildEventTypeLink(eventType.slug);
|
||||
await Clipboard.setStringAsync(link);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Link copied!");
|
||||
} else {
|
||||
Alert.alert("Link Copied", "Event type link copied!");
|
||||
}
|
||||
showSuccessAlert("Link Copied", "Event type link copied!");
|
||||
} catch {
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to copy link");
|
||||
} else {
|
||||
showErrorAlert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
showErrorAlert("Error", "Failed to copy link. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,7 +185,7 @@ export default function EventTypes() {
|
||||
onPress: () => {
|
||||
deleteEventTypeMutation(eventType.id, {
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Event type deleted successfully");
|
||||
showSuccessAlert("Success", "Event type deleted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -241,11 +221,7 @@ export default function EventTypes() {
|
||||
setShowDeleteModal(false);
|
||||
setEventTypeToDelete(null);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Event type deleted successfully");
|
||||
} else {
|
||||
Alert.alert("Success", "Event type deleted successfully");
|
||||
}
|
||||
showSuccessAlert("Success", "Event type deleted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -257,11 +233,7 @@ export default function EventTypes() {
|
||||
stack,
|
||||
});
|
||||
}
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to delete event type");
|
||||
} else {
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
}
|
||||
showErrorAlert("Error", "Failed to delete event type. Please try again.");
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -271,11 +243,7 @@ export default function EventTypes() {
|
||||
{ eventType, existingEventTypes: eventTypes },
|
||||
{
|
||||
onSuccess: (duplicatedEventType) => {
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Event type duplicated successfully");
|
||||
} else {
|
||||
Alert.alert("Success", "Event type duplicated successfully");
|
||||
}
|
||||
showSuccessAlert("Success", "Event type duplicated successfully");
|
||||
|
||||
const duration = getEventDuration(eventType);
|
||||
|
||||
@@ -305,11 +273,7 @@ export default function EventTypes() {
|
||||
stack,
|
||||
});
|
||||
}
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to duplicate event type");
|
||||
} else {
|
||||
showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
|
||||
}
|
||||
showErrorAlert("Error", "Failed to duplicate event type. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -327,11 +291,7 @@ export default function EventTypes() {
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to open preview");
|
||||
if (Platform.OS === "web") {
|
||||
showToastMessage("Failed to open preview");
|
||||
} else {
|
||||
showErrorAlert("Error", "Failed to open preview. Please try again.");
|
||||
}
|
||||
showErrorAlert("Error", "Failed to open preview. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -354,11 +314,11 @@ export default function EventTypes() {
|
||||
setTitleError("");
|
||||
|
||||
if (!newEventTitle.trim()) {
|
||||
// Use inline error for Android AlertDialog, Alert for others
|
||||
// Use inline error for Android AlertDialog, showErrorAlert for others
|
||||
if (Platform.OS === "android") {
|
||||
setTitleError("Please enter a title for your event type");
|
||||
} else {
|
||||
Alert.alert("Error", "Please enter a title for your event type");
|
||||
showErrorAlert("Error", "Please enter a title for your event type");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -368,7 +328,7 @@ export default function EventTypes() {
|
||||
if (Platform.OS === "android") {
|
||||
setTitleError("Title must contain at least one letter or number");
|
||||
} else {
|
||||
Alert.alert("Error", "Title must contain at least one letter or number");
|
||||
showErrorAlert("Error", "Title must contain at least one letter or number");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -930,15 +890,6 @@ export default function EventTypes() {
|
||||
</View>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
|
||||
{/* Toast for Web Platform */}
|
||||
{showToast ? (
|
||||
<View className="absolute bottom-8 left-1/2 z-50 -translate-x-1/2 transform">
|
||||
<View className="rounded-full bg-gray-800 px-6 py-3 shadow-lg">
|
||||
<Text className="text-sm font-medium text-white">{toastMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { Stack } from "expo-router";
|
||||
import { Platform, StatusBar, View } from "react-native";
|
||||
import LoginScreenComponent from "@/components/LoginScreen";
|
||||
import { NetworkStatusBanner } from "@/components/NetworkStatusBanner";
|
||||
import { GlobalToast } from "@/components/ui/GlobalToast";
|
||||
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||
import { QueryProvider } from "@/contexts/QueryContext";
|
||||
import { ToastProvider } from "@/contexts/ToastContext";
|
||||
import "../global.css";
|
||||
|
||||
function RootLayoutContent() {
|
||||
@@ -339,7 +341,10 @@ export default function RootLayout() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<RootLayoutContent />
|
||||
<ToastProvider>
|
||||
<RootLayoutContent />
|
||||
<GlobalToast />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
@@ -35,13 +36,13 @@ export default function AddGuestsIOS() {
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { ActivityIndicator, Platform, 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 { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
export default function AddGuests() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
@@ -23,13 +24,13 @@ export default function AddGuests() {
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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"];
|
||||
|
||||
@@ -39,13 +40,13 @@ export default function EditAvailabilityDayIOS() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
||||
import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen";
|
||||
import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -28,13 +29,13 @@ export default function EditAvailabilityDay() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
@@ -73,7 +74,7 @@ export default function EditAvailabilityDay() {
|
||||
isSaving ? "text-gray-400" : "text-[#007AFF]"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
Save
|
||||
</Text>
|
||||
</HeaderButtonWrapper>
|
||||
),
|
||||
|
||||
@@ -2,10 +2,11 @@ import { osName } from "expo-device";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen.ios";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
// Semi-transparent background to prevent black flash while preserving glass effect
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
@@ -30,13 +31,13 @@ export default function EditAvailabilityHoursIOS() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
||||
import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
export default function EditAvailabilityHours() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
@@ -20,13 +21,13 @@ export default function EditAvailabilityHours() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
@@ -34,13 +35,13 @@ export default function EditAvailabilityNameIOS() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native";
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
||||
import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen";
|
||||
import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
export default function EditAvailabilityName() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
@@ -24,13 +25,13 @@ export default function EditAvailabilityName() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
@@ -92,7 +93,7 @@ export default function EditAvailabilityName() {
|
||||
isSaving ? "text-gray-400" : "text-[#007AFF]"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
Save
|
||||
</Text>
|
||||
</HeaderButtonWrapper>
|
||||
),
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
@@ -40,13 +41,13 @@ export default function EditAvailabilityOverrideIOS() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native";
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
||||
import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen";
|
||||
import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
export default function EditAvailabilityOverride() {
|
||||
const { id, overrideIndex } = useLocalSearchParams<{
|
||||
@@ -30,13 +31,13 @@ export default function EditAvailabilityOverride() {
|
||||
CalComAPIService.getScheduleById(Number(id))
|
||||
.then(setSchedule)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load schedule details");
|
||||
showErrorAlert("Error", "Failed to load schedule details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Schedule ID is missing");
|
||||
showErrorAlert("Error", "Schedule ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [id, router]);
|
||||
@@ -109,7 +110,7 @@ export default function EditAvailabilityOverride() {
|
||||
isSaving ? "text-gray-400" : "text-[#007AFF]"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
Save
|
||||
</Text>
|
||||
</HeaderButtonWrapper>
|
||||
),
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
@@ -34,13 +35,13 @@ export default function EditLocationIOS() {
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { ActivityIndicator, Platform, 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 { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
export default function EditLocation() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
@@ -23,13 +24,13 @@ export default function EditLocation() {
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -2,10 +2,11 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
|
||||
import { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
interface Attendee {
|
||||
id?: number | string;
|
||||
@@ -57,13 +58,13 @@ export default function MarkNoShowIOS() {
|
||||
setAttendees(bookingAttendees);
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { ActivityIndicator, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
||||
import MarkNoShowScreenComponent from "@/components/screens/MarkNoShowScreen";
|
||||
import { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
interface Attendee {
|
||||
id?: number | string;
|
||||
@@ -50,13 +51,13 @@ export default function MarkNoShow() {
|
||||
setAttendees(bookingAttendees);
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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
|
||||
@@ -35,13 +36,13 @@ export default function MeetingSessionDetailsIOS() {
|
||||
setSessions(sessionsData);
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load session details");
|
||||
showErrorAlert("Error", "Failed to load session details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { ActivityIndicator, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
||||
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 }>();
|
||||
@@ -22,13 +23,13 @@ export default function MeetingSessionDetails() {
|
||||
setSessions(sessionsData);
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -4,9 +4,10 @@ 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, Alert, Image, Text, TouchableOpacity, View } from "react-native";
|
||||
import { ActivityIndicator, Image, Text, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useUserProfile } from "@/hooks";
|
||||
import { showSuccessAlert } from "@/utils/alerts";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { getAvatarUrl } from "@/utils/getAvatarUrl";
|
||||
|
||||
@@ -82,7 +83,7 @@ export default function ProfileSheet() {
|
||||
if (publicPageUrl) {
|
||||
await Clipboard.setStringAsync(publicPageUrl);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert("Copied!", "Public page link copied to clipboard");
|
||||
showSuccessAlert("Copied!", "Public page link copied to clipboard");
|
||||
}
|
||||
},
|
||||
external: false,
|
||||
|
||||
@@ -2,18 +2,11 @@ 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,
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ActivityIndicator, Image, ScrollView, Text, TouchableOpacity, 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";
|
||||
|
||||
@@ -77,7 +70,7 @@ export default function ProfileSheet() {
|
||||
if (publicPageUrl) {
|
||||
await Clipboard.setStringAsync(publicPageUrl);
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert("Copied!", "Public page link copied to clipboard");
|
||||
showSuccessAlert("Copied!", "Public page link copied to clipboard");
|
||||
}
|
||||
},
|
||||
external: false,
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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
|
||||
const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
|
||||
@@ -34,13 +35,13 @@ export default function RescheduleIOS() {
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { ActivityIndicator, Platform, 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 { type Booking, CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
export default function Reschedule() {
|
||||
const { uid } = useLocalSearchParams<{ uid: string }>();
|
||||
@@ -23,13 +24,13 @@ export default function Reschedule() {
|
||||
CalComAPIService.getBookingByUid(uid)
|
||||
.then(setBooking)
|
||||
.catch(() => {
|
||||
Alert.alert("Error", "Failed to load booking details");
|
||||
showErrorAlert("Error", "Failed to load booking details");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, Alert, View } from "react-native";
|
||||
import { ActivityIndicator, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
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" {
|
||||
@@ -30,13 +31,13 @@ export default function ViewRecordingsIOS() {
|
||||
.then(setRecordings)
|
||||
.catch((error) => {
|
||||
safeLogError("Failed to load recordings:", error);
|
||||
Alert.alert("Error", "Failed to load recordings");
|
||||
showErrorAlert("Error", "Failed to load recordings");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Platform, View } from "react-native";
|
||||
import { ActivityIndicator, Platform, View } from "react-native";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { HeaderButtonWrapper } from "@/components/HeaderButtonWrapper";
|
||||
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() {
|
||||
@@ -22,13 +23,13 @@ export default function ViewRecordings() {
|
||||
.then(setRecordings)
|
||||
.catch((error) => {
|
||||
safeLogError("Failed to load recordings:", error);
|
||||
Alert.alert("Error", "Failed to load recordings");
|
||||
showErrorAlert("Error", "Failed to load recordings");
|
||||
router.back();
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Booking ID is missing");
|
||||
showErrorAlert("Error", "Booking ID is missing");
|
||||
router.back();
|
||||
}
|
||||
}, [uid, router]);
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"!**/*.d.ts",
|
||||
"!**/bun.lock",
|
||||
"!**/android",
|
||||
"!**/ios"
|
||||
"!**/ios",
|
||||
"!**/safari-extension"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
@@ -3,19 +3,13 @@
|
||||
* Reusable component for displaying and managing multiple event type locations
|
||||
*/
|
||||
|
||||
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionSheetIOS,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Modal, Platform, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
|
||||
import {
|
||||
createLocationItemFromOption,
|
||||
@@ -40,6 +34,12 @@ interface LocationsListProps {
|
||||
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 {
|
||||
@@ -54,6 +54,94 @@ function isLocationAlreadyAdded(locations: LocationItem[], optionValue: string):
|
||||
});
|
||||
}
|
||||
|
||||
const getSFSymbolForType = (type: string) => {
|
||||
switch (type) {
|
||||
case "address":
|
||||
return "mappin.and.ellipse";
|
||||
case "link":
|
||||
return "link";
|
||||
case "phone":
|
||||
return "phone";
|
||||
default:
|
||||
return "video";
|
||||
}
|
||||
};
|
||||
|
||||
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 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"
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#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 ${
|
||||
disabled ? "opacity-50" : "active:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<Text className="text-sm text-gray-500">Loading options...</Text>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="add-circle-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-2 text-sm font-medium text-gray-600">Add Location</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (Platform.OS !== "ios") {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
locations,
|
||||
onAdd,
|
||||
@@ -62,39 +150,16 @@ export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
locationOptions,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
hideAddButton = false,
|
||||
showAddModal: controlledShowAddModal,
|
||||
onShowAddModalChange,
|
||||
}) => {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [internalShowAddModal, setInternalShowAddModal] = useState(false);
|
||||
const showAddModal = controlledShowAddModal ?? internalShowAddModal;
|
||||
const setShowAddModal = onShowAddModalChange ?? setInternalShowAddModal;
|
||||
|
||||
const handleAddLocation = () => {
|
||||
if (Platform.OS === "ios") {
|
||||
const allOptions: { label: string; value: string }[] = [];
|
||||
locationOptions.forEach((group) => {
|
||||
group.options.forEach((option) => {
|
||||
if (!isLocationAlreadyAdded(locations, option.value)) {
|
||||
allOptions.push({ label: option.label, value: option.value });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const options = [...allOptions.map((o) => o.label), "Cancel"];
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex: options.length - 1,
|
||||
title: "Add Location",
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex !== options.length - 1 && buttonIndex < allOptions.length) {
|
||||
const selected = allOptions[buttonIndex];
|
||||
const newLocation = createLocationItemFromOption(selected.value, selected.label);
|
||||
onAdd(newLocation);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setShowAddModal(true);
|
||||
}
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleSelectOption = (optionValue: string, optionLabel: string) => {
|
||||
@@ -105,11 +170,15 @@ export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
|
||||
const renderLocationIcon = (location: LocationItem) => {
|
||||
if (location.iconUrl) {
|
||||
return <SvgImage uri={location.iconUrl} width={20} height={20} style={{ marginRight: 12 }} />;
|
||||
return (
|
||||
<View className="mr-3 h-8 w-8 items-center justify-center rounded-lg bg-[#F2F2F7]">
|
||||
<SvgImage uri={location.iconUrl} width={20} height={20} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={{ marginRight: 12 }}>
|
||||
<Ionicons name="location-outline" size={20} color="#6B7280" />
|
||||
<View className="mr-3 h-8 w-8 items-center justify-center rounded-lg bg-[#F2F2F7]">
|
||||
<Ionicons name="location" size={18} color="#000000" />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -142,9 +211,9 @@ export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<Text className="mb-1 text-xs text-gray-500">{label}</Text>
|
||||
<Text className="mb-1 text-[13px] text-[#6D6D72]">{label}</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
|
||||
className="rounded-lg bg-[#F2F2F7] px-3 py-2 text-[17px] text-black"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChangeText={(text) => onUpdate(location.id, { [fieldKey]: text })}
|
||||
@@ -157,56 +226,59 @@ export const LocationsList: React.FC<LocationsListProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className="bg-white">
|
||||
{/* Locations List */}
|
||||
{locations.length > 0 ? (
|
||||
<View className="mb-3 space-y-2">
|
||||
{locations.map((location, _index) => (
|
||||
<View key={location.id} className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1 flex-row items-center">
|
||||
{renderLocationIcon(location)}
|
||||
<Text className="flex-1 text-base text-gray-900" numberOfLines={1}>
|
||||
{location.displayName}
|
||||
</Text>
|
||||
<View className="mb-2">
|
||||
{locations.map((location, index) => (
|
||||
<View
|
||||
key={location.id}
|
||||
className={`pl-4 ${
|
||||
index !== locations.length - 1 ? "border-b border-[#E5E5E5]" : ""
|
||||
}`}
|
||||
>
|
||||
<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" 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>
|
||||
{!disabled ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => onRemove(location.id)}
|
||||
className="ml-2 p-1"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
{renderLocationInput(location)}
|
||||
</View>
|
||||
{renderLocationInput(location)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View className="mb-3 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4">
|
||||
<Text className="text-center text-sm text-gray-500">No locations added yet</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Add Location Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleAddLocation}
|
||||
disabled={disabled || loading}
|
||||
className={`flex-row items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 ${
|
||||
disabled ? "opacity-50" : "active:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<Text className="text-sm text-gray-500">Loading options...</Text>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="add-circle-outline" size={20} color="#6B7280" />
|
||||
<Text className="ml-2 text-sm font-medium text-gray-600">Add Location</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{!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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -41,11 +42,7 @@ export function LogoutButton({ className = "" }: LogoutButtonProps) {
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
console.debug("[LogoutButton] logout failed", { message, stack });
|
||||
}
|
||||
if (Platform.OS === "web") {
|
||||
window.alert("Failed to sign out. Please try again.");
|
||||
} else {
|
||||
Alert.alert("Error", "Failed to sign out. Please try again.");
|
||||
}
|
||||
showErrorAlert("Error", "Failed to sign out. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import { BookingListItem } from "@/components/booking-list-item/BookingListItem";
|
||||
import { RecurringBookingListItem } from "@/components/booking-list-item/RecurringBookingListItem";
|
||||
import { BookingModals } from "@/components/booking-modals/BookingModals";
|
||||
import { EmptyScreen } from "@/components/EmptyScreen";
|
||||
import { LoadingSpinner } from "@/components/LoadingSpinner";
|
||||
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -123,6 +126,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
rejectReason,
|
||||
setRejectReason,
|
||||
showCancelModal,
|
||||
cancelBooking,
|
||||
cancelReason,
|
||||
setCancelReason,
|
||||
handleSubmitCancel,
|
||||
@@ -370,11 +374,14 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "All remaining bookings have been cancelled.");
|
||||
showSuccessAlert(
|
||||
"Success",
|
||||
"All remaining bookings have been cancelled."
|
||||
);
|
||||
setIsCancellingAll(false);
|
||||
},
|
||||
onError: () => {
|
||||
Alert.alert("Error", "Failed to cancel bookings. Please try again.");
|
||||
showErrorAlert("Error", "Failed to cancel bookings. Please try again.");
|
||||
setIsCancellingAll(false);
|
||||
},
|
||||
}
|
||||
@@ -405,7 +412,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
);
|
||||
|
||||
if (unconfirmedBookings.length === 0) {
|
||||
Alert.alert("Info", "No unconfirmed bookings to confirm.");
|
||||
showInfoAlert("Info", "No unconfirmed bookings to confirm.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -448,12 +455,12 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
|
||||
setIsConfirmingAll(false);
|
||||
if (errorCount > 0) {
|
||||
Alert.alert(
|
||||
showInfoAlert(
|
||||
"Partial Success",
|
||||
`Confirmed ${successCount} bookings. Failed to confirm ${errorCount}.`
|
||||
);
|
||||
} else {
|
||||
Alert.alert("Success", `All ${successCount} bookings have been confirmed.`);
|
||||
showSuccessAlert("Success", `All ${successCount} bookings have been confirmed.`);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -474,7 +481,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
);
|
||||
|
||||
if (unconfirmedBookings.length === 0) {
|
||||
Alert.alert("Info", "No unconfirmed bookings to reject.");
|
||||
showInfoAlert("Info", "No unconfirmed bookings to reject.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -539,12 +546,15 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
|
||||
setIsDecliningAll(false);
|
||||
if (errorCount > 0) {
|
||||
Alert.alert(
|
||||
showInfoAlert(
|
||||
"Partial Success",
|
||||
`Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
|
||||
);
|
||||
} else {
|
||||
Alert.alert("Success", `All ${successCount} bookings have been rejected.`);
|
||||
showSuccessAlert(
|
||||
"Success",
|
||||
`All ${successCount} bookings have been rejected.`
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -578,7 +588,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
onMeetingSessionDetails={handleNavigateToMeetingSessionDetails}
|
||||
onMarkNoShow={handleNavigateToMarkNoShow}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={handleCancelBooking}
|
||||
/>
|
||||
@@ -612,7 +622,7 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
onMeetingSessionDetails={handleNavigateToMeetingSessionDetails}
|
||||
onMarkNoShow={handleNavigateToMarkNoShow}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={handleCancelBooking}
|
||||
/>
|
||||
@@ -772,6 +782,73 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
onMarkNoShow={() => {}}
|
||||
/>
|
||||
|
||||
{/* Web/Extension: Cancel Event Modal */}
|
||||
{Platform.OS === "web" && (
|
||||
<FullScreenModal
|
||||
visible={showCancelModal}
|
||||
animationType="fade"
|
||||
onRequestClose={handleCloseCancelModal}
|
||||
>
|
||||
<View className="flex-1 items-center justify-center bg-black/50 p-4">
|
||||
<View className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||
<View className="p-6">
|
||||
<View className="flex-row">
|
||||
{/* Danger icon */}
|
||||
<View className="mr-3 self-start rounded-full bg-red-50 p-2">
|
||||
<Ionicons name="alert-circle" size={20} color="#800000" />
|
||||
</View>
|
||||
|
||||
{/* Title and description */}
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-900">Cancel Event</Text>
|
||||
<Text className="text-sm leading-5 text-gray-600">
|
||||
Are you sure you want to cancel "{cancelBooking?.title}"? Cancellation reason
|
||||
will be shared with guests.
|
||||
</Text>
|
||||
|
||||
{/* Reason Input */}
|
||||
<View className="mt-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-700">
|
||||
Reason for cancellation
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-base text-gray-900"
|
||||
placeholder="Why are you cancelling?"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={cancelReason}
|
||||
onChangeText={setCancelReason}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="flex-row-reverse gap-2 px-6 pb-6 pt-2">
|
||||
<TouchableOpacity
|
||||
className="rounded-lg px-4 py-2.5"
|
||||
style={{ backgroundColor: "#111827" }}
|
||||
onPress={handleSubmitCancel}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-white">Cancel Event</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2.5"
|
||||
onPress={handleCloseCancelModal}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Nevermind</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
)}
|
||||
|
||||
{/* Cancel Event AlertDialog for Android */}
|
||||
{Platform.OS === "android" && (
|
||||
<AlertDialog open={showCancelModal} onOpenChange={handleCloseCancelModal}>
|
||||
@@ -864,13 +941,13 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
{ uid: cancelAllGroup.recurringBookingUid, reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "All remaining bookings have been cancelled.");
|
||||
showSuccessAlert("Success", "All remaining bookings have been cancelled.");
|
||||
setIsCancellingAll(false);
|
||||
setCancelAllGroup(null);
|
||||
setCancelAllReason("");
|
||||
},
|
||||
onError: () => {
|
||||
Alert.alert("Error", "Failed to cancel bookings. Please try again.");
|
||||
showErrorAlert("Error", "Failed to cancel bookings. Please try again.");
|
||||
setIsCancellingAll(false);
|
||||
},
|
||||
}
|
||||
@@ -968,12 +1045,12 @@ export const BookingListScreen: React.FC<BookingListScreenProps> = ({
|
||||
setRejectAllReason("");
|
||||
|
||||
if (errorCount > 0) {
|
||||
Alert.alert(
|
||||
showInfoAlert(
|
||||
"Partial Success",
|
||||
`Rejected ${successCount} bookings. Failed to reject ${errorCount}.`
|
||||
);
|
||||
} else {
|
||||
Alert.alert("Success", `All ${successCount} bookings have been rejected.`);
|
||||
showSuccessAlert("Success", `All ${successCount} bookings have been rejected.`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ActivityIndicator, Alert, ScrollView, Text, TouchableOpacity, View } fr
|
||||
import { BookingActionsModal } from "@/components/BookingActionsModal";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import type { Booking, EventType } from "@/services/calcom";
|
||||
import { showInfoAlert } from "@/utils/alerts";
|
||||
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
|
||||
|
||||
const EMPTY_ACTIONS: BookingActionsResult = {
|
||||
@@ -225,7 +226,7 @@ export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
}
|
||||
}}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={onCancel}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import { BookingActionsModal } from "@/components/BookingActionsModal";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import type { Booking, EventType } from "@/services/calcom";
|
||||
import { showInfoAlert } from "@/utils/alerts";
|
||||
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
|
||||
|
||||
// Empty actions result for when no booking is selected
|
||||
@@ -207,7 +207,7 @@ export const BookingModals: React.FC<BookingModalsProps> = ({
|
||||
}
|
||||
}}
|
||||
onReportBooking={() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
||||
}}
|
||||
onCancelBooking={onCancel}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* AvailabilityTab Component
|
||||
*
|
||||
* Displays schedule selection and weekly availability in iOS Settings style.
|
||||
* Matches the beautiful styling from AvailabilityDetailScreen.
|
||||
*/
|
||||
|
||||
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
|
||||
interface DaySchedule {
|
||||
@@ -18,82 +28,248 @@ interface AvailabilityTabProps {
|
||||
getDaySchedules: () => DaySchedule[];
|
||||
formatTime: (time: string) => string;
|
||||
selectedTimezone: string;
|
||||
schedules: Schedule[];
|
||||
setSelectedSchedule: (schedule: Schedule) => void;
|
||||
}
|
||||
|
||||
const sectionDividerStyle = {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#E5E5EA",
|
||||
marginLeft: -20,
|
||||
marginRight: -20,
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
// 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}`;
|
||||
};
|
||||
|
||||
export function AvailabilityTab(props: AvailabilityTabProps) {
|
||||
// Section header
|
||||
function SectionHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<View className="rounded-2xl bg-white p-5">
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Availability</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
<Text
|
||||
className="mb-2 ml-4 text-[13px] uppercase tracking-wide text-[#6D6D72]"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings group container
|
||||
function SettingsGroup({ children, header }: { children: React.ReactNode; header?: string }) {
|
||||
return (
|
||||
<View>
|
||||
{header ? <SectionHeader title={header} /> : null}
|
||||
<View className="overflow-hidden rounded-[14px] bg-white">{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigation row
|
||||
function NavigationRow({
|
||||
title,
|
||||
value,
|
||||
onPress,
|
||||
isLast = false,
|
||||
disabled = false,
|
||||
options,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
value?: string;
|
||||
onPress: () => void;
|
||||
isLast?: boolean;
|
||||
disabled?: boolean;
|
||||
options?: { label: string; value: string }[];
|
||||
onSelect?: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="bg-white pl-4" style={{ height: 44 }}>
|
||||
<View
|
||||
className={`flex-1 flex-row items-center justify-between pr-4 ${
|
||||
!isLast ? "border-b border-[#E5E5E5]" : ""
|
||||
}`}
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<Text className="text-[17px] text-black" style={{ fontWeight: "400" }}>
|
||||
{title}
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
{Platform.OS === "ios" && options && onSelect ? (
|
||||
<>
|
||||
{value ? (
|
||||
<Text className="mr-2 text-[17px] text-[#8E8E93]" numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
<IOSPickerTrigger options={options} selectedValue={value || ""} onSelect={onSelect} />
|
||||
</>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={onPress}
|
||||
activeOpacity={0.5}
|
||||
disabled={disabled}
|
||||
>
|
||||
{value ? (
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]" numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// iOS Native Picker trigger component
|
||||
function IOSPickerTrigger({
|
||||
options,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
}: {
|
||||
options: { label: string; value: string }[];
|
||||
selectedValue: string;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
|
||||
onPress={() => onSelect(opt.value)}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvailabilityTab(props: AvailabilityTabProps) {
|
||||
const daySchedules = props.getDaySchedules();
|
||||
const enabledDaysCount = daySchedules.filter((d) => d.available).length;
|
||||
|
||||
return (
|
||||
<View className="gap-6">
|
||||
{/* Schedule Selector */}
|
||||
<SettingsGroup header="Schedule">
|
||||
<NavigationRow
|
||||
title="Schedule"
|
||||
value={
|
||||
props.schedulesLoading
|
||||
? "Loading..."
|
||||
: props.selectedSchedule?.name || "Select schedule"
|
||||
}
|
||||
onPress={() => props.setShowScheduleDropdown(true)}
|
||||
disabled={props.schedulesLoading}
|
||||
>
|
||||
<Text className="text-base text-black">
|
||||
{props.schedulesLoading
|
||||
? "Loading schedules..."
|
||||
: props.selectedSchedule
|
||||
? props.selectedSchedule.name
|
||||
: "Select schedule"}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
options={props.schedules.map((s) => ({ label: s.name, value: s.id.toString() }))}
|
||||
onSelect={(id) => {
|
||||
const schedule = props.schedules.find((s) => s.id.toString() === id);
|
||||
if (schedule) props.setSelectedSchedule(schedule);
|
||||
}}
|
||||
isLast
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Weekly Schedule */}
|
||||
{props.selectedSchedule ? (
|
||||
<>
|
||||
<View className="mt-5 pt-5" style={sectionDividerStyle}>
|
||||
{props.scheduleDetailsLoading ? (
|
||||
<View className="items-center py-4">
|
||||
<Text className="text-sm italic text-[#8E8E93]">Loading schedule details...</Text>
|
||||
</View>
|
||||
) : props.selectedScheduleDetails ? (
|
||||
props.getDaySchedules().map((dayInfo) => (
|
||||
<View key={dayInfo.day} className="flex-row items-center justify-between py-4">
|
||||
<Text
|
||||
className={`ml-2 flex-1 text-[15px] font-medium text-[#333] ${
|
||||
!dayInfo.available ? "text-[#8E8E93] line-through" : ""
|
||||
}`}
|
||||
>
|
||||
{dayInfo.day}
|
||||
</Text>
|
||||
<Text className="mr-4 text-right text-[15px] text-[#666]">
|
||||
{dayInfo.available && dayInfo.startTime && dayInfo.endTime
|
||||
? `${props.formatTime(dayInfo.startTime)} - ${props.formatTime(
|
||||
dayInfo.endTime
|
||||
)}`
|
||||
: "Unavailable"}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<View className="items-center py-4">
|
||||
<Text className="text-sm italic text-[#8E8E93]">
|
||||
Failed to load schedule details
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mt-5 pt-5" style={sectionDividerStyle}>
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Timezone</Text>
|
||||
<View className="items-center rounded-lg bg-[#F8F9FA] px-3 py-3">
|
||||
<Text className="text-center text-base text-[#666]">
|
||||
{props.selectedTimezone || props.selectedScheduleDetails?.timeZone || "No timezone"}
|
||||
<SettingsGroup>
|
||||
{/* Header row */}
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Weekly Schedule</Text>
|
||||
<Text className="text-[17px] text-[#8E8E93]">
|
||||
{enabledDaysCount} {enabledDaysCount === 1 ? "day" : "days"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
|
||||
{/* Day rows */}
|
||||
{props.scheduleDetailsLoading ? (
|
||||
<View className="items-center bg-white py-6">
|
||||
<Text className="text-[15px] italic text-[#8E8E93]">Loading schedule details...</Text>
|
||||
</View>
|
||||
) : props.selectedScheduleDetails ? (
|
||||
<View className="bg-white px-4 py-2">
|
||||
{DAYS.map((day, index) => {
|
||||
const dayInfo = daySchedules.find((d) => d.day === day);
|
||||
const isEnabled = dayInfo?.available ?? false;
|
||||
const isLast = index === DAYS.length - 1;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={day}
|
||||
className={`flex-row items-center py-3 ${!isLast ? "border-b border-[#E5E5E5]" : ""}`}
|
||||
>
|
||||
{/* Availability indicator */}
|
||||
<View
|
||||
className={`mr-3 h-2.5 w-2.5 rounded-full ${
|
||||
isEnabled ? "bg-[#34C759]" : "bg-[#E5E5EA]"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Day name */}
|
||||
<Text
|
||||
className={`w-24 text-[15px] font-medium ${
|
||||
isEnabled ? "text-black" : "text-[#8E8E93]"
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</Text>
|
||||
|
||||
{/* Time range or Unavailable */}
|
||||
<Text
|
||||
className={`flex-1 text-right text-[15px] ${
|
||||
isEnabled ? "text-black" : "text-[#8E8E93]"
|
||||
}`}
|
||||
>
|
||||
{isEnabled && dayInfo?.startTime && dayInfo?.endTime
|
||||
? `${formatTime12Hour(dayInfo.startTime)} - ${formatTime12Hour(dayInfo.endTime)}`
|
||||
: "Unavailable"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View className="items-center bg-white py-6">
|
||||
<Text className="text-[15px] italic text-[#8E8E93]">
|
||||
Failed to load schedule details
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
) : null}
|
||||
|
||||
{/* Timezone */}
|
||||
{props.selectedSchedule ? (
|
||||
<SettingsGroup header="Timezone">
|
||||
<View className="bg-white px-4 py-3">
|
||||
<Text className="text-center text-[17px] text-[#666]">
|
||||
{props.selectedTimezone || props.selectedScheduleDetails?.timeZone || "No timezone"}
|
||||
</Text>
|
||||
</View>
|
||||
</SettingsGroup>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
/**
|
||||
* BasicsTab Component
|
||||
*
|
||||
* iOS Settings style with grouped input rows and section headers.
|
||||
*/
|
||||
|
||||
import { LocationsList } from "@/components/LocationsList";
|
||||
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { LocationsList, AddLocationTrigger } from "@/components/LocationsList";
|
||||
import { createLocationItemFromOption } from "@/utils/locationHelpers";
|
||||
import type { LocationItem, LocationOptionGroup } from "@/types/locations";
|
||||
import { slugify } from "@/utils/slugify";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface BasicsTabProps {
|
||||
// Title, description, URL
|
||||
eventTitle: string;
|
||||
setEventTitle: (value: string) => void;
|
||||
eventDescription: string;
|
||||
@@ -14,8 +25,6 @@ interface BasicsTabProps {
|
||||
username: string;
|
||||
eventSlug: string;
|
||||
setEventSlug: (value: string) => void;
|
||||
|
||||
// Duration
|
||||
allowMultipleDurations: boolean;
|
||||
setAllowMultipleDurations: (value: boolean) => void;
|
||||
eventDuration: string;
|
||||
@@ -23,9 +32,8 @@ interface BasicsTabProps {
|
||||
selectedDurations: string[];
|
||||
setShowDurationDropdown: (show: boolean) => void;
|
||||
defaultDuration: string;
|
||||
setDefaultDuration: (value: string) => void;
|
||||
setShowDefaultDurationDropdown: (show: boolean) => void;
|
||||
|
||||
// Multiple locations support
|
||||
locations: LocationItem[];
|
||||
onAddLocation: (location: LocationItem) => void;
|
||||
onRemoveLocation: (locationId: string) => void;
|
||||
@@ -34,124 +42,333 @@ interface BasicsTabProps {
|
||||
conferencingLoading: boolean;
|
||||
}
|
||||
|
||||
export function BasicsTab(props: BasicsTabProps) {
|
||||
// Section header
|
||||
function SectionHeader({ title, rightElement }: { title: string; rightElement?: React.ReactNode }) {
|
||||
return (
|
||||
<View className="gap-3">
|
||||
{/* Title and Description Card */}
|
||||
<View className="rounded-2xl bg-white p-5">
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Title</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
value={props.eventTitle}
|
||||
onChangeText={props.setEventTitle}
|
||||
placeholder="Enter event title"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between mb-2 px-4">
|
||||
<Text
|
||||
className="text-[13px] uppercase tracking-wide text-[#6D6D72]"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{rightElement}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Description</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-black"
|
||||
style={{ height: 100, textAlignVertical: "top" }}
|
||||
value={props.eventDescription}
|
||||
onChangeText={props.setEventDescription}
|
||||
placeholder="Enter event description"
|
||||
placeholderTextColor="#8E8E93"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
// Settings group container
|
||||
function SettingsGroup({
|
||||
children,
|
||||
header,
|
||||
headerRight,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
header?: string;
|
||||
headerRight?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{header ? <SectionHeader title={header} rightElement={headerRight} /> : null}
|
||||
<View className="overflow-hidden rounded-[14px] bg-white">{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">URL</Text>
|
||||
<View className="flex-row items-center overflow-hidden rounded-lg border border-[#E5E5EA] bg-[#F8F9FA]">
|
||||
<Text className="rounded-bl-lg rounded-tl-lg bg-[#E5E5EA] px-3 py-3 text-base text-[#666]">
|
||||
cal.com/{props.username}/
|
||||
</Text>
|
||||
<TextInput
|
||||
className="flex-1 px-3 py-3 text-base text-black"
|
||||
value={props.eventSlug}
|
||||
onChangeText={(text) => props.setEventSlug(slugify(text, true))}
|
||||
placeholder="event-slug"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
</View>
|
||||
// Input row with label
|
||||
function InputRow({
|
||||
label,
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
multiline = false,
|
||||
numberOfLines = 1,
|
||||
keyboardType = "default",
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder: string;
|
||||
multiline?: boolean;
|
||||
numberOfLines?: number;
|
||||
keyboardType?: "default" | "numeric" | "email-address" | "url";
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className={`pr-4 ${!isLast ? "border-b border-[#E5E5E5]" : ""} ${
|
||||
isFirst ? "pt-4 pb-3" : isLast ? "pt-3 pb-4" : "py-3"
|
||||
}`}
|
||||
>
|
||||
<Text className="mb-2 text-[13px] text-[#6D6D72]">{label}</Text>
|
||||
<TextInput
|
||||
className="rounded-lg bg-[#F2F2F7] px-3 py-2 text-[17px] text-black"
|
||||
style={multiline ? { height: 80, textAlignVertical: "top" } : undefined}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#8E8E93"
|
||||
multiline={multiline}
|
||||
numberOfLines={numberOfLines}
|
||||
keyboardType={keyboardType}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigation row with value and chevron
|
||||
function NavigationRow({
|
||||
title,
|
||||
value,
|
||||
onPress,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
options,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string;
|
||||
value?: string;
|
||||
onPress: () => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
options?: string[];
|
||||
onSelect?: (value: string) => void;
|
||||
}) {
|
||||
const height = isFirst || isLast ? 52 : 44;
|
||||
return (
|
||||
<View className="bg-white pl-4" style={{ height }}>
|
||||
<View
|
||||
className={`flex-1 flex-row items-center justify-between pr-4 ${
|
||||
!isLast ? "border-b border-[#E5E5E5]" : ""
|
||||
}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<Text className="text-[17px] text-black" style={{ fontWeight: "400" }}>
|
||||
{title}
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
{Platform.OS === "ios" && options && onSelect ? (
|
||||
<>
|
||||
{value ? (
|
||||
<Text className="mr-2 text-[17px] text-[#8E8E93]" numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
<IOSPickerTrigger options={options} selectedValue={value || ""} onSelect={onSelect} />
|
||||
</>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={onPress}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
{value ? (
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]" numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Duration Card */}
|
||||
<View className="rounded-2xl bg-white p-5">
|
||||
{!props.allowMultipleDurations ? (
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Duration</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
// iOS Native Picker trigger component
|
||||
function IOSPickerTrigger({
|
||||
options,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
}: {
|
||||
options: string[];
|
||||
selectedValue: string;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
systemImage={selectedValue === opt ? "checkmark" : undefined}
|
||||
onPress={() => onSelect(opt)}
|
||||
label={opt}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle row
|
||||
function SettingRow({
|
||||
title,
|
||||
value,
|
||||
onValueChange,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
}: {
|
||||
title: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}) {
|
||||
const height = isFirst || isLast ? 52 : 44;
|
||||
return (
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className={`flex-row items-center pr-4 ${!isLast ? "border-b border-[#E5E5E5]" : ""}`}
|
||||
style={{ height }}
|
||||
>
|
||||
<Text className="flex-1 text-[17px] text-black" style={{ fontWeight: "400" }}>
|
||||
{title}
|
||||
</Text>
|
||||
<View style={{ alignSelf: "center" }}>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: "#E9E9EA", true: "#000000" }}
|
||||
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const BasicsTab: React.FC<BasicsTabProps> = (props) => {
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
|
||||
const onSelectLocation = (value: string, label: string) => {
|
||||
const newLocation = createLocationItemFromOption(value, label);
|
||||
props.onAddLocation(newLocation);
|
||||
setShowLocationModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="gap-6">
|
||||
{/* Event Details */}
|
||||
<SettingsGroup>
|
||||
<InputRow
|
||||
isFirst
|
||||
label="Title"
|
||||
value={props.eventTitle}
|
||||
onChangeText={props.setEventTitle}
|
||||
placeholder="Enter event title"
|
||||
/>
|
||||
<InputRow
|
||||
label="Description"
|
||||
value={props.eventDescription}
|
||||
onChangeText={props.setEventDescription}
|
||||
placeholder="Enter event description"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
{/* URL Input */}
|
||||
<View className="bg-white pl-4">
|
||||
<View className="pr-4 pt-3 pb-4">
|
||||
<Text className="mb-2 text-[13px] text-[#6D6D72]">URL</Text>
|
||||
<View className="flex-row items-center overflow-hidden rounded-lg bg-[#F2F2F7]">
|
||||
<Text className="bg-[#E5E5EA] px-3 py-2 text-[15px] text-[#666]">
|
||||
cal.com/{props.username}/
|
||||
</Text>
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={props.eventDuration}
|
||||
onChangeText={props.setEventDuration}
|
||||
placeholder="30"
|
||||
className="flex-1 px-3 py-2 text-[17px] text-black"
|
||||
value={props.eventSlug}
|
||||
onChangeText={(text) => props.setEventSlug(slugify(text, true))}
|
||||
placeholder="event-slug"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-base text-[#666]">Minutes</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsGroup>
|
||||
|
||||
{props.allowMultipleDurations ? (
|
||||
<>
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">
|
||||
Available durations
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowDurationDropdown(true)}
|
||||
>
|
||||
<Text className="text-base text-black">
|
||||
{props.selectedDurations.length > 0
|
||||
? `${props.selectedDurations.length} duration${
|
||||
props.selectedDurations.length > 1 ? "s" : ""
|
||||
} selected`
|
||||
: "Select durations"}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{props.selectedDurations.length > 0 ? (
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Default duration</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowDefaultDurationDropdown(true)}
|
||||
>
|
||||
<Text className="text-base text-black">
|
||||
{props.defaultDuration || "Select default duration"}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
{/* Duration */}
|
||||
<SettingsGroup header="Duration">
|
||||
{!props.allowMultipleDurations ? (
|
||||
<View className="bg-white pl-4" style={{ height: 52 }}>
|
||||
<View
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ height: 52 }}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Duration</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-16 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={props.eventDuration}
|
||||
onChangeText={props.setEventDuration}
|
||||
placeholder="30"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-[15px] text-[#6D6D72]">minutes</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<NavigationRow
|
||||
isFirst
|
||||
title="Available durations"
|
||||
value={
|
||||
props.selectedDurations.length > 0
|
||||
? `${props.selectedDurations.length} selected`
|
||||
: "Select"
|
||||
}
|
||||
onPress={() => props.setShowDurationDropdown(true)}
|
||||
/>
|
||||
{props.selectedDurations.length > 0 ? (
|
||||
<NavigationRow
|
||||
title="Default duration"
|
||||
value={props.defaultDuration || "Select"}
|
||||
onPress={() => props.setShowDefaultDurationDropdown(true)}
|
||||
options={props.selectedDurations}
|
||||
onSelect={props.setDefaultDuration}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
<SettingRow
|
||||
title="Multiple durations"
|
||||
value={props.allowMultipleDurations}
|
||||
onValueChange={props.setAllowMultipleDurations}
|
||||
isLast
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
<View className="flex-row items-start justify-between">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Allow multiple durations</Text>
|
||||
<Switch
|
||||
value={props.allowMultipleDurations}
|
||||
onValueChange={props.setAllowMultipleDurations}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
{/* Locations */}
|
||||
<SettingsGroup
|
||||
header="Locations"
|
||||
headerRight={
|
||||
<AddLocationTrigger
|
||||
isHeader
|
||||
locations={props.locations}
|
||||
locationOptions={props.locationOptions}
|
||||
onSelectOption={onSelectLocation}
|
||||
onPressFallback={() => setShowLocationModal(true)}
|
||||
disabled={props.conferencingLoading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Location Card */}
|
||||
<View className="rounded-2xl bg-white p-5">
|
||||
<Text className="mb-3 text-base font-semibold text-[#333]">Locations</Text>
|
||||
}
|
||||
>
|
||||
<LocationsList
|
||||
locations={props.locations}
|
||||
onAdd={props.onAddLocation}
|
||||
@@ -159,8 +376,11 @@ export function BasicsTab(props: BasicsTabProps) {
|
||||
onUpdate={props.onUpdateLocation}
|
||||
locationOptions={props.locationOptions}
|
||||
loading={props.conferencingLoading}
|
||||
hideAddButton={true}
|
||||
showAddModal={showLocationModal}
|
||||
onShowAddModalChange={setShowLocationModal}
|
||||
/>
|
||||
</View>
|
||||
</SettingsGroup>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,26 @@
|
||||
/**
|
||||
* LimitsTab Component
|
||||
*
|
||||
* Settings app style with grouped rows, section headers, and compact toggles.
|
||||
* Uses native iOS ContextMenu for pickers on iOS, Modal-based dropdowns on Android/Web.
|
||||
*/
|
||||
|
||||
import { Button, ContextMenu, Host, HStack, Image, Text as SwiftUIText } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Animated, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
Platform,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
|
||||
interface FrequencyLimit {
|
||||
id: number;
|
||||
@@ -13,24 +34,243 @@ interface DurationLimit {
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// Buffer time options
|
||||
const BUFFER_TIME_OPTIONS = [
|
||||
"No buffer time",
|
||||
"5 Minutes",
|
||||
"10 Minutes",
|
||||
"15 Minutes",
|
||||
"20 Minutes",
|
||||
"30 Minutes",
|
||||
"45 Minutes",
|
||||
"60 Minutes",
|
||||
"90 Minutes",
|
||||
"120 Minutes",
|
||||
];
|
||||
|
||||
// Time unit options
|
||||
const TIME_UNIT_OPTIONS = ["Minutes", "Hours", "Days"];
|
||||
|
||||
// Slot interval options
|
||||
const SLOT_INTERVAL_OPTIONS = [
|
||||
"Default",
|
||||
"5 Minutes",
|
||||
"10 Minutes",
|
||||
"15 Minutes",
|
||||
"20 Minutes",
|
||||
"30 Minutes",
|
||||
"45 Minutes",
|
||||
"60 Minutes",
|
||||
"75 Minutes",
|
||||
"90 Minutes",
|
||||
"105 Minutes",
|
||||
"120 Minutes",
|
||||
];
|
||||
|
||||
// Frequency unit options
|
||||
const FREQUENCY_UNIT_OPTIONS = ["Per day", "Per week", "Per month", "Per year"];
|
||||
|
||||
// Duration unit options
|
||||
const DURATION_UNIT_OPTIONS = ["Per day", "Per week", "Per month"];
|
||||
|
||||
// Section header
|
||||
function SectionHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<Text
|
||||
className="mb-2 ml-4 text-[13px] uppercase tracking-wide text-[#6D6D72]"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings group container
|
||||
function SettingsGroup({
|
||||
children,
|
||||
header,
|
||||
footer,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
header?: string;
|
||||
footer?: string;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{header ? <SectionHeader title={header} /> : null}
|
||||
<View className="overflow-hidden rounded-[14px] bg-white">{children}</View>
|
||||
{footer ? <Text className="ml-4 mt-2 text-[13px] text-[#6D6D72]">{footer}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle row with indented separator
|
||||
function SettingRow({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
isLast = false,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
isLast?: boolean;
|
||||
}) {
|
||||
const showDescription = () => {
|
||||
if (!description) return;
|
||||
Alert.alert(title, description, [{ text: "OK", style: "cancel" }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className={`flex-row items-center pr-4 ${!isLast ? "border-b border-[#E5E5E5]" : ""}`}
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 flex-row items-center"
|
||||
style={{ height: 44 }}
|
||||
onPress={description ? showDescription : undefined}
|
||||
activeOpacity={description ? 0.7 : 1}
|
||||
disabled={!description}
|
||||
>
|
||||
<Text className="text-[17px] text-black" style={{ fontWeight: "400" }}>
|
||||
{title}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Ionicons name="chevron-down" size={12} color="#C7C7CC" style={{ marginLeft: 6 }} />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
<View style={{ alignSelf: "center" }}>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: "#E9E9EA", true: "#000000" }}
|
||||
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// iOS Native Picker using ContextMenu - only renders the chevron trigger
|
||||
function IOSPickerTrigger({
|
||||
options,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
}: {
|
||||
options: string[];
|
||||
selectedValue: string;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
systemImage={selectedValue === opt ? "checkmark" : undefined}
|
||||
onPress={() => onSelect(opt)}
|
||||
label={opt}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Android/Web picker button (opens modal)
|
||||
function ModalPickerButton({ value, onPress }: { value: string; onPress: () => void }) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center rounded-lg bg-[#F2F2F7] px-2 py-1.5"
|
||||
onPress={onPress}
|
||||
>
|
||||
<Text className="text-[15px] text-black">{value}</Text>
|
||||
<Ionicons name="chevron-down" size={14} color="#8E8E93" style={{ marginLeft: 4 }} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigation row with native iOS picker or modal button
|
||||
function PickerNavigationRow({
|
||||
title,
|
||||
value,
|
||||
options,
|
||||
onSelect,
|
||||
onPressModal,
|
||||
isLast = false,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
options: string[];
|
||||
onSelect: (value: string) => void;
|
||||
onPressModal: () => void;
|
||||
isLast?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View className="bg-white pl-4" style={{ height: 44 }}>
|
||||
<View
|
||||
className={`flex-1 flex-row items-center justify-between pr-4 ${
|
||||
!isLast ? "border-b border-[#E5E5E5]" : ""
|
||||
}`}
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<Text className="text-[17px] text-black" style={{ fontWeight: "400" }}>
|
||||
{title}
|
||||
</Text>
|
||||
<View className="flex-row items-center">
|
||||
{Platform.OS === "ios" ? (
|
||||
<>
|
||||
<Text className="mr-2 text-[17px] text-[#8E8E93]">{value}</Text>
|
||||
<IOSPickerTrigger options={options} selectedValue={value} onSelect={onSelect} />
|
||||
</>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={onPressModal}
|
||||
activeOpacity={0.5}
|
||||
>
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]" numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color="#C7C7CC" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface LimitsTabProps {
|
||||
// Buffer times
|
||||
beforeEventBuffer: string;
|
||||
setBeforeEventBuffer: (value: string) => void;
|
||||
setShowBeforeBufferDropdown: (show: boolean) => void;
|
||||
afterEventBuffer: string;
|
||||
setAfterEventBuffer: (value: string) => void;
|
||||
setShowAfterBufferDropdown: (show: boolean) => void;
|
||||
|
||||
// Minimum notice
|
||||
minimumNoticeValue: string;
|
||||
setMinimumNoticeValue: (value: string) => void;
|
||||
minimumNoticeUnit: string;
|
||||
setMinimumNoticeUnit: (value: string) => void;
|
||||
setShowMinimumNoticeUnitDropdown: (show: boolean) => void;
|
||||
|
||||
// Slot interval
|
||||
slotInterval: string;
|
||||
setSlotInterval: (value: string) => void;
|
||||
setShowSlotIntervalDropdown: (show: boolean) => void;
|
||||
|
||||
// Booking frequency
|
||||
limitBookingFrequency: boolean;
|
||||
toggleBookingFrequency: (value: boolean) => void;
|
||||
frequencyAnimationValue: Animated.Value;
|
||||
@@ -39,12 +279,8 @@ interface LimitsTabProps {
|
||||
setShowFrequencyUnitDropdown: (id: number) => void;
|
||||
removeFrequencyLimit: (id: number) => void;
|
||||
addFrequencyLimit: () => void;
|
||||
|
||||
// Only show first slot
|
||||
onlyShowFirstAvailableSlot: boolean;
|
||||
setOnlyShowFirstAvailableSlot: (value: boolean) => void;
|
||||
|
||||
// Total duration
|
||||
limitTotalDuration: boolean;
|
||||
toggleTotalDuration: (value: boolean) => void;
|
||||
durationAnimationValue: Animated.Value;
|
||||
@@ -53,16 +289,12 @@ interface LimitsTabProps {
|
||||
setShowDurationUnitDropdown: (id: number) => void;
|
||||
removeDurationLimit: (id: number) => void;
|
||||
addDurationLimit: () => void;
|
||||
|
||||
// Max active bookings
|
||||
maxActiveBookingsPerBooker: boolean;
|
||||
setMaxActiveBookingsPerBooker: (value: boolean) => void;
|
||||
maxActiveBookingsValue: string;
|
||||
setMaxActiveBookingsValue: (value: string) => void;
|
||||
offerReschedule: boolean;
|
||||
setOfferReschedule: (value: boolean) => void;
|
||||
|
||||
// Future bookings
|
||||
limitFutureBookings: boolean;
|
||||
setLimitFutureBookings: (value: boolean) => void;
|
||||
futureBookingType: "rolling" | "range";
|
||||
@@ -79,115 +311,120 @@ interface LimitsTabProps {
|
||||
|
||||
export function LimitsTab(props: LimitsTabProps) {
|
||||
return (
|
||||
<View className="gap-3">
|
||||
{/* Buffer Time, Minimum Notice, and Slot Interval Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="mb-3">
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Before event</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowBeforeBufferDropdown(true)}
|
||||
<View className="gap-6">
|
||||
{/* Buffer & Notice Settings */}
|
||||
<SettingsGroup header="Buffer & Notice">
|
||||
<PickerNavigationRow
|
||||
title="Before event"
|
||||
value={props.beforeEventBuffer}
|
||||
options={BUFFER_TIME_OPTIONS}
|
||||
onSelect={props.setBeforeEventBuffer}
|
||||
onPressModal={() => props.setShowBeforeBufferDropdown(true)}
|
||||
/>
|
||||
<PickerNavigationRow
|
||||
title="After event"
|
||||
value={props.afterEventBuffer}
|
||||
options={BUFFER_TIME_OPTIONS}
|
||||
onSelect={props.setAfterEventBuffer}
|
||||
onPressModal={() => props.setShowAfterBufferDropdown(true)}
|
||||
/>
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<Text className="text-base text-black">{props.beforeEventBuffer}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="mb-3"
|
||||
style={{ borderTopWidth: 1, borderTopColor: "#E5E5EA", paddingTop: 12, marginTop: 12 }}
|
||||
>
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">After event</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowAfterBufferDropdown(true)}
|
||||
>
|
||||
<Text className="text-base text-black">{props.afterEventBuffer}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="mb-3"
|
||||
style={{ borderTopWidth: 1, borderTopColor: "#E5E5EA", paddingTop: 12, marginTop: 12 }}
|
||||
>
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Minimum Notice</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={props.minimumNoticeValue}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue, 10) || 0;
|
||||
if (num >= 0) {
|
||||
props.setMinimumNoticeValue(numericValue || "0");
|
||||
}
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowMinimumNoticeUnitDropdown(true)}
|
||||
>
|
||||
<Text className="text-base text-black">{props.minimumNoticeUnit}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
<Text className="text-[17px] text-black">Minimum Notice</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-16 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={props.minimumNoticeValue}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue, 10) || 0;
|
||||
if (num >= 0) {
|
||||
props.setMinimumNoticeValue(numericValue || "0");
|
||||
}
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
{Platform.OS === "ios" ? (
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]">{props.minimumNoticeUnit}</Text>
|
||||
<IOSPickerTrigger
|
||||
options={TIME_UNIT_OPTIONS}
|
||||
selectedValue={props.minimumNoticeUnit}
|
||||
onSelect={props.setMinimumNoticeUnit}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ModalPickerButton
|
||||
value={props.minimumNoticeUnit}
|
||||
onPress={() => props.setShowMinimumNoticeUnitDropdown(true)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<PickerNavigationRow
|
||||
title="Time-slot intervals"
|
||||
value={props.slotInterval === "Default" ? "Event length" : props.slotInterval}
|
||||
options={SLOT_INTERVAL_OPTIONS}
|
||||
onSelect={props.setSlotInterval}
|
||||
onPressModal={() => props.setShowSlotIntervalDropdown(true)}
|
||||
isLast
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
<View
|
||||
style={{ borderTopWidth: 1, borderTopColor: "#E5E5EA", paddingTop: 12, marginTop: 12 }}
|
||||
>
|
||||
<Text className="mb-1.5 text-base font-semibold text-[#333]">Time-slot intervals</Text>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowSlotIntervalDropdown(true)}
|
||||
>
|
||||
<Text className="text-base text-black">
|
||||
{props.slotInterval === "Default" ? "Use event length (default)" : props.slotInterval}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* Booking Limits */}
|
||||
<SettingsGroup header="Booking Limits">
|
||||
<SettingRow
|
||||
title="Limit booking frequency"
|
||||
description="Limit how many times this event can be booked."
|
||||
value={props.limitBookingFrequency}
|
||||
onValueChange={props.toggleBookingFrequency}
|
||||
/>
|
||||
<SettingRow
|
||||
title="First slot only"
|
||||
description="Only show the first slot of each day as available. This will limit your availability for this event type to one slot per day, scheduled at the earliest available time."
|
||||
value={props.onlyShowFirstAvailableSlot}
|
||||
onValueChange={props.setOnlyShowFirstAvailableSlot}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Limit total duration"
|
||||
description="Limit total amount of time that this event can be booked."
|
||||
value={props.limitTotalDuration}
|
||||
onValueChange={props.toggleTotalDuration}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Limit active bookings"
|
||||
description="Limit the number of active bookings a booker can make for this event type."
|
||||
value={props.maxActiveBookingsPerBooker}
|
||||
onValueChange={props.setMaxActiveBookingsPerBooker}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Limit future bookings"
|
||||
description="Limit how far in the future this event can be booked."
|
||||
value={props.limitFutureBookings}
|
||||
onValueChange={props.setLimitFutureBookings}
|
||||
isLast
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* 1. Booking Frequency Limit Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Limit booking frequency</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
Limit how many times this event can be booked.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.limitBookingFrequency}
|
||||
onValueChange={props.toggleBookingFrequency}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{ overflow: "hidden" },
|
||||
{
|
||||
opacity: props.frequencyAnimationValue,
|
||||
maxHeight: props.frequencyAnimationValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 500],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.limitBookingFrequency ? (
|
||||
<>
|
||||
{props.frequencyLimits.map((limit) => (
|
||||
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
|
||||
{/* Frequency Limit Configuration */}
|
||||
{props.limitBookingFrequency ? (
|
||||
<SettingsGroup header="Frequency Limits">
|
||||
{props.frequencyLimits.map((limit, index) => (
|
||||
<View key={limit.id} className="bg-white pl-4">
|
||||
<View
|
||||
className={`flex-row items-center justify-between pr-4 py-3 ${
|
||||
index !== props.frequencyLimits.length - 1 ? "border-b border-[#E5E5E5]" : ""
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
className="w-16 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={limit.value}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
@@ -200,218 +437,155 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowFrequencyUnitDropdown(limit.id)}
|
||||
>
|
||||
<Text className="text-base text-black">{limit.unit}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
{props.frequencyLimits.length > 1 ? (
|
||||
<TouchableOpacity
|
||||
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
|
||||
onPress={() => props.removeFrequencyLimit(limit.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
className="mt-4 flex-row items-center justify-center gap-2 rounded-lg border border-black bg-transparent px-2 py-3 md:px-4"
|
||||
onPress={props.addFrequencyLimit}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#000" />
|
||||
<Text className="text-base font-medium text-black">Add Limit</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 2. Only Show First Available Slot Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Only show the first slot of each day as available
|
||||
</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
This will limit your availability for this event type to one slot per day, scheduled
|
||||
at the earliest available time.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.onlyShowFirstAvailableSlot}
|
||||
onValueChange={props.setOnlyShowFirstAvailableSlot}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 3. Total Booking Duration Limit Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Limit total booking duration
|
||||
</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
Limit total amount of time that this event can be booked.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.limitTotalDuration}
|
||||
onValueChange={props.toggleTotalDuration}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{ overflow: "hidden" },
|
||||
{
|
||||
opacity: props.durationAnimationValue,
|
||||
maxHeight: props.durationAnimationValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 500],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{props.limitTotalDuration ? (
|
||||
<>
|
||||
{props.durationLimits.map((limit) => (
|
||||
<View key={limit.id} className="mt-4 flex-row items-center gap-3">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={limit.value}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue, 10) || 0;
|
||||
if (num >= 0) {
|
||||
props.updateDurationLimit(limit.id, "value", numericValue || "0");
|
||||
<Text className="text-[15px] text-[#6D6D72]">per</Text>
|
||||
{Platform.OS === "ios" ? (
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]">{limit.unit}</Text>
|
||||
<IOSPickerTrigger
|
||||
options={FREQUENCY_UNIT_OPTIONS}
|
||||
selectedValue={limit.unit}
|
||||
onSelect={(value: string) =>
|
||||
props.updateFrequencyLimit(limit.id, "unit", value)
|
||||
}
|
||||
}}
|
||||
placeholder="60"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ModalPickerButton
|
||||
value={limit.unit}
|
||||
onPress={() => props.setShowFrequencyUnitDropdown(limit.id)}
|
||||
/>
|
||||
<Text className="text-base text-[#666]">Minutes</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => props.setShowDurationUnitDropdown(limit.id)}
|
||||
>
|
||||
<Text className="text-base text-black">{limit.unit}</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
{props.durationLimits.length > 1 ? (
|
||||
<TouchableOpacity
|
||||
className="h-10 w-10 items-center justify-center rounded-lg border border-[#FFCCC7] bg-[#FFF1F0]"
|
||||
onPress={() => props.removeDurationLimit(limit.id)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#800000" />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
className="mt-4 flex-row items-center justify-center gap-2 rounded-lg border border-black bg-transparent px-2 py-3 md:px-4"
|
||||
onPress={props.addDurationLimit}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#000" />
|
||||
<Text className="text-base font-medium text-black">Add Limit</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 4. Max Active Bookings Per Booker Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">
|
||||
Limit number of upcoming bookings per booker
|
||||
</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
Limit the number of active bookings a booker can make for this event type.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.maxActiveBookingsPerBooker}
|
||||
onValueChange={props.setMaxActiveBookingsPerBooker}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
{props.maxActiveBookingsPerBooker ? (
|
||||
<View className="mt-4 gap-3">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
value={props.maxActiveBookingsValue}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue, 10) || 0;
|
||||
if (num >= 0) {
|
||||
props.setMaxActiveBookingsValue(numericValue || "1");
|
||||
}
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-base text-[#666]">bookings</Text>
|
||||
{props.frequencyLimits.length > 1 ? (
|
||||
<TouchableOpacity onPress={() => props.removeFrequencyLimit(limit.id)}>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center"
|
||||
onPress={() => props.setOfferReschedule(!props.offerReschedule)}
|
||||
>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center bg-white py-3"
|
||||
onPress={props.addFrequencyLimit}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#007AFF" />
|
||||
<Text className="ml-1 text-[17px] text-[#007AFF]">Add Limit</Text>
|
||||
</TouchableOpacity>
|
||||
</SettingsGroup>
|
||||
) : null}
|
||||
|
||||
{/* Duration Limit Configuration */}
|
||||
{props.limitTotalDuration ? (
|
||||
<SettingsGroup header="Duration Limits">
|
||||
{props.durationLimits.map((limit, index) => (
|
||||
<View key={limit.id} className="bg-white pl-4">
|
||||
<View
|
||||
className={`mr-3 h-5 w-5 items-center justify-center rounded border ${
|
||||
props.offerReschedule ? "border-[#007AFF] bg-[#007AFF]" : "border-[#C7C7CC]"
|
||||
className={`flex-row items-center justify-between pr-4 py-3 ${
|
||||
index !== props.durationLimits.length - 1 ? "border-b border-[#E5E5E5]" : ""
|
||||
}`}
|
||||
>
|
||||
{props.offerReschedule && <Ionicons name="checkmark" size={14} color="#FFFFFF" />}
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-16 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={limit.value}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue, 10) || 0;
|
||||
if (num >= 0) {
|
||||
props.updateDurationLimit(limit.id, "value", numericValue || "0");
|
||||
}
|
||||
}}
|
||||
placeholder="60"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-[15px] text-[#6D6D72]">minutes per</Text>
|
||||
{Platform.OS === "ios" ? (
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]">{limit.unit}</Text>
|
||||
<IOSPickerTrigger
|
||||
options={DURATION_UNIT_OPTIONS}
|
||||
selectedValue={limit.unit}
|
||||
onSelect={(value: string) =>
|
||||
props.updateDurationLimit(limit.id, "unit", value)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ModalPickerButton
|
||||
value={limit.unit}
|
||||
onPress={() => props.setShowDurationUnitDropdown(limit.id)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{props.durationLimits.length > 1 ? (
|
||||
<TouchableOpacity onPress={() => props.removeDurationLimit(limit.id)}>
|
||||
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
<Text className="flex-1 text-sm text-[#333]">
|
||||
Offer to reschedule the last booking to the new time slot
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-center bg-white py-3"
|
||||
onPress={props.addDurationLimit}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#007AFF" />
|
||||
<Text className="ml-1 text-[17px] text-[#007AFF]">Add Limit</Text>
|
||||
</TouchableOpacity>
|
||||
</SettingsGroup>
|
||||
) : null}
|
||||
|
||||
{/* 5. Limit Future Bookings Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-4 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Limit future bookings</Text>
|
||||
<Text className="text-sm leading-5 text-[#666]">
|
||||
Limit how far in the future this event can be booked.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={props.limitFutureBookings}
|
||||
onValueChange={props.setLimitFutureBookings}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
{props.limitFutureBookings ? (
|
||||
<View className="mt-4 gap-3">
|
||||
{/* Rolling option */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-start"
|
||||
onPress={() => props.setFutureBookingType("rolling")}
|
||||
{/* Max Active Bookings Configuration */}
|
||||
{props.maxActiveBookingsPerBooker ? (
|
||||
<SettingsGroup header="Active Bookings Limit">
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Max bookings</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-16 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={props.maxActiveBookingsValue}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
const num = parseInt(numericValue, 10) || 0;
|
||||
if (num >= 0) {
|
||||
props.setMaxActiveBookingsValue(numericValue || "1");
|
||||
}
|
||||
}}
|
||||
placeholder="1"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-[15px] text-[#6D6D72]">bookings</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<SettingRow
|
||||
title="Offer reschedule"
|
||||
description="Offer to reschedule the last booking to the new time slot."
|
||||
value={props.offerReschedule}
|
||||
onValueChange={props.setOfferReschedule}
|
||||
isLast
|
||||
/>
|
||||
</SettingsGroup>
|
||||
) : null}
|
||||
|
||||
{/* Future Bookings Configuration */}
|
||||
{props.limitFutureBookings ? (
|
||||
<SettingsGroup header="Future Booking Limit">
|
||||
{/* Rolling option */}
|
||||
<TouchableOpacity
|
||||
className="bg-white pl-4"
|
||||
onPress={() => props.setFutureBookingType("rolling")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="flex-row items-center border-b border-[#E5E5E5] pr-4 py-3">
|
||||
<View
|
||||
className={`mr-3 mt-0.5 h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
className={`mr-3 h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
props.futureBookingType === "rolling" ? "border-[#007AFF]" : "border-[#C7C7CC]"
|
||||
}`}
|
||||
>
|
||||
@@ -419,41 +593,42 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
<View className="h-2.5 w-2.5 rounded-full bg-[#007AFF]" />
|
||||
) : null}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<View className="flex-row flex-wrap items-center gap-2">
|
||||
<TextInput
|
||||
className="w-16 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-2 py-2 text-center text-base text-black"
|
||||
value={props.rollingDays}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
props.setRollingDays(numericValue || "30");
|
||||
props.setFutureBookingType("rolling");
|
||||
}}
|
||||
placeholder="30"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2"
|
||||
onPress={() => {
|
||||
props.setRollingCalendarDays(!props.rollingCalendarDays);
|
||||
props.setFutureBookingType("rolling");
|
||||
}}
|
||||
>
|
||||
<Text className="text-base text-black">
|
||||
{props.rollingCalendarDays ? "calendar days" : "business days"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-base text-[#666]">into the future</Text>
|
||||
</View>
|
||||
<View className="flex-1 flex-row flex-wrap items-center gap-2">
|
||||
<TextInput
|
||||
className="w-14 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={props.rollingDays}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
props.setRollingDays(numericValue || "30");
|
||||
props.setFutureBookingType("rolling");
|
||||
}}
|
||||
placeholder="30"
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="rounded-lg bg-[#F2F2F7] px-2 py-1.5"
|
||||
onPress={() => {
|
||||
props.setRollingCalendarDays(!props.rollingCalendarDays);
|
||||
props.setFutureBookingType("rolling");
|
||||
}}
|
||||
>
|
||||
<Text className="text-[15px] text-black">
|
||||
{props.rollingCalendarDays ? "calendar" : "business"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-[15px] text-[#6D6D72]">days ahead</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Date Range option */}
|
||||
<TouchableOpacity
|
||||
className="flex-row items-start"
|
||||
onPress={() => props.setFutureBookingType("range")}
|
||||
>
|
||||
{/* Date Range option */}
|
||||
<TouchableOpacity
|
||||
className="bg-white pl-4"
|
||||
onPress={() => props.setFutureBookingType("range")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View className="flex-row items-start pr-4 py-3">
|
||||
<View
|
||||
className={`mr-3 mt-0.5 h-5 w-5 items-center justify-center rounded-full border-2 ${
|
||||
props.futureBookingType === "range" ? "border-[#007AFF]" : "border-[#C7C7CC]"
|
||||
@@ -464,18 +639,18 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
) : null}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-base text-[#333]">Within a date range</Text>
|
||||
<Text className="mb-2 text-[17px] text-black">Within a date range</Text>
|
||||
{props.futureBookingType === "range" ? (
|
||||
<View className="gap-2">
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
|
||||
className="rounded-lg bg-[#F2F2F7] px-3 py-2 text-[15px] text-black"
|
||||
value={props.rangeStartDate}
|
||||
onChangeText={props.setRangeStartDate}
|
||||
placeholder="Start date (YYYY-MM-DD)"
|
||||
placeholderTextColor="#8E8E93"
|
||||
/>
|
||||
<TextInput
|
||||
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-2 text-base text-black"
|
||||
className="rounded-lg bg-[#F2F2F7] px-3 py-2 text-[15px] text-black"
|
||||
value={props.rangeEndDate}
|
||||
onChangeText={props.setRangeEndDate}
|
||||
placeholder="End date (YYYY-MM-DD)"
|
||||
@@ -484,10 +659,10 @@ export function LimitsTab(props: LimitsTabProps) {
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</SettingsGroup>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* RecurringTab Component
|
||||
*
|
||||
* iOS Settings style with grouped rows and section headers.
|
||||
*/
|
||||
|
||||
import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, frame } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { Alert, Platform, Switch, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
|
||||
interface RecurringTabProps {
|
||||
@@ -21,56 +31,169 @@ const frequencyToLabel: Record<string, string> = {
|
||||
yearly: "year",
|
||||
};
|
||||
|
||||
// Section header
|
||||
function SectionHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<Text
|
||||
className="mb-2 ml-4 text-[13px] uppercase tracking-wide text-[#6D6D72]"
|
||||
style={{ letterSpacing: 0.5 }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings group container
|
||||
function SettingsGroup({ children, header }: { children: React.ReactNode; header?: string }) {
|
||||
return (
|
||||
<View>
|
||||
{header ? <SectionHeader title={header} /> : null}
|
||||
<View className="overflow-hidden rounded-[14px] bg-white">{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle row with description
|
||||
function SettingRow({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
learnMoreUrl,
|
||||
isLast = false,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
learnMoreUrl?: string;
|
||||
isLast?: boolean;
|
||||
}) {
|
||||
const showDescription = () => {
|
||||
if (!description) return;
|
||||
|
||||
const buttons: {
|
||||
text: string;
|
||||
onPress?: () => void;
|
||||
style?: "cancel" | "default" | "destructive";
|
||||
}[] = [{ text: "OK", style: "cancel" }];
|
||||
|
||||
if (learnMoreUrl) {
|
||||
buttons.unshift({
|
||||
text: "Learn more",
|
||||
onPress: () => openInAppBrowser(learnMoreUrl, "Learn more"),
|
||||
});
|
||||
}
|
||||
|
||||
Alert.alert(title, description, buttons);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className={`flex-row items-center pr-4 ${!isLast ? "border-b border-[#E5E5E5]" : ""}`}
|
||||
style={{ minHeight: 44 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 flex-row items-center py-3"
|
||||
onPress={description ? showDescription : undefined}
|
||||
activeOpacity={description ? 0.7 : 1}
|
||||
disabled={!description}
|
||||
>
|
||||
<Text className="text-[17px] text-black" style={{ fontWeight: "400" }}>
|
||||
{title}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Ionicons name="chevron-down" size={12} color="#C7C7CC" style={{ marginLeft: 6 }} />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
<View style={{ alignSelf: "center" }}>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: "#E9E9EA", true: "#000000" }}
|
||||
thumbColor={Platform.OS === "android" ? "#FFFFFF" : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// iOS Native Picker trigger component
|
||||
function IOSPickerTrigger({
|
||||
options,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
}: {
|
||||
options: { label: string; value: string }[];
|
||||
selectedValue: string;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Host matchContents>
|
||||
<ContextMenu
|
||||
modifiers={[buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered")]}
|
||||
activationMethod="singlePress"
|
||||
>
|
||||
<ContextMenu.Items>
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
systemImage={selectedValue === opt.label ? "checkmark" : undefined}
|
||||
onPress={() => onSelect(opt.value)}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu.Items>
|
||||
<ContextMenu.Trigger>
|
||||
<HStack>
|
||||
<Image systemName="chevron.up.chevron.down" color="primary" size={13} />
|
||||
</HStack>
|
||||
</ContextMenu.Trigger>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecurringTab({
|
||||
recurringEnabled,
|
||||
setRecurringEnabled,
|
||||
recurringInterval,
|
||||
setRecurringInterval,
|
||||
recurringFrequency,
|
||||
setRecurringFrequency: _setRecurringFrequency,
|
||||
setRecurringFrequency,
|
||||
recurringOccurrences,
|
||||
setRecurringOccurrences,
|
||||
setShowFrequencyDropdown,
|
||||
}: RecurringTabProps) {
|
||||
return (
|
||||
<View className="gap-3">
|
||||
{/* Recurring Event Toggle Card */}
|
||||
<View className="rounded-2xl border border-[#E5E5EA] bg-white p-5">
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-medium text-[#333]">Recurring Event</Text>
|
||||
<Text className="text-sm text-[#666]">
|
||||
People can subscribe for recurring events.{" "}
|
||||
<Text
|
||||
className="text-[#007AFF]"
|
||||
onPress={() =>
|
||||
openInAppBrowser(
|
||||
"https://cal.com/docs/core-features/event-types/recurring-events",
|
||||
"Learn more about recurring events"
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn more
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={recurringEnabled}
|
||||
onValueChange={setRecurringEnabled}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
<View className="gap-6">
|
||||
{/* Recurring Toggle */}
|
||||
<SettingsGroup>
|
||||
<SettingRow
|
||||
title="Recurring Event"
|
||||
description="People can subscribe for recurring events. When enabled, you can set how often the event repeats and for how many occurrences."
|
||||
value={recurringEnabled}
|
||||
onValueChange={setRecurringEnabled}
|
||||
learnMoreUrl="https://cal.com/docs/core-features/event-types/recurring-events"
|
||||
isLast
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Recurring Configuration - shown when enabled */}
|
||||
{recurringEnabled ? (
|
||||
<View className="mt-4 gap-4 border-t border-[#E5E5EA] pt-4">
|
||||
{/* Repeats Every */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">Repeats every</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
{/* Recurring Configuration - shown when enabled */}
|
||||
{recurringEnabled ? (
|
||||
<SettingsGroup header="Recurrence">
|
||||
{/* Repeats Every */}
|
||||
<View className="bg-white pl-4">
|
||||
<View
|
||||
className="flex-row items-center justify-between border-b border-[#E5E5E5] pr-4"
|
||||
style={{ height: 44 }}
|
||||
>
|
||||
<Text className="text-[17px] text-black">Repeats every</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
className="w-14 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={recurringInterval}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
@@ -87,24 +210,48 @@ export function RecurringTab({
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
className="min-w-[100px] flex-row items-center justify-between rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3"
|
||||
onPress={() => setShowFrequencyDropdown(true)}
|
||||
>
|
||||
<Text className="text-base text-black">
|
||||
{frequencyToLabel[recurringFrequency] || recurringFrequency}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={20} color="#8E8E93" />
|
||||
</TouchableOpacity>
|
||||
{Platform.OS === "ios" ? (
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-1 text-[17px] text-[#8E8E93]">
|
||||
{frequencyToLabel[recurringFrequency] || recurringFrequency}
|
||||
</Text>
|
||||
<IOSPickerTrigger
|
||||
options={[
|
||||
{ label: "week", value: "weekly" },
|
||||
{ label: "month", value: "monthly" },
|
||||
{ label: "year", value: "yearly" },
|
||||
]}
|
||||
selectedValue={frequencyToLabel[recurringFrequency] || recurringFrequency}
|
||||
onSelect={(val) => setRecurringFrequency(val as any)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center rounded-lg bg-[#F2F2F7] px-2 py-1.5"
|
||||
onPress={() => setShowFrequencyDropdown(true)}
|
||||
>
|
||||
<Text className="text-[15px] text-black">
|
||||
{frequencyToLabel[recurringFrequency] || recurringFrequency}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={14}
|
||||
color="#8E8E93"
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* For a maximum of */}
|
||||
<View>
|
||||
<Text className="mb-2 text-sm font-medium text-[#333]">For a maximum of</Text>
|
||||
<View className="flex-row items-center gap-3">
|
||||
{/* Maximum occurrences */}
|
||||
<View className="bg-white pl-4">
|
||||
<View className="flex-row items-center justify-between pr-4" style={{ height: 44 }}>
|
||||
<Text className="text-[17px] text-black">Maximum events</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<TextInput
|
||||
className="w-20 rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-center text-base text-black"
|
||||
className="w-14 rounded-lg bg-[#F2F2F7] px-2 py-1.5 text-center text-[15px] text-black"
|
||||
value={recurringOccurrences}
|
||||
onChangeText={(text) => {
|
||||
const numericValue = text.replace(/[^0-9]/g, "");
|
||||
@@ -121,12 +268,12 @@ export function RecurringTab({
|
||||
placeholderTextColor="#8E8E93"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<Text className="text-base text-[#333]">Events</Text>
|
||||
<Text className="text-[15px] text-[#6D6D72]">events</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</SettingsGroup>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useAddGuests } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
export interface AddGuestsScreenProps {
|
||||
@@ -68,17 +69,17 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
const handleAddGuest = useCallback(() => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (!trimmedEmail) {
|
||||
Alert.alert("Error", "Please enter an email address");
|
||||
showErrorAlert("Error", "Please enter an email address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEmail(trimmedEmail)) {
|
||||
Alert.alert("Error", "Please enter a valid email address");
|
||||
showErrorAlert("Error", "Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (guests.some((g) => g.email.toLowerCase() === trimmedEmail.toLowerCase())) {
|
||||
Alert.alert("Error", "This guest has already been added");
|
||||
showErrorAlert("Error", "This guest has already been added");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
if (guests.length === 0) {
|
||||
Alert.alert("Error", "Please add at least one guest");
|
||||
showErrorAlert("Error", "Please add at least one guest");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -109,13 +110,18 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Guests added successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
if (Platform.OS === "web") {
|
||||
showSuccessAlert("Success", "Guests added successfully");
|
||||
onSuccess();
|
||||
} else {
|
||||
Alert.alert("Success", "Guests added successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
safeLogError("[AddGuestsScreen] Failed to add guests:", error);
|
||||
Alert.alert("Error", "Failed to add guests. Please try again.");
|
||||
showErrorAlert("Error", "Failed to add guests. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
@@ -186,7 +186,7 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
|
||||
const handleSetAsDefault = useCallback(async () => {
|
||||
if (isDefault) {
|
||||
Alert.alert("Info", "This schedule is already set as default");
|
||||
showInfoAlert("Info", "This schedule is already set as default");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
isDefault: true,
|
||||
});
|
||||
setIsDefault(true);
|
||||
Alert.alert("Success", "Availability set as default successfully");
|
||||
showSuccessAlert("Success", "Availability set as default successfully");
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to set availability as default. Please try again.");
|
||||
}
|
||||
@@ -203,7 +203,7 @@ export const AvailabilityDetailScreen = forwardRef<
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (isDefault) {
|
||||
Alert.alert(
|
||||
showInfoAlert(
|
||||
"Cannot Delete",
|
||||
"You cannot delete the default schedule. Please set another schedule as default first."
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
useSetScheduleAsDefault,
|
||||
} from "@/hooks";
|
||||
import { CalComAPIService, type Schedule } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { offlineAwareRefresh } from "@/utils/network";
|
||||
import { shadows } from "@/utils/shadows";
|
||||
|
||||
@@ -108,6 +108,9 @@ export function AvailabilityListScreen({
|
||||
|
||||
const handleSetAsDefault = (schedule: Schedule) => {
|
||||
setAsDefaultMutation(schedule.id, {
|
||||
onSuccess: () => {
|
||||
showSuccessAlert("Success", "Schedule set as default");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
|
||||
},
|
||||
@@ -116,6 +119,9 @@ export function AvailabilityListScreen({
|
||||
|
||||
const handleDuplicate = (schedule: Schedule) => {
|
||||
duplicateScheduleMutation(schedule.id, {
|
||||
onSuccess: () => {
|
||||
showSuccessAlert("Success", "Schedule duplicated successfully");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to duplicate schedule. Please try again.");
|
||||
},
|
||||
@@ -124,7 +130,8 @@ export function AvailabilityListScreen({
|
||||
|
||||
const handleDelete = (schedule: Schedule) => {
|
||||
if (Platform.OS === "web") {
|
||||
// Use custom modal for web
|
||||
// Use custom modal for web - must set selectedSchedule first!
|
||||
setSelectedSchedule(schedule);
|
||||
setShowDeleteModal(true);
|
||||
} else {
|
||||
// Use native Alert for iOS/Android
|
||||
@@ -135,6 +142,9 @@ export function AvailabilityListScreen({
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deleteScheduleMutation(schedule.id, {
|
||||
onSuccess: () => {
|
||||
showSuccessAlert("Success", "Schedule deleted successfully");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
|
||||
},
|
||||
@@ -152,6 +162,7 @@ export function AvailabilityListScreen({
|
||||
onSuccess: () => {
|
||||
setShowDeleteModal(false);
|
||||
setSelectedSchedule(null);
|
||||
showSuccessAlert("Success", "Schedule deleted successfully");
|
||||
},
|
||||
onError: () => {
|
||||
showErrorAlert("Error", "Failed to delete schedule. Please try again.");
|
||||
@@ -177,11 +188,11 @@ export function AvailabilityListScreen({
|
||||
setNameError("");
|
||||
|
||||
if (!newScheduleName.trim()) {
|
||||
// Use inline error for Android AlertDialog, Alert for others
|
||||
// Use inline error for Android AlertDialog, showErrorAlert for others
|
||||
if (Platform.OS === "android") {
|
||||
setNameError("Please enter a schedule name");
|
||||
} else {
|
||||
Alert.alert("Error", "Please enter a schedule name");
|
||||
showErrorAlert("Error", "Please enter a schedule name");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -440,19 +451,14 @@ export function AvailabilityListScreen({
|
||||
animationType="fade"
|
||||
onRequestClose={() => onShowCreateModalChange(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 items-center justify-center bg-black/50 p-2 md:p-4"
|
||||
activeOpacity={1}
|
||||
onPress={() => onShowCreateModalChange(false)}
|
||||
>
|
||||
<View className="flex-1 items-center justify-center bg-black/50 p-4">
|
||||
<TouchableOpacity
|
||||
className="w-[90%] max-w-[500px] rounded-2xl bg-white"
|
||||
className="w-full max-w-md rounded-2xl bg-white shadow-2xl"
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={shadows.xl()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="px-8 pb-4 pt-6">
|
||||
<View className="p-6 pb-2">
|
||||
<Text className="mb-2 text-2xl font-semibold text-gray-900">
|
||||
Add a new schedule
|
||||
</Text>
|
||||
@@ -460,7 +466,7 @@ export function AvailabilityListScreen({
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="px-8 pb-6">
|
||||
<View className="px-6 pb-4">
|
||||
<View className="mb-1">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-700">Name</Text>
|
||||
<TextInput
|
||||
@@ -474,35 +480,36 @@ export function AvailabilityListScreen({
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleCreateSchedule}
|
||||
/>
|
||||
{nameError ? (
|
||||
<Text className="mt-1 text-sm text-red-500">{nameError}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="rounded-b-2xl border-t border-[#E5E7EB] bg-[#F9FAFB] px-8 py-4">
|
||||
<View className="flex-row justify-end gap-2 space-x-2">
|
||||
<AppPressable
|
||||
className="rounded-xl border border-gray-300 bg-white px-2 py-2 md:px-4"
|
||||
onPress={() => {
|
||||
onShowCreateModalChange(false);
|
||||
setNewScheduleName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
<Text className="text-base font-medium text-gray-700">Close</Text>
|
||||
</AppPressable>
|
||||
<AppPressable
|
||||
className={`rounded-xl bg-[#111827] px-2 py-2 md:px-4 ${
|
||||
creating ? "opacity-60" : ""
|
||||
}`}
|
||||
onPress={handleCreateSchedule}
|
||||
disabled={creating}
|
||||
>
|
||||
<Text className="text-base font-medium text-white">Continue</Text>
|
||||
</AppPressable>
|
||||
</View>
|
||||
{/* Buttons */}
|
||||
<View className="flex-row-reverse gap-2 px-6 pb-6 pt-2">
|
||||
<TouchableOpacity
|
||||
className={`rounded-lg px-4 py-2.5 ${creating ? "opacity-50" : ""}`}
|
||||
style={{ backgroundColor: "#111827" }}
|
||||
onPress={handleCreateSchedule}
|
||||
disabled={creating}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-white">Continue</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2.5"
|
||||
onPress={() => {
|
||||
onShowCreateModalChange(false);
|
||||
setNewScheduleName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
)}
|
||||
|
||||
@@ -608,43 +615,44 @@ export function AvailabilityListScreen({
|
||||
onRequestClose={() => !deleting && setShowDeleteModal(false)}
|
||||
>
|
||||
<View className="flex-1 items-center justify-center bg-black/50 p-4">
|
||||
<View className="w-full max-w-sm rounded-2xl bg-white p-6">
|
||||
{/* Icon */}
|
||||
<View className="mb-4 items-center">
|
||||
<View className="h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<Ionicons name="trash-outline" size={24} color="#800000" />
|
||||
<View className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||
<View className="p-6">
|
||||
<View className="flex-row">
|
||||
{/* Danger icon */}
|
||||
<View className="mr-3 self-start rounded-full bg-red-50 p-2">
|
||||
<Ionicons name="alert-circle" size={20} color="#800000" />
|
||||
</View>
|
||||
|
||||
{/* Title and description */}
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-900">Delete Schedule</Text>
|
||||
<Text className="text-sm leading-5 text-gray-600">
|
||||
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot
|
||||
be undone.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="mb-2 text-center text-xl font-semibold text-gray-900">
|
||||
Delete Schedule
|
||||
</Text>
|
||||
|
||||
{/* Description */}
|
||||
<Text className="mb-6 text-center text-base text-gray-600">
|
||||
Are you sure you want to delete "{selectedSchedule?.name}"? This action cannot be
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="flex-row gap-3">
|
||||
<AppPressable
|
||||
className="flex-1 rounded-lg bg-gray-100 px-4 py-3"
|
||||
onPress={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-gray-700">Cancel</Text>
|
||||
</AppPressable>
|
||||
<AppPressable
|
||||
className={`flex-1 rounded-lg bg-gray-900 px-4 py-3 ${
|
||||
deleting ? "opacity-50" : ""
|
||||
}`}
|
||||
{/* Buttons */}
|
||||
<View className="flex-row-reverse gap-2 px-6 pb-6 pt-2">
|
||||
<TouchableOpacity
|
||||
className={`rounded-lg px-4 py-2.5 ${deleting ? "opacity-50" : ""}`}
|
||||
style={{ backgroundColor: deleting ? "#6B7280" : "#111827" }}
|
||||
onPress={confirmDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Text className="text-center text-base font-semibold text-white">Delete</Text>
|
||||
</AppPressable>
|
||||
<Text className="text-center text-base font-medium text-white">Delete</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2.5"
|
||||
onPress={() => setShowDeleteModal(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -38,7 +40,7 @@ import { SvgImage } from "@/components/SvgImage";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useCancelBooking } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showInfoAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { type BookingActionsResult, getBookingActions } from "@/utils/booking-actions";
|
||||
import { openInAppBrowser } from "@/utils/browser";
|
||||
import { defaultLocations, getDefaultLocationIconUrl } from "@/utils/defaultLocations";
|
||||
@@ -273,12 +275,8 @@ export function BookingDetailScreen({
|
||||
{ uid: booking.uid, reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Booking cancelled successfully", [
|
||||
{
|
||||
text: "OK",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
]);
|
||||
showSuccessAlert("Success", "Booking cancelled successfully");
|
||||
router.back();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error("Failed to cancel booking");
|
||||
@@ -297,7 +295,7 @@ export function BookingDetailScreen({
|
||||
const handleCancelBooking = useCallback(() => {
|
||||
if (!booking) return;
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
if (Platform.OS === "android" || Platform.OS === "web") {
|
||||
setCancellationReason("");
|
||||
setShowCancelDialog(true);
|
||||
} else {
|
||||
@@ -347,7 +345,7 @@ export function BookingDetailScreen({
|
||||
}, []);
|
||||
|
||||
const handleReportBooking = useCallback(() => {
|
||||
Alert.alert("Report Booking", "Report booking functionality is not yet available");
|
||||
showInfoAlert("Report Booking", "Report booking functionality is not yet available");
|
||||
}, []);
|
||||
|
||||
// Navigate to reschedule screen (same pattern as senior's - navigate to screen in same folder)
|
||||
@@ -822,46 +820,115 @@ export function BookingDetailScreen({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Cancel Event AlertDialog (Android only) */}
|
||||
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader className="items-start">
|
||||
<AlertDialogTitle>
|
||||
<UIText className="text-left text-lg font-semibold">Cancel event</UIText>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<UIText className="text-left text-sm text-muted-foreground">
|
||||
Cancellation reason will be shared with guests
|
||||
</UIText>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{/* Web/Extension: Cancel Event Modal */}
|
||||
{Platform.OS === "web" && (
|
||||
<FullScreenModal
|
||||
visible={showCancelDialog}
|
||||
animationType="fade"
|
||||
onRequestClose={handleCloseCancelDialog}
|
||||
>
|
||||
<View className="flex-1 items-center justify-center bg-black/50 p-4">
|
||||
<View className="w-full max-w-md rounded-2xl bg-white shadow-2xl">
|
||||
<View className="p-6">
|
||||
<View className="flex-row">
|
||||
{/* Danger icon */}
|
||||
<View className="mr-3 self-start rounded-full bg-red-50 p-2">
|
||||
<Ionicons name="alert-circle" size={20} color="#800000" />
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<UIText className="mb-2 text-sm font-medium">Reason for cancellation</UIText>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
|
||||
placeholder="Why are you cancelling?"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={cancellationReason}
|
||||
onChangeText={setCancellationReason}
|
||||
autoFocus
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
{/* Title and description */}
|
||||
<View className="flex-1">
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-900">Cancel Event</Text>
|
||||
<Text className="text-sm leading-5 text-gray-600">
|
||||
Are you sure you want to cancel "{booking?.title}"? Cancellation reason will
|
||||
be shared with guests.
|
||||
</Text>
|
||||
|
||||
{/* Reason Input */}
|
||||
<View className="mt-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-700">
|
||||
Reason for cancellation
|
||||
</Text>
|
||||
<TextInput
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-base text-gray-900"
|
||||
placeholder="Why are you cancelling?"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={cancellationReason}
|
||||
onChangeText={setCancellationReason}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="flex-row-reverse gap-2 px-6 pb-6 pt-2">
|
||||
<TouchableOpacity
|
||||
className="rounded-lg px-4 py-2.5"
|
||||
style={{ backgroundColor: "#111827" }}
|
||||
onPress={handleConfirmCancel}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-white">Cancel Event</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="rounded-lg border border-gray-300 bg-white px-4 py-2.5"
|
||||
onPress={handleCloseCancelDialog}
|
||||
>
|
||||
<Text className="text-center text-base font-medium text-gray-700">Nevermind</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</FullScreenModal>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onPress={handleCloseCancelDialog}>
|
||||
<UIText>Nevermind</UIText>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={handleConfirmCancel}>
|
||||
<UIText className="text-white">Cancel event</UIText>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{/* Cancel Event AlertDialog (Android only) */}
|
||||
{Platform.OS === "android" && (
|
||||
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader className="items-start">
|
||||
<AlertDialogTitle>
|
||||
<UIText className="text-left text-lg font-semibold">Cancel event</UIText>
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<UIText className="text-left text-sm text-muted-foreground">
|
||||
Cancellation reason will be shared with guests
|
||||
</UIText>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<View>
|
||||
<UIText className="mb-2 text-sm font-medium">Reason for cancellation</UIText>
|
||||
<TextInput
|
||||
className="rounded-md border border-[#D1D5DB] bg-white px-3 py-2.5 text-base text-[#111827]"
|
||||
placeholder="Why are you cancelling?"
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={cancellationReason}
|
||||
onChangeText={setCancellationReason}
|
||||
autoFocus
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
textAlignVertical="top"
|
||||
style={{ minHeight: 80 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onPress={handleCloseCancelDialog}>
|
||||
<UIText>Nevermind</UIText>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onPress={handleConfirmCancel}>
|
||||
<UIText className="text-white">Cancel event</UIText>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -327,7 +327,7 @@ export const EditAvailabilityDayScreen = forwardRef<
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={handleToggle}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
trackColor={{ false: "#E5E5EA", true: "#000000" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import type { ScheduleAvailability } from "@/services/types";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { shadows } from "@/utils/shadows";
|
||||
|
||||
const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
@@ -325,9 +325,8 @@ export const EditAvailabilityDayScreen = forwardRef<
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
availability: fullAvailability,
|
||||
});
|
||||
Alert.alert("Success", `${dayName} updated successfully`, [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
showSuccessAlert("Success", `${dayName} updated successfully`);
|
||||
onSuccess();
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
@@ -365,7 +364,7 @@ export const EditAvailabilityDayScreen = forwardRef<
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={handleToggle}
|
||||
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
|
||||
trackColor={{ false: "#E5E5EA", true: "#000000" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -110,7 +110,7 @@ export const EditAvailabilityHoursScreen = forwardRef<unknown, EditAvailabilityH
|
||||
Tap a day to edit its hours
|
||||
</Text>
|
||||
|
||||
<View>
|
||||
<View className="overflow-hidden rounded-xl bg-white border border-gray-200">
|
||||
{DAYS.map((day, dayIndex) => {
|
||||
const daySlots = availability[dayIndex] || [];
|
||||
const isEnabled = daySlots.length > 0;
|
||||
@@ -118,8 +118,8 @@ export const EditAvailabilityHoursScreen = forwardRef<unknown, EditAvailabilityH
|
||||
return (
|
||||
<AppPressable key={day} onPress={() => onDayPress(dayIndex)}>
|
||||
<View
|
||||
className={`mb-2.5 flex-row items-center rounded-xl border-2 px-4 py-4 ${
|
||||
isEnabled ? "border-gray-200 bg-white" : "border-gray-100 bg-gray-50"
|
||||
className={`flex-row items-center px-4 py-3.5 ${
|
||||
dayIndex > 0 ? "border-t border-gray-100" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Day status indicator */}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { shadows } from "@/utils/shadows";
|
||||
|
||||
// Format timezones for display
|
||||
@@ -75,7 +75,8 @@ export const EditAvailabilityNameScreen = forwardRef<
|
||||
name: trimmedName,
|
||||
timeZone: timezone,
|
||||
});
|
||||
Alert.alert("Success", "Schedule updated successfully", [{ text: "OK", onPress: onSuccess }]);
|
||||
showSuccessAlert("Success", "Schedule updated successfully");
|
||||
onSuccess();
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to update schedule. Please try again.");
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Alert, Pressable, ScrollView, Switch, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
|
||||
// Convert 24-hour time to 12-hour format with AM/PM
|
||||
const formatTime12Hour = (time24: string): string => {
|
||||
@@ -141,7 +141,8 @@ export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
overrides: newOverrides,
|
||||
});
|
||||
Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]);
|
||||
showSuccessAlert("Success", successMessage);
|
||||
onSuccess();
|
||||
setIsSaving(false);
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to save override. Please try again.");
|
||||
@@ -195,7 +196,7 @@ export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
const startTimeStr = dateToTimeString(startTime);
|
||||
const endTimeStr = dateToTimeString(endTime);
|
||||
if (!isUnavailable && endTimeStr <= startTimeStr) {
|
||||
Alert.alert("Error", "End time must be after start time");
|
||||
showErrorAlert("Error", "End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,7 +332,7 @@ export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
<Switch
|
||||
value={isUnavailable}
|
||||
onValueChange={setIsUnavailable}
|
||||
trackColor={{ false: "#E5E5EA", true: "#FF3B30" }}
|
||||
trackColor={{ false: "#E5E5EA", true: "#000000" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AppPressable } from "@/components/AppPressable";
|
||||
import { FullScreenModal } from "@/components/FullScreenModal";
|
||||
import type { Schedule } from "@/services/calcom";
|
||||
import { CalComAPIService } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { shadows } from "@/utils/shadows";
|
||||
|
||||
// Generate time options (15-minute intervals)
|
||||
@@ -181,8 +181,13 @@ export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
await CalComAPIService.updateSchedule(schedule.id, {
|
||||
overrides: newOverrides,
|
||||
});
|
||||
Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]);
|
||||
setIsSaving(false);
|
||||
if (Platform.OS === "web") {
|
||||
showSuccessAlert("Success", successMessage);
|
||||
onSuccess();
|
||||
} else {
|
||||
Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]);
|
||||
}
|
||||
} catch {
|
||||
showErrorAlert("Error", "Failed to save override. Please try again.");
|
||||
setIsSaving(false);
|
||||
@@ -229,20 +234,20 @@ export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
if (!schedule || isSaving) return;
|
||||
|
||||
if (!selectedDate) {
|
||||
Alert.alert("Error", "Please enter a date (YYYY-MM-DD format)");
|
||||
showErrorAlert("Error", "Please enter a date (YYYY-MM-DD format)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(selectedDate)) {
|
||||
Alert.alert("Error", "Please enter date in YYYY-MM-DD format");
|
||||
showErrorAlert("Error", "Please enter date in YYYY-MM-DD format");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate end time is after start time (only when not marking as unavailable)
|
||||
if (!isUnavailable && endTime <= startTime) {
|
||||
Alert.alert("Error", "End time must be after start time");
|
||||
showErrorAlert("Error", "End time must be after start time");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -376,7 +381,7 @@ export const EditAvailabilityOverrideScreen = forwardRef<
|
||||
<Switch
|
||||
value={isUnavailable}
|
||||
onValueChange={setIsUnavailable}
|
||||
trackColor={{ false: "#E5E5EA", true: "#FF3B30" }}
|
||||
trackColor={{ false: "#E5E5EA", true: "#000000" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -10,10 +10,11 @@ import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useUpdateLocation } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
// Location types configuration
|
||||
@@ -132,14 +133,14 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
Alert.alert("Error", "Please enter a location");
|
||||
showErrorAlert("Error", "Please enter a location");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate phone number format
|
||||
if (selectedType === "phone") {
|
||||
if (!trimmedValue.startsWith("+")) {
|
||||
Alert.alert(
|
||||
showErrorAlert(
|
||||
"Invalid Phone Number",
|
||||
"Phone number must include country code (e.g., +1 234-567-8900)"
|
||||
);
|
||||
@@ -168,13 +169,12 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Location updated successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
showSuccessAlert("Success", "Location updated successfully");
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
safeLogError("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", "Failed to update location. Please try again.");
|
||||
showErrorAlert("Error", "Failed to update location. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useUpdateLocation } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { safeLogError } from "@/utils/safeLogger";
|
||||
|
||||
export const LOCATION_TYPES = {
|
||||
@@ -138,13 +138,13 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
const trimmedValue = inputValue.trim();
|
||||
|
||||
if (!trimmedValue) {
|
||||
Alert.alert("Error", "Please enter a location");
|
||||
showErrorAlert("Error", "Please enter a location");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "phone") {
|
||||
if (!trimmedValue.startsWith("+")) {
|
||||
Alert.alert(
|
||||
showErrorAlert(
|
||||
"Invalid Phone Number",
|
||||
"Phone number must include country code (e.g., +1 234-567-8900)"
|
||||
);
|
||||
@@ -173,13 +173,12 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Location updated successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
showSuccessAlert("Success", "Location updated successfully");
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
safeLogError("[EditLocationScreen] Failed to update location:", error);
|
||||
Alert.alert("Error", "Failed to update location. Please try again.");
|
||||
showErrorAlert("Error", "Failed to update location. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ActivityIndicator, Alert, FlatList, Text, TouchableOpacity, View } from
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useMarkNoShow } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
|
||||
interface Attendee {
|
||||
id?: number | string;
|
||||
@@ -113,7 +114,7 @@ export function MarkNoShowScreen({
|
||||
onBookingUpdate(updatedBooking);
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
showSuccessAlert(
|
||||
"Success",
|
||||
`${attendee.name || attendee.email} has been ${
|
||||
isCurrentlyNoShow ? "unmarked as no-show" : "marked as no-show"
|
||||
@@ -134,7 +135,7 @@ export function MarkNoShowScreen({
|
||||
absent: !isCurrentlyNoShow,
|
||||
});
|
||||
}
|
||||
Alert.alert(
|
||||
showErrorAlert(
|
||||
"Error",
|
||||
error instanceof Error ? error.message : `Failed to ${action}`
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppPressable } from "@/components/AppPressable";
|
||||
import { useRescheduleBooking } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
export interface RescheduleScreenProps {
|
||||
@@ -74,7 +75,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
showErrorAlert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
},
|
||||
onError: (error) => {
|
||||
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
import { DatePicker, Host } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRescheduleBooking } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
export interface RescheduleScreenProps {
|
||||
@@ -57,7 +58,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
if (!booking || isSaving) return;
|
||||
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
showErrorAlert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,13 +70,12 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Booking rescheduled successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
showSuccessAlert("Success", "Booking rescheduled successfully");
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRescheduleBooking } from "@/hooks/useBookings";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -76,7 +77,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
|
||||
// Validate the date is in the future
|
||||
if (selectedDateTime <= new Date()) {
|
||||
Alert.alert("Error", "Please select a future date and time");
|
||||
showErrorAlert("Error", "Please select a future date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,13 +94,18 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Booking rescheduled successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
if (Platform.OS === "web") {
|
||||
showSuccessAlert("Success", "Booking rescheduled successfully");
|
||||
onSuccess();
|
||||
} else {
|
||||
Alert.alert("Success", "Booking rescheduled successfully", [
|
||||
{ text: "OK", onPress: onSuccess },
|
||||
]);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
safeLogError("[RescheduleScreen] Failed to reschedule:", error);
|
||||
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
|
||||
showErrorAlert("Error", "Failed to reschedule booking. Please try again.");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Global Toast Component
|
||||
*
|
||||
* A centered toast notification component for web/browser extension.
|
||||
* Displays in the center of the screen with a subtle backdrop blur effect.
|
||||
*
|
||||
* Features:
|
||||
* - Centered horizontally and vertically
|
||||
* - Clean, minimal, professional design
|
||||
* - Subtle backdrop blur effect
|
||||
* - Rounded card with shadow
|
||||
* - Icon based on type (checkmark for success, X for error, info circle for info)
|
||||
* - Title in bold, message in regular weight
|
||||
* - Fade in/out animation
|
||||
* - Auto-dismiss after 2500ms (handled by ToastProvider)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { GlobalToast } from '@/components/ui/GlobalToast';
|
||||
* import { ToastProvider } from '@/contexts/ToastContext';
|
||||
*
|
||||
* // In root layout
|
||||
* <ToastProvider>
|
||||
* <App />
|
||||
* <GlobalToast />
|
||||
* </ToastProvider>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Animated, Platform, StyleSheet, Text, View } from "react-native";
|
||||
|
||||
import { useGlobalToast, type ToastType } from "@/contexts/ToastContext";
|
||||
|
||||
const ICON_CONFIG: Record<ToastType, { name: keyof typeof Ionicons.glyphMap; color: string }> = {
|
||||
success: { name: "checkmark-circle", color: "#000000" },
|
||||
error: { name: "close-circle", color: "#000000" },
|
||||
info: { name: "information-circle", color: "#000000" },
|
||||
};
|
||||
|
||||
export function GlobalToast() {
|
||||
const { toast } = useGlobalToast();
|
||||
// Use useState to preserve Animated.Value instance across renders
|
||||
const [fadeAnim] = useState(() => new Animated.Value(0));
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.visible) {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [toast.visible, fadeAnim]);
|
||||
|
||||
// Expand iframe on web when toast is visible (to center on screen)
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (toast.visible) {
|
||||
window.parent.postMessage({ type: "cal-companion-expand" }, "*");
|
||||
} else {
|
||||
window.parent.postMessage({ type: "cal-companion-collapse" }, "*");
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup: collapse on unmount if we expanded
|
||||
if (Platform.OS === "web" && toast.visible) {
|
||||
window.parent.postMessage({ type: "cal-companion-collapse" }, "*");
|
||||
}
|
||||
};
|
||||
}, [toast.visible]);
|
||||
|
||||
// Only render on web platform
|
||||
if (Platform.OS !== "web") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!toast.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iconConfig = ICON_CONFIG[toast.type];
|
||||
|
||||
return (
|
||||
<View style={styles.overlay} pointerEvents="none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name={iconConfig.name} size={24} color={iconConfig.color} style={styles.icon} />
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title}>{toast.title}</Text>
|
||||
{toast.message ? <Text style={styles.message}>{toast.message}</Text> : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 9999,
|
||||
},
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: "#E5E7EB",
|
||||
backgroundColor: "#FFFFFF",
|
||||
maxWidth: 640,
|
||||
minWidth: 400,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
icon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
color: "#000000",
|
||||
marginBottom: 2,
|
||||
},
|
||||
message: {
|
||||
fontSize: 13,
|
||||
fontWeight: "400",
|
||||
color: "#374151",
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Toast Context
|
||||
*
|
||||
* Global toast context with provider that wraps the app.
|
||||
* Manages toast state globally and exposes showToast function via context.
|
||||
* Used for cross-platform alert system - shows native Alert on iOS/Android
|
||||
* and centered toast on web (browser extension).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { ToastProvider, useToast } from '../contexts';
|
||||
*
|
||||
* // In root layout
|
||||
* <ToastProvider>
|
||||
* <App />
|
||||
* <GlobalToast />
|
||||
* </ToastProvider>
|
||||
*
|
||||
* // In any component
|
||||
* const { showToast } = useToast();
|
||||
* showToast("Success!", "Operation completed", "success");
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { setGlobalToastFunction } from "@/utils/alerts";
|
||||
|
||||
export type ToastType = "success" | "error" | "info";
|
||||
|
||||
export interface ToastState {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: ToastState;
|
||||
showToast: (title: string, message: string, type?: ToastType) => void;
|
||||
hideToast: () => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const INITIAL_TOAST_STATE: ToastState = {
|
||||
visible: false,
|
||||
title: "",
|
||||
message: "",
|
||||
type: "success",
|
||||
};
|
||||
|
||||
const AUTO_DISMISS_MS = 2500;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toast, setToast] = useState<ToastState>(INITIAL_TOAST_STATE);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hideToast = useCallback(() => {
|
||||
setToast(INITIAL_TOAST_STATE);
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(
|
||||
(title: string, message: string, type: ToastType = "success") => {
|
||||
// Clear any existing timer
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
setToast({ visible: true, title, message, type });
|
||||
|
||||
// Set auto-dismiss timer
|
||||
timerRef.current = setTimeout(hideToast, AUTO_DISMISS_MS);
|
||||
},
|
||||
[hideToast]
|
||||
);
|
||||
|
||||
// Register the global toast function for use in alerts.ts
|
||||
useEffect(() => {
|
||||
setGlobalToastFunction(showToast);
|
||||
|
||||
return () => {
|
||||
setGlobalToastFunction(null);
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast, showToast, hideToast }}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the global toast context.
|
||||
* Must be used within a ToastProvider.
|
||||
*
|
||||
* @returns Toast context value with toast state and control functions
|
||||
* @throws Error if used outside of ToastProvider
|
||||
*/
|
||||
export function useGlobalToast(): ToastContextValue {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useGlobalToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { AuthProvider, useAuth, QueryProvider } from '../contexts';
|
||||
* import { AuthProvider, useAuth, QueryProvider, ToastProvider, useGlobalToast } from '../contexts';
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -14,3 +14,6 @@ export { AuthProvider, useAuth } from "./AuthContext";
|
||||
|
||||
// Query context
|
||||
export { QueryProvider, useQueryContext } from "./QueryContext";
|
||||
|
||||
// Toast context
|
||||
export { ToastProvider, useGlobalToast, type ToastState, type ToastType } from "./ToastContext";
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
BookingRecording,
|
||||
ConferencingSession,
|
||||
} from "@/services/types/bookings.types";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
|
||||
interface UseBookingActionModalsReturn {
|
||||
// Selected booking for actions
|
||||
@@ -121,7 +121,7 @@ export function useBookingActionModals(): UseBookingActionModalsReturn {
|
||||
setIsAddingGuests(true);
|
||||
try {
|
||||
await CalComAPIService.addGuests(selectedBooking.uid, guests);
|
||||
Alert.alert(
|
||||
showSuccessAlert(
|
||||
"Success",
|
||||
"Guests added successfully. They will receive an email notification."
|
||||
);
|
||||
@@ -160,7 +160,7 @@ export function useBookingActionModals(): UseBookingActionModalsReturn {
|
||||
setIsUpdatingLocation(true);
|
||||
try {
|
||||
await CalComAPIService.updateLocation(selectedBooking.uid, location);
|
||||
Alert.alert(
|
||||
showSuccessAlert(
|
||||
"Success",
|
||||
"Location updated successfully. Note: The calendar event may not be automatically updated."
|
||||
);
|
||||
@@ -257,7 +257,10 @@ export function useBookingActionModals(): UseBookingActionModalsReturn {
|
||||
setIsMarkingNoShow(true);
|
||||
try {
|
||||
await CalComAPIService.markAbsent(selectedBooking.uid, attendeeEmail, absent);
|
||||
Alert.alert("Success", absent ? "Attendee marked as no-show." : "No-show status removed.");
|
||||
showSuccessAlert(
|
||||
"Success",
|
||||
absent ? "Attendee marked as no-show." : "No-show status removed."
|
||||
);
|
||||
invalidateBookingQueries();
|
||||
closeMarkNoShowModal();
|
||||
setIsMarkingNoShow(false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, Platform } from "react-native";
|
||||
import type { Booking } from "@/services/calcom";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
|
||||
|
||||
interface UseBookingActionsParams {
|
||||
router: ReturnType<typeof useRouter>;
|
||||
@@ -158,7 +158,7 @@ export const useBookingActions = ({
|
||||
onSuccess: () => {
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleBooking(null);
|
||||
Alert.alert("Success", "Booking rescheduled successfully");
|
||||
showSuccessAlert("Success", "Booking rescheduled successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorAlert("Error", error.message || "Failed to reschedule booking");
|
||||
@@ -209,7 +209,7 @@ export const useBookingActions = ({
|
||||
onSuccess: () => {
|
||||
setShowRescheduleModal(false);
|
||||
setRescheduleBooking(null);
|
||||
Alert.alert("Success", "Booking rescheduled successfully");
|
||||
showSuccessAlert("Success", "Booking rescheduled successfully");
|
||||
resolve();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -252,7 +252,7 @@ export const useBookingActions = ({
|
||||
setShowCancelModal(false);
|
||||
setCancelBooking(null);
|
||||
setCancelReason("");
|
||||
Alert.alert("Success", "Event cancelled successfully");
|
||||
showSuccessAlert("Success", "Event cancelled successfully");
|
||||
},
|
||||
onError: (_error) => {
|
||||
console.error("Failed to cancel booking");
|
||||
@@ -275,8 +275,8 @@ export const useBookingActions = ({
|
||||
* Show alert and cancel booking (iOS Alert.prompt pattern, Android opens modal)
|
||||
*/
|
||||
const handleCancelBooking = (booking: Booking) => {
|
||||
// For Android, open the cancel modal with AlertDialog
|
||||
if (Platform.OS === "android") {
|
||||
// For Android and Web, open the cancel modal (AlertDialog on Android, content-modal on Web)
|
||||
if (Platform.OS === "android" || Platform.OS === "web") {
|
||||
handleOpenCancelModal(booking);
|
||||
return;
|
||||
}
|
||||
@@ -303,7 +303,7 @@ export const useBookingActions = ({
|
||||
{ uid: booking.uid, reason: cancellationReason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Event cancelled successfully");
|
||||
showSuccessAlert("Success", "Event cancelled successfully");
|
||||
},
|
||||
onError: (_error) => {
|
||||
console.error("Failed to cancel booking");
|
||||
@@ -336,7 +336,7 @@ export const useBookingActions = ({
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Booking confirmed successfully");
|
||||
showSuccessAlert("Success", "Booking confirmed successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorAlert("Error", error.message || "Failed to confirm booking");
|
||||
@@ -380,7 +380,7 @@ export const useBookingActions = ({
|
||||
{ uid: booking.uid, reason: reason || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Booking declined successfully");
|
||||
showSuccessAlert("Success", "Booking declined successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorAlert("Error", error.message || "Failed to decline booking");
|
||||
@@ -416,7 +416,7 @@ export const useBookingActions = ({
|
||||
setShowRejectModal(false);
|
||||
setRejectBooking(null);
|
||||
setRejectReason("");
|
||||
Alert.alert("Success", "Booking rejected successfully");
|
||||
showSuccessAlert("Success", "Booking rejected successfully");
|
||||
},
|
||||
onError: (_error) => {
|
||||
showErrorAlert("Error", "Failed to reject booking. Please try again.");
|
||||
@@ -442,7 +442,7 @@ export const useBookingActions = ({
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
onSuccess: () => {
|
||||
Alert.alert("Success", "Booking confirmed successfully");
|
||||
showSuccessAlert("Success", "Booking confirmed successfully");
|
||||
},
|
||||
onError: (_error) => {
|
||||
showErrorAlert("Error", "Failed to confirm booking. Please try again.");
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
/**
|
||||
* Alert Utilities
|
||||
*
|
||||
* Helper functions for showing alerts with environment-aware behavior.
|
||||
* Helper functions for showing alerts with environment-aware and platform-aware behavior.
|
||||
* On iOS/Android, uses native Alert.alert().
|
||||
* On web (browser extension), uses the global toast system for centered notifications.
|
||||
* Error alerts are only shown in development mode to avoid confusing users in production.
|
||||
*/
|
||||
|
||||
import { Alert } from "react-native";
|
||||
import { Alert, Platform } from "react-native";
|
||||
|
||||
type ToastType = "success" | "error" | "info";
|
||||
|
||||
/**
|
||||
* Global toast reference (set by ToastProvider)
|
||||
* This allows the alert utilities to show toasts on web without requiring React context.
|
||||
*/
|
||||
let globalShowToast: ((title: string, message: string, type?: ToastType) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Set the global toast function. Called by ToastProvider on mount.
|
||||
* @param fn - The showToast function from ToastProvider, or null to clear
|
||||
*/
|
||||
export const setGlobalToastFunction = (fn: typeof globalShowToast) => {
|
||||
globalShowToast = fn;
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an error alert only in development mode.
|
||||
* On web, shows a centered toast notification.
|
||||
* In production, errors are silently logged to console.
|
||||
*
|
||||
* @param title - The alert title
|
||||
* @param message - The error message to display
|
||||
*/
|
||||
export const showErrorAlert = (title: string, message: string) => {
|
||||
if (Platform.OS === "web" && globalShowToast) {
|
||||
if (__DEV__) {
|
||||
globalShowToast(title, message, "error");
|
||||
} else {
|
||||
console.error(`[${title}] ${message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
Alert.alert(title, message);
|
||||
} else {
|
||||
@@ -25,15 +53,38 @@ export const showErrorAlert = (title: string, message: string) => {
|
||||
/**
|
||||
* Show a confirmation alert (always shown - user-initiated actions)
|
||||
* This is a direct reference to Alert.alert for consistency.
|
||||
* Note: On web, this will not work - use a modal or custom confirmation dialog instead.
|
||||
*/
|
||||
export const showConfirmAlert = Alert.alert;
|
||||
|
||||
/**
|
||||
* Show a success alert (always shown - user feedback)
|
||||
* On web, shows a centered toast notification.
|
||||
*
|
||||
* @param title - The alert title
|
||||
* @param message - The success message to display
|
||||
*/
|
||||
export const showSuccessAlert = (title: string, message: string) => {
|
||||
if (Platform.OS === "web" && globalShowToast) {
|
||||
globalShowToast(title, message, "success");
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(title, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an info alert (always shown - informational feedback)
|
||||
* On web, shows a centered toast notification.
|
||||
*
|
||||
* @param title - The alert title
|
||||
* @param message - The info message to display
|
||||
*/
|
||||
export const showInfoAlert = (title: string, message: string) => {
|
||||
if (Platform.OS === "web" && globalShowToast) {
|
||||
globalShowToast(title, message, "info");
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(title, message);
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
import * as Linking from "expo-linking";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Alert } from "react-native";
|
||||
import { showErrorAlert } from "@/utils/alerts";
|
||||
|
||||
// Default Cal.com web URL - can be overridden for self-hosted instances
|
||||
const DEFAULT_CAL_WEB_URL = "https://app.cal.com";
|
||||
@@ -41,7 +42,7 @@ export async function openBookingInWeb(bookingUid: string): Promise<void> {
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to open booking in browser. Please try again.");
|
||||
showErrorAlert("Error", "Failed to open booking in browser. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,7 +99,7 @@ export async function openReschedulePage(
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to open reschedule page. Please try again.");
|
||||
showErrorAlert("Error", "Failed to open reschedule page. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export async function openCancelBookingInWeb(bookingUid: string): Promise<void>
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch {
|
||||
Alert.alert("Error", "Failed to open booking page. Please try again.");
|
||||
showErrorAlert("Error", "Failed to open booking page. Please try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
// Alert utilities
|
||||
export { showErrorAlert } from "./alerts";
|
||||
export { setGlobalToastFunction, showErrorAlert, showInfoAlert, showSuccessAlert } from "./alerts";
|
||||
// Browser utilities
|
||||
export { openInAppBrowser } from "./browser";
|
||||
// Default location utilities
|
||||
|
||||
Reference in new issue
Block a user