React Native Integration

Build a mobile storefront with React Native or Expo using the Skakio Catalog API.

Setup

1. Create the API Client

Create lib/skakio.ts:

import * as SecureStore from 'expo-secure-store';

const API_BASE = 'https://api.skakio.com';
const API_KEY = 'sk_live_your_key_here'; // Use secret key in mobile apps

interface TokenData {
  token: string;
  token_type: string;
  expires_in: number;
}

class SkakioClient {
  private token: string | null = null;
  private tokenExpiresAt: number = 0;

  async getToken(): Promise<string> {
    const now = Date.now();
    const buffer = 60 * 1000;

    // Check cached token
    if (this.token && now < this.tokenExpiresAt - buffer) {
      return this.token;
    }

    // Check stored token
    const storedExpiry = await SecureStore.getItemAsync('skakio_token_expiry');
    if (storedExpiry && now < parseInt(storedExpiry) - buffer) {
      const storedToken = await SecureStore.getItemAsync('skakio_token');
      if (storedToken) {
        this.token = storedToken;
        this.tokenExpiresAt = parseInt(storedExpiry);
        return this.token;
      }
    }

    // Fetch new token
    const res = await fetch(`${API_BASE}/auth/token`, {
      method: 'POST',
      headers: { 'X-API-Key': API_KEY },
    });

    if (!res.ok) throw new Error('Failed to get token');

    const { data }: { data: TokenData } = await res.json();
    this.token = data.token;
    this.tokenExpiresAt = now + data.expires_in * 1000;

    // Store for persistence
    await SecureStore.setItemAsync('skakio_token', this.token);
    await SecureStore.setItemAsync('skakio_token_expiry', String(this.tokenExpiresAt));

    return this.token;
  }

  async fetch<T>(path: string): Promise<T> {
    const token = await this.getToken();
    const res = await fetch(`${API_BASE}${path}`, {
      headers: { Authorization: `Bearer ${token}` },
    });

    if (res.status === 401) {
      // Token expired, clear cache and retry
      this.token = null;
      await SecureStore.deleteItemAsync('skakio_token');
      return this.fetch(path);
    }

    if (!res.ok) throw new Error(`API error: ${res.status}`);

    const { data } = await res.json();
    return data;
  }
}

export const skakio = new SkakioClient();

2. Create React Query Hooks

Create hooks/useSkakio.ts:

import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { skakio } from '../lib/skakio';

// Types
export interface Store {
  id: string;
  name: string;
  description: string;
  logo?: { url: string };
}

export interface Listing {
  id: string;
  name: string;
  publicationCount: number;
  publications?: Publication[];
}

export interface Publication {
  id: string;
  title: string;
  description: string;
  price: { amount: number; currency: string };
  quantity: { available: number };
  media?: { url: string }[];
}

