diff --git a/companion/bun.lock b/companion/bun.lock index d42c9d099b..70c9a3e177 100644 --- a/companion/bun.lock +++ b/companion/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "cal-companion", @@ -33,6 +34,7 @@ "expo-secure-store": "15.0.9-canary-20251230-fc48ddc", "expo-splash-screen": "31.0.14-canary-20251230-fc48ddc", "expo-status-bar": "3.0.10-canary-20251230-fc48ddc", + "expo-store-review": "9.0.10-canary-20251230-fc48ddc", "expo-web-browser": "15.0.11-canary-20251230-fc48ddc", "lucide-react-native": "0.562.0", "nativewind": "4.2.1", @@ -1133,6 +1135,8 @@ "expo-status-bar": ["expo-status-bar@3.0.10-canary-20251230-fc48ddc", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.2.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Lkbb3e6yyRtkmjpTfwIYbO9XDmhPeSipyJLBa5nADq8eiGf7kXZ1pmaLEelPl05azCPY3109bJzhvbonz86sIg=="], + "expo-store-review": ["expo-store-review@9.0.10-canary-20251230-fc48ddc", "", { "peerDependencies": { "expo": "55.0.0-canary-20251230-fc48ddc", "react-native": "*" } }, "sha512-n100Y4mTa2EnYbJItbtV8rMYPMimnV9Qgaz44s6g8zk59Jv1Qtv1fsCEXwD1M82QQc6YYmDHkOsN3fbi4ASrEQ=="], + "expo-symbols": ["expo-symbols@1.1.0-canary-20251230-fc48ddc", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "55.0.0-canary-20251230-fc48ddc", "expo-font": "14.1.0-canary-20251230-fc48ddc", "react": "*", "react-native": "*" } }, "sha512-3cDn97a5NP6bBBHhN/U9/QKZrvqoSUOYtaTPbfH/WBDJC/kM6hzD8/BQBNe7Vt9ygcAh2iFx3w5DcFlxsMyKAw=="], "expo-updates-interface": ["expo-updates-interface@2.0.1-canary-20251230-fc48ddc", "", { "peerDependencies": { "expo": "55.0.0-canary-20251230-fc48ddc" } }, "sha512-ERfTb7zR5lAknBGkMs0devAlyFrD3he4SQJtGItIrov99JQRVYoEfCImLYIhzaancLXnHJLpuQtaK0c3mK41FA=="], diff --git a/companion/hooks/useAppStoreRating.ts b/companion/hooks/useAppStoreRating.ts new file mode 100644 index 0000000000..5f3742e0e0 --- /dev/null +++ b/companion/hooks/useAppStoreRating.ts @@ -0,0 +1,102 @@ +import { useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Platform } from "react-native"; + +/** + * Rating triggers - add new triggers here as needed. + * Each trigger will only prompt the user once (tracked via AsyncStorage). + */ +export const RatingTrigger = { + EVENT_TYPE_SAVED: "event_type_saved", + BOOKING_CONFIRMED: "booking_confirmed", + BOOKING_REJECTED: "booking_rejected", +} as const; + +export type RatingTriggerType = (typeof RatingTrigger)[keyof typeof RatingTrigger]; + +const STORAGE_KEY_PREFIX = "app_rating_triggered_"; + +function getStorageKey(trigger: RatingTriggerType): string { + return `${STORAGE_KEY_PREFIX}${trigger}`; +} + +async function requestReviewIfAvailable(): Promise { + // Only available on native platforms (iOS/Android) + if (Platform.OS === "web") { + return false; + } + + try { + // Dynamic import to avoid bundling issues on web + const StoreReview = await import("expo-store-review"); + const isAvailable = await StoreReview.isAvailableAsync(); + if (isAvailable) { + await StoreReview.requestReview(); + return true; + } + return false; + } catch (error) { + console.warn("Failed to request app store review:", error); + return false; + } +} + +async function hasAlreadyTriggered(trigger: RatingTriggerType): Promise { + try { + const value = await AsyncStorage.getItem(getStorageKey(trigger)); + return value === "true"; + } catch (error) { + console.warn("Failed to check rating trigger status:", error); + return false; + } +} + +async function markAsTriggered(trigger: RatingTriggerType): Promise { + try { + await AsyncStorage.setItem(getStorageKey(trigger), "true"); + } catch (error) { + console.warn("Failed to mark rating as triggered:", error); + } +} + +/** + * Request an app store rating for a specific trigger. + * Only prompts the user once per trigger type. + * + * @param trigger - The trigger type from RatingTrigger + * @returns true if the rating dialog was shown, false otherwise + * + * @example + * // Request rating after first event type save + * await requestRating(RatingTrigger.EVENT_TYPE_SAVED); + * + * // Request rating after first booking confirmation + * await requestRating(RatingTrigger.BOOKING_CONFIRMED); + */ +export async function requestRating(trigger: RatingTriggerType): Promise { + const alreadyTriggered = await hasAlreadyTriggered(trigger); + if (alreadyTriggered) { + return false; + } + + await markAsTriggered(trigger); + return requestReviewIfAvailable(); +} + +/** + * Hook for requesting app store ratings. + * Provides a memoized callback for use in React components. + */ +export function useAppStoreRating() { + const requestRatingCallback = useCallback( + async (trigger: RatingTriggerType): Promise => { + return requestRating(trigger); + }, + [] + ); + + return { + requestRating: requestRatingCallback, + RatingTrigger, + }; +} diff --git a/companion/hooks/useBookings.ts b/companion/hooks/useBookings.ts index d8dda5df98..05a3925924 100644 --- a/companion/hooks/useBookings.ts +++ b/companion/hooks/useBookings.ts @@ -12,6 +12,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CACHE_CONFIG, queryKeys } from "@/config/cache.config"; import { type Booking, CalComAPIService } from "@/services/calcom"; +import { requestRating, RatingTrigger } from "@/hooks/useAppStoreRating"; /** * Filter options for fetching bookings @@ -225,6 +226,9 @@ export function useConfirmBooking() { queryClient.invalidateQueries({ queryKey: queryKeys.bookings.detail(variables.uid), }); + + // Request app store rating on first booking confirmation + requestRating(RatingTrigger.BOOKING_CONFIRMED); }, onError: (_error) => { console.error("Failed to confirm booking"); @@ -258,6 +262,9 @@ export function useDeclineBooking() { queryClient.invalidateQueries({ queryKey: queryKeys.bookings.detail(variables.uid), }); + + // Request app store rating on first booking rejection + requestRating(RatingTrigger.BOOKING_REJECTED); }, onError: (_error) => { console.error("Failed to decline booking"); diff --git a/companion/hooks/useEventTypes.ts b/companion/hooks/useEventTypes.ts index f06a0da838..97f2482714 100644 --- a/companion/hooks/useEventTypes.ts +++ b/companion/hooks/useEventTypes.ts @@ -12,6 +12,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CACHE_CONFIG, queryKeys } from "@/config/cache.config"; import { CalComAPIService, type CreateEventTypeInput, type EventType } from "@/services/calcom"; +import { requestRating, RatingTrigger } from "@/hooks/useAppStoreRating"; /** * Hook to fetch all event types @@ -94,6 +95,9 @@ export function useCreateEventType() { // Optionally, add the new event type to cache immediately queryClient.setQueryData(queryKeys.eventTypes.detail(newEventType.id), newEventType); + + // Request app store rating on first event type save + requestRating(RatingTrigger.EVENT_TYPE_SAVED); }, onError: (error) => { console.error("Failed to create event type"); @@ -180,6 +184,9 @@ export function useUpdateEventType() { // If list cache doesn't exist, invalidate to trigger refetch when user navigates to list queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() }); } + + // Request app store rating on first event type save + requestRating(RatingTrigger.EVENT_TYPE_SAVED); }, onError: (error, variables, context) => { // Rollback to previous values on error diff --git a/companion/package.json b/companion/package.json index 7c9b9cf669..0e293d93cf 100644 --- a/companion/package.json +++ b/companion/package.json @@ -78,6 +78,7 @@ "expo-secure-store": "15.0.9-canary-20251230-fc48ddc", "expo-splash-screen": "31.0.14-canary-20251230-fc48ddc", "expo-status-bar": "3.0.10-canary-20251230-fc48ddc", + "expo-store-review": "9.0.10-canary-20251230-fc48ddc", "expo-web-browser": "15.0.11-canary-20251230-fc48ddc", "lucide-react-native": "0.562.0", "nativewind": "4.2.1",