Files
cal.diy/companion/hooks/useEventTypes.ts
T
8725733f2c 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>
2026-01-25 09:09:35 -03:00

362 lines
11 KiB
TypeScript

/**
* Event Types Query Hooks
*
* This module provides React Query hooks for fetching and mutating event types.
* It integrates with the existing CalComAPIService and provides:
* - Automatic caching with configurable stale times
* - Pull-to-refresh support via refetch
* - Optimistic updates for mutations
* - Cache invalidation on create/update/delete
*/
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
*
* @returns Query result with event types data, loading state, error, and refetch function
*
* @example
* ```tsx
* const { data: eventTypes, isLoading, refetch, isRefetching } = useEventTypes();
*
* // Pull-to-refresh
* <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
* ```
*/
export function useEventTypes() {
return useQuery({
queryKey: queryKeys.eventTypes.lists(),
queryFn: () => CalComAPIService.getEventTypes(),
staleTime: CACHE_CONFIG.eventTypes.staleTime,
// Keep previous data while fetching new data (smoother UX)
placeholderData: (previousData) => previousData,
// Don't retry on network errors (keeps cache intact)
retry: (failureCount, error) => {
if (error?.message?.includes("Network") || error?.message?.includes("fetch")) {
return false;
}
return failureCount < 2;
},
refetchOnReconnect: true,
});
}
/**
* Hook to fetch a single event type by ID
*
* @param id - The ID of the event type
* @returns Query result with event type data
*
* @example
* ```tsx
* const { data: eventType, isLoading } = useEventTypeById(123);
* ```
*/
export function useEventTypeById(id: number | undefined) {
return useQuery({
queryKey: queryKeys.eventTypes.detail(id || 0),
queryFn: () => {
if (!id) throw new Error("id is required");
return CalComAPIService.getEventTypeById(id);
},
enabled: !!id, // Only fetch when id is provided
staleTime: CACHE_CONFIG.eventTypes.staleTime,
});
}
/**
* Hook to create a new event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: createEventType, isPending } = useCreateEventType();
*
* createEventType({
* title: 'Quick Chat',
* slug: 'quick-chat',
* lengthInMinutes: 15,
* });
* ```
*/
export function useCreateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateEventTypeInput) => CalComAPIService.createEventType(input),
onSuccess: (newEventType) => {
// Invalidate the list to include the new event type
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
// 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");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useCreateEventType] failed", { message, stack });
}
},
});
}
/**
* Hook to update an event type with optimistic updates
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: updateEventType, isPending } = useUpdateEventType();
*
* updateEventType({
* id: 123,
* updates: { title: 'Updated Title' }
* });
* ```
*/
export function useUpdateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, updates }: { id: number; updates: Record<string, unknown> }) =>
CalComAPIService.updateEventType(id, updates),
onMutate: async ({ id, updates }) => {
// Cancel any outgoing refetches to prevent overwriting optimistic update
await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.detail(id) });
await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.lists() });
// Snapshot the previous values for rollback
const previousEventType = queryClient.getQueryData<EventType | null>(
queryKeys.eventTypes.detail(id)
);
const previousEventTypes = queryClient.getQueryData<EventType[]>(
queryKeys.eventTypes.lists()
);
// Optimistically update the detail cache if it exists
if (previousEventType) {
const optimisticEventType: EventType = {
...previousEventType,
...updates,
};
queryClient.setQueryData(queryKeys.eventTypes.detail(id), optimisticEventType);
}
// Update the list cache optimistically (even if detail cache doesn't exist)
if (previousEventTypes) {
const updatedList = previousEventTypes.map((et) => {
if (et.id === id) {
return {
...et,
...updates,
};
}
return et;
});
queryClient.setQueryData(queryKeys.eventTypes.lists(), updatedList);
}
return { previousEventType, previousEventTypes };
},
onSuccess: (updatedEventType, variables) => {
// Update the specific event type in cache with server response
queryClient.setQueryData(queryKeys.eventTypes.detail(variables.id), updatedEventType);
// Update the list cache with the server response
const currentEventTypes = queryClient.getQueryData<EventType[]>(queryKeys.eventTypes.lists());
if (currentEventTypes) {
const updatedList = currentEventTypes.map((et) =>
et.id === variables.id ? updatedEventType : et
);
queryClient.setQueryData(queryKeys.eventTypes.lists(), updatedList);
} else {
// 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
if (context?.previousEventType) {
queryClient.setQueryData(
queryKeys.eventTypes.detail(variables.id),
context.previousEventType
);
}
if (context?.previousEventTypes) {
queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes);
}
console.error("Failed to update event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useUpdateEventType] failed", { message, stack });
}
},
});
}
/**
* Hook to delete an event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: deleteEventType, isPending } = useDeleteEventType();
*
* deleteEventType(123);
* ```
*/
export function useDeleteEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => CalComAPIService.deleteEventType(id),
onMutate: async (deletedId) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.eventTypes.lists() });
// Snapshot the previous value
const previousEventTypes = queryClient.getQueryData<EventType[]>(
queryKeys.eventTypes.lists()
);
// Optimistically remove from the list
if (previousEventTypes) {
queryClient.setQueryData(
queryKeys.eventTypes.lists(),
previousEventTypes.filter((et) => et.id !== deletedId)
);
}
return { previousEventTypes };
},
onError: (error, _deletedId, context) => {
// Rollback on error
if (context?.previousEventTypes) {
queryClient.setQueryData(queryKeys.eventTypes.lists(), context.previousEventTypes);
}
console.error("Failed to delete event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useDeleteEventType] failed", { message, stack });
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
},
});
}
/**
* Hook to duplicate an event type
*
* @returns Mutation function and state
*
* @example
* ```tsx
* const { mutate: duplicateEventType, isPending } = useDuplicateEventType();
*
* duplicateEventType({
* eventType: existingEventType,
* existingEventTypes: allEventTypes
* });
* ```
*/
export function useDuplicateEventType() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
eventType,
existingEventTypes,
}: {
eventType: EventType;
existingEventTypes: EventType[];
}) => {
// Generate a new title and slug for the duplicate
const newTitle = `${eventType.title} (copy)`;
let newSlug = `${eventType.slug}-copy`;
// Check if slug already exists and append a number if needed
let counter = 1;
while (existingEventTypes.some((et) => et.slug === newSlug)) {
newSlug = `${eventType.slug}-copy-${counter}`;
counter++;
}
const duration = eventType.lengthInMinutes ?? eventType.length ?? 15;
return CalComAPIService.createEventType({
title: newTitle,
slug: newSlug,
lengthInMinutes: duration,
description: eventType.description || undefined,
});
},
onSuccess: () => {
// Invalidate the list to include the duplicated event type
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.lists() });
},
onError: (error) => {
console.error("Failed to duplicate event type");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.debug("[useDuplicateEventType] failed", { message, stack });
}
},
});
}
/**
* Hook to prefetch event types (useful for navigation)
*
* @returns Function to prefetch event types
*/
export function usePrefetchEventTypes() {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: queryKeys.eventTypes.lists(),
queryFn: () => CalComAPIService.getEventTypes(),
staleTime: CACHE_CONFIG.eventTypes.staleTime,
});
};
}
/**
* Hook to invalidate all event types cache
*
* @returns Function to invalidate event types cache
*/
export function useInvalidateEventTypes() {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries({ queryKey: queryKeys.eventTypes.all });
};
}
/**
* Type exports for consumers
*/
export type { EventType, CreateEventTypeInput };