feat(companion): add app store rating prompts for iOS and Android (#27210)

* feat(companion): add app store rating prompts for iOS and Android

- Add useAppStoreRating hook with generalized requestRating(trigger) function
- Add RatingTrigger const for extensible trigger types (EVENT_TYPE_SAVED, BOOKING_CONFIRMED, BOOKING_REJECTED)
- Integrate rating prompt into useCreateEventType and useUpdateEventType hooks
- Integrate rating prompt into useConfirmBooking and useDeclineBooking hooks
- Add expo-store-review package dependency
- Rating prompts only show once per trigger type (tracked via AsyncStorage)

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix(companion): correct expo-store-review version to 9.0.10

Co-Authored-By: peer@cal.com <peer@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Peer Richelsen
2026-01-25 09:09:35 -03:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent b02af9ae53
commit 8725733f2c
5 changed files with 121 additions and 0 deletions
+4
View File
@@ -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=="],
+102
View File
@@ -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<boolean> {
// 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<boolean> {
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<void> {
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<boolean> {
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<boolean> => {
return requestRating(trigger);
},
[]
);
return {
requestRating: requestRatingCallback,
RatingTrigger,
};
}
+7
View File
@@ -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");
+7
View File
@@ -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
+1
View File
@@ -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",