// Hooks
export function useStore(storeId: string) {
  return useQuery({
    queryKey: ['store', storeId],
    queryFn: () => skakio.fetch<Store>(`/api/store/${storeId}`),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

export function useStoreListings(storeId: string) {
  return useQuery({
    queryKey: ['store', storeId, 'listings'],
    queryFn: () => skakio.fetch<Listing[]>(`/api/store/${storeId}/listings`),
    staleTime: 5 * 60 * 1000,
  });
}

export function useListing(listingId: string, expand?: boolean) {
  const path = expand
    ? `/api/listing/${listingId}?expand=publication`
    : `/api/listing/${listingId}`;

  return useQuery({
    queryKey: ['listing', listingId, { expand }],
    queryFn: () => skakio.fetch<Listing>(path),
    staleTime: 2 * 60 * 1000, // 2 minutes
  });
}

export function usePublication(publicationId: string) {
  return useQuery({
    queryKey: ['publication', publicationId],
    queryFn: () => skakio.fetch<Publication>(`/api/publication/${publicationId}`),
    staleTime: 2 * 60 * 1000,
  });
}

export function useSearch(query: string) {
  return useQuery({
    queryKey: ['search', query],
    queryFn: () =>
      skakio.fetch(`/api/marketplace/search?q=${encodeURIComponent(query)}`),
    enabled: query.length > 0,
    staleTime: 30 * 1000, // 30 seconds
  });
}

Screen Examples

Home Screen

Create screens/HomeScreen.tsx:

import React from 'react';
import {
  View,
  Text,
  FlatList,
  Image,
  TouchableOpacity,
  StyleSheet,
  ActivityIndicator,
} from 'react-native';
import { useStore, useStoreListings } from '../hooks/useSkakio';

const STORE_ID = 'store_abc123';

export function HomeScreen({ navigation }) {
  const { data: store, isLoading: storeLoading } = useStore(STORE_ID);
  const { data: listings, isLoading: listingsLoading } = useStoreListings(STORE_ID);

  if (storeLoading || listingsLoading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      {/* Header */}
      <View style={styles.header}>
        {store?.logo && (
          <Image source={{ uri: store.logo.url }} style={styles.logo} />
        )}
        <Text style={styles.title}>{store?.name}</Text>
        <Text style={styles.subtitle}>{store?.description}</Text>
      </View>

      {/* Categories */}
      <FlatList
        data={listings}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.categoryCard}
            onPress={() => navigation.navigate('Category', { id: item.id })}
          >
            <Text style={styles.categoryName}>{item.name}</Text>
            <Text style={styles.categoryCount}>
              {item.publicationCount} products
            </Text>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  header: { alignItems: 'center', padding: 24 },
  logo: { width: 80, height: 80, borderRadius: 40 },
  title: { fontSize: 24, fontWeight: 'bold', marginTop: 12 },
  subtitle: { fontSize: 14, color: '#666', marginTop: 4 },
  categoryCard: {
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  categoryName: { fontSize: 16, fontWeight: '600' },
  categoryCount: { fontSize: 12, color: '#999', marginTop: 4 },
});

Category Screen

Create screens/CategoryScreen.tsx:

import React from 'react';
import {
  View,
  Text,
  FlatList,
  Image,
  TouchableOpacity,
  StyleSheet,
  ActivityIndicator,
  Dimensions,
} from 'react-native';
import { useListing } from '../hooks/useSkakio';

const { width } = Dimensions.get('window');
const CARD_WIDTH = (width - 48) / 2;

export function CategoryScreen({ route, navigation }) {
  const { id } = route.params;
  const { data: listing, isLoading } = useListing(id, true);

  if (isLoading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>{listing?.name}</Text>

      <FlatList
        data={listing?.publications}
        keyExtractor={(item) => item.id}
        numColumns={2}
        contentContainerStyle={styles.grid}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.productCard}
            onPress={() => navigation.navigate('Product', { id: item.id })}
          >
            {item.media?.[0] && (
              <Image
                source={{ uri: item.media[0].url }}
                style={styles.productImage}
              />
            )}
            <Text style={styles.productTitle} numberOfLines={2}>
              {item.title}
            </Text>
            <Text style={styles.productPrice}>
              {item.price.currency} {item.price.amount.toFixed(2)}
            </Text>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  title: { fontSize: 24, fontWeight: 'bold', padding: 16 },
  grid: { padding: 16 },
  productCard: {
    width: CARD_WIDTH,
    marginRight: 16,
    marginBottom: 16,
  },
  productImage: {
    width: CARD_WIDTH,
    height: CARD_WIDTH,
    borderRadius: 8,
    backgroundColor: '#f0f0f0',
  },
  productTitle: { fontSize: 14, marginTop: 8 },
  productPrice: { fontSize: 16, fontWeight: 'bold', color: '#059669', marginTop: 4 },
});

Product Screen

Create screens/ProductScreen.tsx:

import React from 'react';
import {
  View,
  Text,
  ScrollView,
  Image,
  StyleSheet,
  ActivityIndicator,
  Dimensions,
} from 'react-native';
import { usePublication } from '../hooks/useSkakio';

const { width } = Dimensions.get('window');

export function ProductScreen({ route }) {
  const { id } = route.params;
  const { data: product, isLoading } = usePublication(id);

  if (isLoading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <ScrollView style={styles.container}>
      {/* Images */}
      <ScrollView
        horizontal
        pagingEnabled
        showsHorizontalScrollIndicator={false}
      >
        {product?.media?.map((m, i) => (
          <Image
            key={i}
            source={{ uri: m.url }}
            style={styles.image}
            resizeMode="cover"
          />
        ))}
      </ScrollView>

      {/* Info */}
      <View style={styles.info}>
        <Text style={styles.title}>{product?.title}</Text>
        <Text style={styles.price}>
          {product?.price.currency} {product?.price.amount.toFixed(2)}
        </Text>
        <Text style={styles.description}>{product?.description}</Text>

        <View style={styles.stock}>
          {product?.quantity.available > 0 ? (
            <Text style={styles.inStock}>
              In Stock ({product.quantity.available})
            </Text>
          ) : (
            <Text style={styles.outOfStock}>Out of Stock</Text>
          )}
        </View>
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  image: { width, height: width, backgroundColor: '#f0f0f0' },
  info: { padding: 16 },
  title: { fontSize: 24, fontWeight: 'bold' },
  price: { fontSize: 20, fontWeight: 'bold', color: '#059669', marginTop: 8 },
  description: { fontSize: 14, color: '#666', marginTop: 12, lineHeight: 20 },
  stock: { marginTop: 16 },
  inStock: { color: '#059669', fontWeight: '600' },
  outOfStock: { color: '#dc2626', fontWeight: '600' },
});

Search Screen

Create screens/SearchScreen.tsx:

import React, { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  FlatList,
  TouchableOpacity,
  StyleSheet,
  ActivityIndicator,
} from 'react-native';
import { useSearch } from '../hooks/useSkakio';
import { useDebounce } from '../hooks/useDebounce';

export function SearchScreen({ navigation }) {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);
  const { data, isLoading } = useSearch(debouncedQuery);

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.input}
        value={query}
        onChangeText={setQuery}
        placeholder="Search products..."
        autoFocus
      />

      {isLoading && (
        <View style={styles.center}>
          <ActivityIndicator />
        </View>
      )}

      <FlatList
        data={data?.publications}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.result}
            onPress={() => navigation.navigate('Product', { id: item.id })}
          >
            <Text style={styles.resultTitle}>{item.title}</Text>
            <Text style={styles.resultPrice}>
              {item.price.currency} {item.price.amount}
            </Text>
          </TouchableOpacity>
        )}
        ListEmptyComponent={
          debouncedQuery && !isLoading ? (
            <Text style={styles.empty}>No products found</Text>
          ) : null
        }
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  center: { padding: 20, alignItems: 'center' },
  input: {
    margin: 16,
    padding: 12,
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    fontSize: 16,
  },
  result: {
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  resultTitle: { fontSize: 16 },
  resultPrice: { fontSize: 14, color: '#059669', marginTop: 4 },
  empty: { textAlign: 'center', padding: 20, color: '#999' },
});

App Setup

Set up React Query in your app:

// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';

import { HomeScreen } from './screens/HomeScreen';
import { CategoryScreen } from './screens/CategoryScreen';
import { ProductScreen } from './screens/ProductScreen';
import { SearchScreen } from './screens/SearchScreen';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      refetchOnWindowFocus: false,
    },
  },
});

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <NavigationContainer>
        <Stack.Navigator>
          <Stack.Screen name="Home" component={HomeScreen} />
          <Stack.Screen name="Category" component={CategoryScreen} />
          <Stack.Screen name="Product" component={ProductScreen} />
          <Stack.Screen name="Search" component={SearchScreen} />
        </Stack.Navigator>
      </NavigationContainer>
    </QueryClientProvider>
  );
}

Security Notes

  1. Use Secret Keys - Mobile apps can use sk_ keys since the code is compiled
  2. Secure Storage - Always use expo-secure-store or equivalent for tokens
  3. Certificate Pinning - Consider implementing for production apps