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:
co-authored by
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
b02af9ae53
commit
8725733f2c
@@ -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=="],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user