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
- Use Secret Keys - Mobile apps can use
sk_keys since the code is compiled - Secure Storage - Always use
expo-secure-storeor equivalent for tokens - Certificate Pinning - Consider implementing for production apps