← Volver al listado de tecnologías

Capítulo 4: Eventos In-App con TypeScript y Zod

Por: Artiko
appsflyerreact-nativeeventostypescriptzodhooks

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:

📦 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

🎯 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:

  1. Valide el input con Zod
  2. Debounce las búsquedas
  3. Envíe eventos de búsqueda
  4. 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:

🚀 Próximo Capítulo

En el siguiente capítulo exploraremos Deep Linking y Deferred Deep Linking, incluyendo:


← Capítulo 3: Tracking de Instalaciones | Siguiente: Deep Linking →