← Volver al listado de tecnologías
Capítulo 4: Eventos In-App con TypeScript y Zod
Capítulo 4: Eventos In-App con TypeScript y Zod
En este capítulo aprenderás a implementar eventos in-app usando componentes funcionales de React Native, TypeScript con tipado estricto y validación con Zod.
🎯 Objetivos del Capítulo
Al finalizar este capítulo serás capaz de:
- ✅ Crear eventos con validación estricta usando Zod
- ✅ Implementar hooks personalizados para eventos
- ✅ Usar componentes funcionales con TypeScript
- ✅ Manejar eventos de revenue y e-commerce
- ✅ Implementar tracking de errores y performance
📦 Configuración Inicial
Instalación de Dependencias
npm install zod react-native-appsflyer
npm install --save-dev @types/react @types/react-native
🎨 Definición de Schemas con Zod
Eventos Predefinidos de AppsFlyer
// schemas/events.schema.ts
import { z } from 'zod';
// Schema base para todos los eventos
const BaseEventSchema = z.object({
timestamp: z.number().optional(),
session_id: z.string().optional(),
user_id: z.string().optional(),
});
// Evento de Purchase (Compra)
export const PurchaseEventSchema = BaseEventSchema.extend({
af_revenue: z.number().positive(),
af_currency: z.string().length(3), // ISO 4217
af_quantity: z.number().int().positive().default(1),
af_content_id: z.string(),
af_content_type: z.string().optional(),
af_receipt_id: z.string().optional(),
af_order_id: z.string().optional(),
});
export type PurchaseEvent = z.infer<typeof PurchaseEventSchema>;
// Evento de Add to Cart
export const AddToCartEventSchema = BaseEventSchema.extend({
af_content_id: z.string(),
af_content_type: z.string(),
af_price: z.number().nonnegative(),
af_currency: z.string().length(3),
af_quantity: z.number().int().positive().default(1),
af_content: z.array(z.object({
product_id: z.string(),
quantity: z.number(),
price: z.number(),
})).optional(),
});
export type AddToCartEvent = z.infer<typeof AddToCartEventSchema>;
// Evento de Registration
export const RegistrationEventSchema = BaseEventSchema.extend({
af_registration_method: z.enum(['email', 'facebook', 'google', 'apple', 'phone']),
af_registration_success: z.boolean(),
user_email: z.string().email().optional(),
user_phone: z.string().optional(),
});
export type RegistrationEvent = z.infer<typeof RegistrationEventSchema>;
// Evento de Level Achievement (Gaming)
export const LevelAchievementSchema = BaseEventSchema.extend({
af_level: z.number().int().positive(),
af_score: z.number().int().nonnegative(),
af_success: z.boolean(),
time_spent: z.number().nonnegative(), // segundos
power_ups_used: z.number().int().nonnegative().optional(),
});
export type LevelAchievementEvent = z.infer<typeof LevelAchievementSchema>;
// Evento de Search
export const SearchEventSchema = BaseEventSchema.extend({
af_search_string: z.string().min(1),
af_content_type: z.string().optional(),
af_results_count: z.number().int().nonnegative(),
search_category: z.string().optional(),
filters_applied: z.record(z.string(), z.any()).optional(),
});
export type SearchEvent = z.infer<typeof SearchEventSchema>;
// Evento de Content View
export const ContentViewSchema = BaseEventSchema.extend({
af_content_id: z.string(),
af_content_type: z.enum(['product', 'article', 'video', 'category', 'other']),
af_content_title: z.string().optional(),
af_price: z.number().optional(),
af_currency: z.string().length(3).optional(),
view_duration: z.number().optional(), // segundos
});
export type ContentViewEvent = z.infer<typeof ContentViewSchema>;
// Schema para eventos personalizados
export const CustomEventSchema = BaseEventSchema.extend({
event_category: z.string(),
event_action: z.string(),
event_label: z.string().optional(),
event_value: z.union([z.string(), z.number(), z.boolean()]).optional(),
custom_parameters: z.record(z.string(), z.any()).optional(),
});
export type CustomEvent = z.infer<typeof CustomEventSchema>;
🪝 Hook Principal para Eventos
// hooks/useAppsFlyerEvents.ts
import { useCallback, useRef } from 'react';
import appsFlyer from 'react-native-appsflyer';
import { z } from 'zod';
import {
PurchaseEventSchema,
AddToCartEventSchema,
RegistrationEventSchema,
LevelAchievementSchema,
SearchEventSchema,
ContentViewSchema,
CustomEventSchema,
type PurchaseEvent,
type AddToCartEvent,
type RegistrationEvent,
type LevelAchievementEvent,
type SearchEvent,
type ContentViewEvent,
type CustomEvent,
} from '../schemas/events.schema';
interface EventError {
eventName: string;
error: Error | z.ZodError;
timestamp: number;
}
interface UseAppsFlyerEventsReturn {
logPurchase: (data: PurchaseEvent) => Promise<void>;
logAddToCart: (data: AddToCartEvent) => Promise<void>;
logRegistration: (data: RegistrationEvent) => Promise<void>;
logLevelAchievement: (data: LevelAchievementEvent) => Promise<void>;
logSearch: (data: SearchEvent) => Promise<void>;
logContentView: (data: ContentViewEvent) => Promise<void>;
logCustomEvent: (eventName: string, data: CustomEvent) => Promise<void>;
getEventErrors: () => EventError[];
clearErrors: () => void;
}
export const useAppsFlyerEvents = (): UseAppsFlyerEventsReturn => {
const errors = useRef<EventError[]>([]);
const handleError = useCallback((eventName: string, error: Error | z.ZodError) => {
const eventError: EventError = {
eventName,
error,
timestamp: Date.now(),
};
errors.current.push(eventError);
console.error(`❌ Error en evento ${eventName}:`, error);
// Limitar el array de errores a los últimos 50
if (errors.current.length > 50) {
errors.current = errors.current.slice(-50);
}
}, []);
const logPurchase = useCallback(async (data: PurchaseEvent) => {
try {
const validatedData = PurchaseEventSchema.parse(data);
await appsFlyer.logEvent('af_purchase', validatedData);
console.log('💰 Evento de compra enviado:', validatedData);
} catch (error) {
handleError('af_purchase', error as Error);
throw error;
}
}, [handleError]);
const logAddToCart = useCallback(async (data: AddToCartEvent) => {
try {
const validatedData = AddToCartEventSchema.parse(data);
await appsFlyer.logEvent('af_add_to_cart', validatedData);
console.log('🛒 Evento add to cart enviado:', validatedData);
} catch (error) {
handleError('af_add_to_cart', error as Error);
throw error;
}
}, [handleError]);
const logRegistration = useCallback(async (data: RegistrationEvent) => {
try {
const validatedData = RegistrationEventSchema.parse(data);
await appsFlyer.logEvent('af_complete_registration', validatedData);
console.log('📝 Evento de registro enviado:', validatedData);
} catch (error) {
handleError('af_complete_registration', error as Error);
throw error;
}
}, [handleError]);
const logLevelAchievement = useCallback(async (data: LevelAchievementEvent) => {
try {
const validatedData = LevelAchievementSchema.parse(data);
await appsFlyer.logEvent('af_level_achieved', validatedData);
console.log('🎮 Evento de nivel enviado:', validatedData);
} catch (error) {
handleError('af_level_achieved', error as Error);
throw error;
}
}, [handleError]);
const logSearch = useCallback(async (data: SearchEvent) => {
try {
const validatedData = SearchEventSchema.parse(data);
await appsFlyer.logEvent('af_search', validatedData);
console.log('🔍 Evento de búsqueda enviado:', validatedData);
} catch (error) {
handleError('af_search', error as Error);
throw error;
}
}, [handleError]);
const logContentView = useCallback(async (data: ContentViewEvent) => {
try {
const validatedData = ContentViewSchema.parse(data);
await appsFlyer.logEvent('af_content_view', validatedData);
console.log('👁️ Evento de vista enviado:', validatedData);
} catch (error) {
handleError('af_content_view', error as Error);
throw error;
}
}, [handleError]);
const logCustomEvent = useCallback(async (eventName: string, data: CustomEvent) => {
try {
const validatedData = CustomEventSchema.parse(data);
await appsFlyer.logEvent(eventName, validatedData);
console.log(`📊 Evento personalizado ${eventName} enviado:`, validatedData);
} catch (error) {
handleError(eventName, error as Error);
throw error;
}
}, [handleError]);
const getEventErrors = useCallback(() => errors.current, []);
const clearErrors = useCallback(() => {
errors.current = [];
}, []);
return {
logPurchase,
logAddToCart,
logRegistration,
logLevelAchievement,
logSearch,
logContentView,
logCustomEvent,
getEventErrors,
clearErrors,
};
};
🛍️ Componente de E-commerce
// components/ProductCard.tsx
import React, { memo, useCallback } from 'react';
import {
View,
Text,
TouchableOpacity,
Image,
StyleSheet,
Alert,
} from 'react-native';
import { z } from 'zod';
import { useAppsFlyerEvents } from '../hooks/useAppsFlyerEvents';
// Schema para producto
const ProductSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number().positive(),
currency: z.string().length(3),
category: z.string(),
imageUrl: z.string().url(),
stock: z.number().int().nonnegative(),
});
type Product = z.infer<typeof ProductSchema>;
interface ProductCardProps {
product: Product;
onPurchase?: (product: Product) => void;
}
export const ProductCard: React.FC<ProductCardProps> = memo(({ product, onPurchase }) => {
const { logAddToCart, logPurchase, logContentView } = useAppsFlyerEvents();
// Validar producto al recibir props
const validatedProduct = ProductSchema.parse(product);
const handleView = useCallback(async () => {
try {
await logContentView({
af_content_id: validatedProduct.id,
af_content_type: 'product',
af_content_title: validatedProduct.name,
af_price: validatedProduct.price,
af_currency: validatedProduct.currency,
timestamp: Date.now(),
});
} catch (error) {
console.error('Error logging view:', error);
}
}, [validatedProduct, logContentView]);
const handleAddToCart = useCallback(async () => {
try {
await logAddToCart({
af_content_id: validatedProduct.id,
af_content_type: validatedProduct.category,
af_price: validatedProduct.price,
af_currency: validatedProduct.currency,
af_quantity: 1,
timestamp: Date.now(),
});
Alert.alert('✅ Éxito', 'Producto añadido al carrito');
} catch (error) {
Alert.alert('❌ Error', 'No se pudo añadir al carrito');
}
}, [validatedProduct, logAddToCart]);
const handleBuyNow = useCallback(async () => {
try {
await logPurchase({
af_revenue: validatedProduct.price,
af_currency: validatedProduct.currency,
af_quantity: 1,
af_content_id: validatedProduct.id,
af_content_type: validatedProduct.category,
af_order_id: `ORDER_${Date.now()}`,
timestamp: Date.now(),
});
onPurchase?.(validatedProduct);
Alert.alert('✅ Compra Exitosa', 'Gracias por tu compra');
} catch (error) {
Alert.alert('❌ Error', 'No se pudo procesar la compra');
}
}, [validatedProduct, logPurchase, onPurchase]);
// Log view cuando el componente se monta
React.useEffect(() => {
handleView();
}, [handleView]);
return (
<View style={styles.container}>
<TouchableOpacity onPress={handleView} activeOpacity={0.9}>
<Image
source={{ uri: validatedProduct.imageUrl }}
style={styles.image}
resizeMode="cover"
/>
</TouchableOpacity>
<View style={styles.content}>
<Text style={styles.name} numberOfLines={2}>
{validatedProduct.name}
</Text>
<Text style={styles.category}>
{validatedProduct.category}
</Text>
<View style={styles.priceRow}>
<Text style={styles.currency}>{validatedProduct.currency}</Text>
<Text style={styles.price}>{validatedProduct.price.toFixed(2)}</Text>
</View>
<Text style={styles.stock}>
{validatedProduct.stock > 0
? `${validatedProduct.stock} en stock`
: 'Agotado'}
</Text>
<View style={styles.actions}>
<TouchableOpacity
style={[styles.button, styles.cartButton]}
onPress={handleAddToCart}
disabled={validatedProduct.stock === 0}
>
<Text style={styles.buttonText}>🛒 Añadir</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.buyButton]}
onPress={handleBuyNow}
disabled={validatedProduct.stock === 0}
>
<Text style={[styles.buttonText, styles.buyText]}>Comprar</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
});
const styles = StyleSheet.create({
container: {
backgroundColor: 'white',
borderRadius: 12,
marginHorizontal: 16,
marginVertical: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
image: {
width: '100%',
height: 200,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
},
content: {
padding: 16,
},
name: {
fontSize: 18,
fontWeight: '600',
color: '#333',
marginBottom: 4,
},
category: {
fontSize: 14,
color: '#666',
marginBottom: 8,
},
priceRow: {
flexDirection: 'row',
alignItems: 'baseline',
marginBottom: 8,
},
currency: {
fontSize: 14,
color: '#666',
marginRight: 4,
},
price: {
fontSize: 24,
fontWeight: 'bold',
color: '#00C853',
},
stock: {
fontSize: 12,
color: '#999',
marginBottom: 16,
},
actions: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 12,
},
button: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
cartButton: {
backgroundColor: '#F5F5F5',
},
buyButton: {
backgroundColor: '#00C853',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
buyText: {
color: 'white',
},
});
ProductCard.displayName = 'ProductCard';
📝 Componente de Registro
// components/RegistrationForm.tsx
import React, { useState, useCallback } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { z } from 'zod';
import { useAppsFlyerEvents } from '../hooks/useAppsFlyerEvents';
// Schema para el formulario
const RegistrationFormSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(8, 'Mínimo 8 caracteres'),
confirmPassword: z.string(),
method: z.enum(['email', 'facebook', 'google', 'apple', 'phone']),
}).refine((data) => data.password === data.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
});
type RegistrationFormData = z.infer<typeof RegistrationFormSchema>;
interface RegistrationFormProps {
onSuccess?: (email: string) => void;
}
export const RegistrationForm: React.FC<RegistrationFormProps> = ({ onSuccess }) => {
const { logRegistration } = useAppsFlyerEvents();
const [formData, setFormData] = useState<Partial<RegistrationFormData>>({
method: 'email',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const validateField = useCallback((field: keyof RegistrationFormData, value: any) => {
try {
const partialSchema = RegistrationFormSchema.pick({ [field]: true });
partialSchema.parse({ [field]: value });
setErrors(prev => ({ ...prev, [field]: '' }));
return true;
} catch (error) {
if (error instanceof z.ZodError) {
setErrors(prev => ({
...prev,
[field]: error.errors[0]?.message || 'Error de validación'
}));
}
return false;
}
}, []);
const handleSubmit = useCallback(async () => {
setIsLoading(true);
try {
// Validar todo el formulario
const validatedData = RegistrationFormSchema.parse(formData);
// Simular registro en backend
await new Promise(resolve => setTimeout(resolve, 1500));
// Log evento de registro exitoso
await logRegistration({
af_registration_method: validatedData.method,
af_registration_success: true,
user_email: validatedData.email,
timestamp: Date.now(),
});
Alert.alert('✅ Registro Exitoso', 'Tu cuenta ha sido creada');
onSuccess?.(validatedData.email);
} catch (error) {
if (error instanceof z.ZodError) {
const fieldErrors: Record<string, string> = {};
error.errors.forEach(err => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
// Log evento de registro fallido
await logRegistration({
af_registration_method: formData.method as any,
af_registration_success: false,
timestamp: Date.now(),
});
}
Alert.alert('❌ Error', 'Por favor revisa los campos');
} finally {
setIsLoading(false);
}
}, [formData, logRegistration, onSuccess]);
const handleSocialLogin = useCallback(async (method: 'facebook' | 'google' | 'apple') => {
setIsLoading(true);
try {
// Simular login social
await new Promise(resolve => setTimeout(resolve, 1000));
await logRegistration({
af_registration_method: method,
af_registration_success: true,
timestamp: Date.now(),
});
Alert.alert('✅ Éxito', `Registrado con ${method}`);
onSuccess?.(`user@${method}.com`);
} catch (error) {
Alert.alert('❌ Error', 'No se pudo completar el registro');
} finally {
setIsLoading(false);
}
}, [logRegistration, onSuccess]);
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<Text style={styles.title}>Crear Cuenta</Text>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
placeholder="[email protected]"
value={formData.email}
onChangeText={(text) => {
setFormData(prev => ({ ...prev, email: text }));
validateField('email', text);
}}
keyboardType="email-address"
autoCapitalize="none"
editable={!isLoading}
/>
{errors.email && <Text style={styles.error}>{errors.email}</Text>}
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Contraseña</Text>
<TextInput
style={[styles.input, errors.password && styles.inputError]}
placeholder="Mínimo 8 caracteres"
value={formData.password}
onChangeText={(text) => {
setFormData(prev => ({ ...prev, password: text }));
validateField('password', text);
}}
secureTextEntry
editable={!isLoading}
/>
{errors.password && <Text style={styles.error}>{errors.password}</Text>}
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Confirmar Contraseña</Text>
<TextInput
style={[styles.input, errors.confirmPassword && styles.inputError]}
placeholder="Repite tu contraseña"
value={formData.confirmPassword}
onChangeText={(text) => {
setFormData(prev => ({ ...prev, confirmPassword: text }));
}}
secureTextEntry
editable={!isLoading}
/>
{errors.confirmPassword && (
<Text style={styles.error}>{errors.confirmPassword}</Text>
)}
</View>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={handleSubmit}
disabled={isLoading}
>
<Text style={styles.primaryButtonText}>
{isLoading ? 'Registrando...' : 'Registrarse'}
</Text>
</TouchableOpacity>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>O registrarse con</Text>
<View style={styles.dividerLine} />
</View>
<View style={styles.socialButtons}>
<TouchableOpacity
style={[styles.socialButton, styles.facebookButton]}
onPress={() => handleSocialLogin('facebook')}
disabled={isLoading}
>
<Text style={styles.socialButtonText}>Facebook</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.socialButton, styles.googleButton]}
onPress={() => handleSocialLogin('google')}
disabled={isLoading}
>
<Text style={styles.socialButtonText}>Google</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.socialButton, styles.appleButton]}
onPress={() => handleSocialLogin('apple')}
disabled={isLoading}
>
<Text style={[styles.socialButtonText, styles.appleText]}>Apple</Text>
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F5F5F5',
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 30,
color: '#333',
},
form: {
backgroundColor: 'white',
borderRadius: 12,
padding: 20,
},
inputGroup: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#666',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#DDD',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#FAFAFA',
},
inputError: {
borderColor: '#FF5252',
},
error: {
color: '#FF5252',
fontSize: 12,
marginTop: 4,
},
button: {
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
primaryButton: {
backgroundColor: '#00C853',
marginTop: 10,
},
primaryButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 20,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#DDD',
},
dividerText: {
marginHorizontal: 10,
color: '#999',
fontSize: 14,
},
socialButtons: {
gap: 10,
},
socialButton: {
padding: 14,
borderRadius: 8,
alignItems: 'center',
borderWidth: 1,
borderColor: '#DDD',
},
facebookButton: {
backgroundColor: '#1877F2',
borderColor: '#1877F2',
},
googleButton: {
backgroundColor: '#DB4437',
borderColor: '#DB4437',
},
appleButton: {
backgroundColor: '#000',
borderColor: '#000',
},
socialButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
appleText: {
color: 'white',
},
});
🎮 Hook para Gaming Events
// hooks/useGamingEvents.ts
import { useCallback, useRef } from 'react';
import { z } from 'zod';
import { useAppsFlyerEvents } from './useAppsFlyerEvents';
// Schemas para eventos de gaming
const SessionSchema = z.object({
session_id: z.string(),
start_time: z.number(),
end_time: z.number().optional(),
levels_played: z.number().int().nonnegative(),
total_score: z.number().int().nonnegative(),
});
const InAppPurchaseSchema = z.object({
item_id: z.string(),
item_name: z.string(),
item_type: z.enum(['consumable', 'non_consumable', 'subscription']),
price: z.number().positive(),
currency: z.string().length(3),
quantity: z.number().int().positive().default(1),
});
type Session = z.infer<typeof SessionSchema>;
type InAppPurchase = z.infer<typeof InAppPurchaseSchema>;
interface UseGamingEventsReturn {
startSession: () => string; // returns session_id
endSession: (sessionId: string) => Promise<void>;
logLevelStart: (level: number) => Promise<void>;
logLevelComplete: (level: number, score: number, stars: number) => Promise<void>;
logLevelFail: (level: number, reason: string) => Promise<void>;
logInAppPurchase: (purchase: InAppPurchase) => Promise<void>;
logAchievement: (achievementId: string, achievementName: string) => Promise<void>;
getCurrentSession: () => Session | null;
}
export const useGamingEvents = (): UseGamingEventsReturn => {
const { logCustomEvent, logPurchase, logLevelAchievement } = useAppsFlyerEvents();
const currentSession = useRef<Session | null>(null);
const startSession = useCallback((): string => {
const sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
currentSession.current = {
session_id: sessionId,
start_time: Date.now(),
levels_played: 0,
total_score: 0,
};
logCustomEvent('session_start', {
event_category: 'gaming',
event_action: 'session_start',
event_label: sessionId,
timestamp: Date.now(),
});
return sessionId;
}, [logCustomEvent]);
const endSession = useCallback(async (sessionId: string) => {
if (currentSession.current?.session_id === sessionId) {
const session = currentSession.current;
session.end_time = Date.now();
const duration = (session.end_time - session.start_time) / 1000; // en segundos
await logCustomEvent('session_end', {
event_category: 'gaming',
event_action: 'session_end',
event_label: sessionId,
custom_parameters: {
duration,
levels_played: session.levels_played,
total_score: session.total_score,
},
timestamp: Date.now(),
});
currentSession.current = null;
}
}, [logCustomEvent]);
const logLevelStart = useCallback(async (level: number) => {
await logCustomEvent('level_start', {
event_category: 'gaming',
event_action: 'level_start',
event_label: `level_${level}`,
event_value: level,
timestamp: Date.now(),
});
}, [logCustomEvent]);
const logLevelComplete = useCallback(async (
level: number,
score: number,
stars: number
) => {
if (currentSession.current) {
currentSession.current.levels_played++;
currentSession.current.total_score += score;
}
await logLevelAchievement({
af_level: level,
af_score: score,
af_success: true,
time_spent: 0, // Calcular si tienes el tiempo
timestamp: Date.now(),
});
await logCustomEvent('level_complete', {
event_category: 'gaming',
event_action: 'level_complete',
event_label: `level_${level}`,
custom_parameters: {
score,
stars,
perfect: stars === 3,
},
timestamp: Date.now(),
});
}, [logLevelAchievement, logCustomEvent]);
const logLevelFail = useCallback(async (level: number, reason: string) => {
await logCustomEvent('level_fail', {
event_category: 'gaming',
event_action: 'level_fail',
event_label: `level_${level}`,
custom_parameters: {
reason,
attempts: 1, // Podrías trackear intentos
},
timestamp: Date.now(),
});
}, [logCustomEvent]);
const logInAppPurchase = useCallback(async (purchase: InAppPurchase) => {
const validatedPurchase = InAppPurchaseSchema.parse(purchase);
await logPurchase({
af_revenue: validatedPurchase.price * validatedPurchase.quantity,
af_currency: validatedPurchase.currency,
af_quantity: validatedPurchase.quantity,
af_content_id: validatedPurchase.item_id,
af_content_type: validatedPurchase.item_type,
timestamp: Date.now(),
});
}, [logPurchase]);
const logAchievement = useCallback(async (
achievementId: string,
achievementName: string
) => {
await logCustomEvent('achievement_unlocked', {
event_category: 'gaming',
event_action: 'achievement_unlocked',
event_label: achievementId,
custom_parameters: {
achievement_name: achievementName,
timestamp: Date.now(),
},
timestamp: Date.now(),
});
}, [logCustomEvent]);
const getCurrentSession = useCallback(() => currentSession.current, []);
return {
startSession,
endSession,
logLevelStart,
logLevelComplete,
logLevelFail,
logInAppPurchase,
logAchievement,
getCurrentSession,
};
};
📋 Checklist de Implementación
- Schemas de Zod definidos para todos los eventos
- Hooks personalizados implementados
- Validación estricta en todos los eventos
- Manejo de errores con try-catch
- Componentes funcionales con TypeScript
- Props validadas con Zod
- Eventos de revenue configurados correctamente
- Testing de validación implementado
- Logs de debug solo en desarrollo
- Documentación de tipos completa
🎯 Ejercicios Prácticos
Ejercicio 1: Crear Schema Personalizado
// Define un schema para un evento de suscripción
const SubscriptionEventSchema = z.object({
plan_id: z.string(),
plan_name: z.string(),
plan_type: z.enum(['monthly', 'yearly', 'lifetime']),
price: z.number().positive(),
currency: z.string().length(3),
trial_days: z.number().int().nonnegative().optional(),
auto_renew: z.boolean(),
});
// Implementa el hook para usar este schema
Ejercicio 2: Componente con Validación Completa
Crea un componente de búsqueda que:
- Valide el input con Zod
- Debounce las búsquedas
- Envíe eventos de búsqueda
- Muestre errores de validación
Ejercicio 3: Testing con Zod
// Implementa tests para validación
import { PurchaseEventSchema } from '../schemas/events.schema';
describe('PurchaseEvent Validation', () => {
it('should validate correct purchase data', () => {
const validData = {
af_revenue: 99.99,
af_currency: 'USD',
af_quantity: 1,
af_content_id: 'PROD123',
};
expect(() => PurchaseEventSchema.parse(validData)).not.toThrow();
});
it('should reject negative revenue', () => {
const invalidData = {
af_revenue: -10,
af_currency: 'USD',
af_quantity: 1,
af_content_id: 'PROD123',
};
expect(() => PurchaseEventSchema.parse(invalidData)).toThrow();
});
});
📚 Resumen
En este capítulo aprendiste:
- ✅ Definir schemas estrictos con Zod para eventos
- ✅ Crear hooks personalizados para manejo de eventos
- ✅ Implementar componentes funcionales con TypeScript
- ✅ Validar props y datos antes de enviar eventos
- ✅ Manejar errores de forma consistente
- ✅ Crear eventos especializados para diferentes industrias
🚀 Próximo Capítulo
En el siguiente capítulo exploraremos Deep Linking y Deferred Deep Linking, incluyendo:
- Configuración de OneLink
- Universal Links y App Links
- Manejo de deep links con validación
- Testing de deep links
← Capítulo 3: Tracking de Instalaciones | Siguiente: Deep Linking →