Skip to content

React Native Examples

Complete React Native examples for building mobile ordering apps with KitchenClick.

Project Setup

Install Dependencies

bash
npm install @stripe/stripe-react-native

Configure Stripe

javascript
// App.js
import { StripeProvider } from '@stripe/stripe-react-native';

export default function App() {
  return (
    <StripeProvider publishableKey="pk_live_xxxxx">
      <NavigationContainer>
        <AppNavigator />
      </NavigationContainer>
    </StripeProvider>
  );
}

API Hook

useKitchenClick Hook

javascript
// hooks/useKitchenClick.js
import { useState, useCallback } from 'react';

const BASE_URL = 'https://api.kitchenclick.retailsuccessplatform.com/api/v1/ecommerce';
const IDENTITY_URL = 'https://identity.retailsuccessplatform.com';

let cachedToken = null;
let tokenExpiry = 0;

async function getToken() {
  if (cachedToken && Date.now() < tokenExpiry - 300000) {
    return cachedToken;
  }

  const response = await fetch(`${IDENTITY_URL}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      scope: 'kitchenclick:*',
    }).toString(),
  });

  const data = await response.json();
  cachedToken = data.access_token;
  tokenExpiry = Date.now() + (data.expires_in * 1000);

  return cachedToken;
}

export function useKitchenClick() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const request = useCallback(async (path, options = {}) => {
    setLoading(true);
    setError(null);

    try {
      const headers = { ...options.headers };

      if (options.authenticated !== false) {
        const token = await getToken();
        headers['Authorization'] = `Bearer ${token}`;
      }

      if (options.body && typeof options.body === 'object') {
        headers['Content-Type'] = 'application/json';
        options.body = JSON.stringify(options.body);
      }

      const response = await fetch(`${BASE_URL}${path}`, {
        ...options,
        headers,
      });

      const data = await response.json();

      if (data.status === 'error') {
        throw new Error(data.message);
      }

      return data;
    } catch (err) {
      setError(err.message);
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);

  return { request, loading, error };
}

javascript
// screens/MenuScreen.js
import React, { useState, useEffect } from 'react';
import {
  View,
  Text,
  FlatList,
  TouchableOpacity,
  Image,
  StyleSheet,
  ActivityIndicator,
} from 'react-native';
import { useKitchenClick } from '../hooks/useKitchenClick';
import { useCart } from '../context/CartContext';

export default function MenuScreen({ route, navigation }) {
  const { locationId, conceptId } = route.params;
  const { request, loading } = useKitchenClick();
  const { addItem } = useCart();

  const [categories, setCategories] = useState([]);
  const [selectedCategory, setSelectedCategory] = useState(null);
  const [items, setItems] = useState([]);

  useEffect(() => {
    loadMenu();
  }, [locationId, conceptId]);

  async function loadMenu() {
    try {
      // Get menus
      const menusResponse = await request(
        `/locations/${locationId}/concepts/${conceptId}/menus`,
        { authenticated: false }
      );

      const activeMenu = menusResponse.data.find(m => m.status === 'live');
      if (!activeMenu) return;

      // Get items
      const itemsResponse = await request(
        `/locations/${locationId}/concepts/${conceptId}/menus/${activeMenu.hashkey}/items`,
        { authenticated: false }
      );

      setCategories(itemsResponse.data.hierarchy);

      if (itemsResponse.data.hierarchy.length > 0) {
        setSelectedCategory(itemsResponse.data.hierarchy[0]);
        setItems(getItemsFromCategory(itemsResponse.data.hierarchy[0]));
      }
    } catch (err) {
      console.error('Failed to load menu:', err);
    }
  }

  function getItemsFromCategory(category) {
    const items = [];

    function traverse(nodes) {
      for (const node of nodes) {
        if (node.type === 'item') {
          items.push(node);
        }
        if (node.children) {
          traverse(node.children);
        }
      }
    }

    traverse(category.children || [category]);
    return items;
  }

  function selectCategory(category) {
    setSelectedCategory(category);
    setItems(getItemsFromCategory(category));
  }

  function handleItemPress(item) {
    if (item.option_groups?.length > 0) {
      navigation.navigate('ItemCustomization', {
        locationId,
        conceptId,
        itemId: item.hashkey,
      });
    } else {
      addItem(item);
    }
  }

  if (loading && categories.length === 0) {
    return (
      <View style={styles.loading}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      {/* Category tabs */}
      <FlatList
        horizontal
        data={categories}
        keyExtractor={(item) => item.hashkey}
        style={styles.categoryList}
        showsHorizontalScrollIndicator={false}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={[
              styles.categoryTab,
              selectedCategory?.hashkey === item.hashkey && styles.categoryTabActive,
            ]}
            onPress={() => selectCategory(item)}
          >
            <Text
              style={[
                styles.categoryText,
                selectedCategory?.hashkey === item.hashkey && styles.categoryTextActive,
              ]}
            >
              {item.name}
            </Text>
          </TouchableOpacity>
        )}
      />

      {/* Items grid */}
      <FlatList
        data={items}
        keyExtractor={(item) => item.hashkey}
        numColumns={2}
        contentContainerStyle={styles.itemsGrid}
        renderItem={({ item }) => (
          <TouchableOpacity
            style={styles.itemCard}
            onPress={() => handleItemPress(item)}
          >
            {item.hero_url && (
              <Image source={{ uri: item.hero_url }} style={styles.itemImage} />
            )}
            <View style={styles.itemInfo}>
              <Text style={styles.itemName}>{item.name}</Text>
              <Text style={styles.itemPrice}>${item.base_price.toFixed(2)}</Text>
              {item.is_86d && (
                <Text style={styles.unavailable}>Unavailable</Text>
              )}
            </View>
          </TouchableOpacity>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#fff' },
  loading: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  categoryList: { maxHeight: 50, borderBottomWidth: 1, borderBottomColor: '#eee' },
  categoryTab: { paddingHorizontal: 20, paddingVertical: 12 },
  categoryTabActive: { borderBottomWidth: 2, borderBottomColor: '#007AFF' },
  categoryText: { fontSize: 16, color: '#666' },
  categoryTextActive: { color: '#007AFF', fontWeight: '600' },
  itemsGrid: { padding: 8 },
  itemCard: {
    flex: 1,
    margin: 8,
    backgroundColor: '#fff',
    borderRadius: 12,
    shadowColor: '#000',
    shadowOpacity: 0.1,
    shadowRadius: 8,
    elevation: 3,
    overflow: 'hidden',
  },
  itemImage: { width: '100%', height: 120 },
  itemInfo: { padding: 12 },
  itemName: { fontSize: 16, fontWeight: '600' },
  itemPrice: { fontSize: 14, color: '#666', marginTop: 4 },
  unavailable: { color: '#FF3B30', fontSize: 12, marginTop: 4 },
});

Cart Context

javascript
// context/CartContext.js
import React, { createContext, useContext, useState } from 'react';

const CartContext = createContext();

export function CartProvider({ children }) {
  const [items, setItems] = useState([]);

  function addItem(item, quantity = 1, modifiers = [], notes = '') {
    setItems(current => {
      const existingIndex = current.findIndex(
        i => i.itemId === item.hashkey &&
             JSON.stringify(i.modifiers) === JSON.stringify(modifiers)
      );

      if (existingIndex >= 0) {
        const updated = [...current];
        updated[existingIndex].quantity += quantity;
        return updated;
      }

      return [...current, {
        itemId: item.hashkey,
        name: item.name,
        basePrice: item.base_price,
        heroUrl: item.hero_url,
        quantity,
        modifiers,
        notes,
      }];
    });
  }

  function updateQuantity(index, quantity) {
    setItems(current => {
      if (quantity <= 0) {
        return current.filter((_, i) => i !== index);
      }
      const updated = [...current];
      updated[index].quantity = quantity;
      return updated;
    });
  }

  function removeItem(index) {
    setItems(current => current.filter((_, i) => i !== index));
  }

  function clear() {
    setItems([]);
  }

  const subtotal = items.reduce((total, item) => {
    const modifierTotal = item.modifiers.reduce(
      (sum, m) => sum + (m.priceAdjustment || 0),
      0
    );
    return total + (item.basePrice + modifierTotal) * item.quantity;
  }, 0);

  const itemCount = items.reduce((count, item) => count + item.quantity, 0);

  return (
    <CartContext.Provider value={{
      items,
      addItem,
      updateQuantity,
      removeItem,
      clear,
      subtotal,
      itemCount,
    }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart() {
  return useContext(CartContext);
}

Checkout with Stripe

javascript
// screens/CheckoutScreen.js
import React, { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  Alert,
  ActivityIndicator,
} from 'react-native';
import { useStripe } from '@stripe/stripe-react-native';
import { useKitchenClick } from '../hooks/useKitchenClick';
import { useCart } from '../context/CartContext';

export default function CheckoutScreen({ route, navigation }) {
  const { locationId, orderType } = route.params;
  const { confirmPayment } = useStripe();
  const { request, loading } = useKitchenClick();
  const { items, subtotal, clear } = useCart();

  const [customer, setCustomer] = useState({ name: '', phone: '', email: '' });
  const [processing, setProcessing] = useState(false);

  async function handleCheckout() {
    if (!customer.name || !customer.phone) {
      Alert.alert('Error', 'Please enter your name and phone number');
      return;
    }

    setProcessing(true);

    try {
      // 1. Calculate the total and save a quote
      const calcResponse = await request('/orders/calculate', {
        method: 'POST',
        body: {
          location_id: locationId,
          order_type: orderType,
          items: items.map(item => ({
            item_id: item.itemId,
            quantity: item.quantity,
            modifiers: item.modifiers.map(m => ({
              modifier_id: m.hashkey,
              quantity: 1,
            })),
          })),
          save_quote: true,
        },
      });

      const quoteTokenId = calcResponse.data.quote.quote_token_id;

      // 2. Create a payment intent from the quote
      const intentResponse = await request('/payment/stripe/create-intent', {
        method: 'POST',
        body: {
          quote_token_id: quoteTokenId,
          payment_plan: 'full',
        },
      });

      // 3. Confirm payment with Stripe
      const { error, paymentIntent } = await confirmPayment(
        intentResponse.data.client_secret,
        { paymentMethodType: 'Card' }
      );

      if (error) {
        Alert.alert('Payment Failed', error.message);
        return;
      }

      // 4. Create the order from the quote + confirmed payment
      const orderResponse = await request('/orders/stripe', {
        method: 'POST',
        body: {
          quote_token_id: quoteTokenId,
          customer: {
            name: customer.name,
            phone: customer.phone,
            email: customer.email || undefined,
          },
          payment: {
            method: 'stripe',
            payment_intent_id: paymentIntent.id,
          },
        },
      });

      // Success!
      clear();
      navigation.replace('OrderConfirmation', {
        order: orderResponse.data,
      });

    } catch (err) {
      Alert.alert('Error', err.message);
    } finally {
      setProcessing(false);
    }
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Contact Information</Text>

      <TextInput
        style={styles.input}
        placeholder="Name"
        value={customer.name}
        onChangeText={(text) => setCustomer({ ...customer, name: text })}
      />

      <TextInput
        style={styles.input}
        placeholder="Phone"
        keyboardType="phone-pad"
        value={customer.phone}
        onChangeText={(text) => setCustomer({ ...customer, phone: text })}
      />

      <TextInput
        style={styles.input}
        placeholder="Email (optional)"
        keyboardType="email-address"
        value={customer.email}
        onChangeText={(text) => setCustomer({ ...customer, email: text })}
      />

      <View style={styles.summary}>
        <Text style={styles.summaryLabel}>Subtotal</Text>
        <Text style={styles.summaryValue}>${subtotal.toFixed(2)}</Text>
      </View>

      <TouchableOpacity
        style={[styles.checkoutButton, processing && styles.checkoutButtonDisabled]}
        onPress={handleCheckout}
        disabled={processing}
      >
        {processing ? (
          <ActivityIndicator color="#fff" />
        ) : (
          <Text style={styles.checkoutButtonText}>Pay Now</Text>
        )}
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: '#fff' },
  title: { fontSize: 20, fontWeight: '600', marginBottom: 20 },
  input: {
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    padding: 12,
    marginBottom: 12,
    fontSize: 16,
  },
  summary: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingVertical: 20,
    borderTopWidth: 1,
    borderTopColor: '#eee',
    marginTop: 20,
  },
  summaryLabel: { fontSize: 18, color: '#666' },
  summaryValue: { fontSize: 18, fontWeight: '600' },
  checkoutButton: {
    backgroundColor: '#007AFF',
    padding: 16,
    borderRadius: 8,
    alignItems: 'center',
    marginTop: 20,
  },
  checkoutButtonDisabled: { opacity: 0.6 },
  checkoutButtonText: { color: '#fff', fontSize: 18, fontWeight: '600' },
});

Order Tracking

javascript
// screens/OrderTrackingScreen.js
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useKitchenClick } from '../hooks/useKitchenClick';

const STATUS_STEPS = ['confirmed', 'preparing', 'ready'];

export default function OrderTrackingScreen({ route }) {
  const { orderId } = route.params;
  const { request } = useKitchenClick();
  const [order, setOrder] = useState(null);

  useEffect(() => {
    const interval = setInterval(fetchStatus, 10000);
    fetchStatus();

    return () => clearInterval(interval);
  }, [orderId]);

  async function fetchStatus() {
    try {
      const response = await request(`/orders/${orderId}/track`, {
        authenticated: false,
      });
      setOrder(response.data);
    } catch (err) {
      console.error('Tracking error:', err);
    }
  }

  if (!order) return null;

  const currentStep = STATUS_STEPS.indexOf(order.status);

  return (
    <View style={styles.container}>
      <Text style={styles.orderNumber}>Order #{order.order_number}</Text>

      <View style={styles.progress}>
        {STATUS_STEPS.map((step, index) => (
          <View key={step} style={styles.step}>
            <View style={[
              styles.stepDot,
              index <= currentStep && styles.stepDotActive,
            ]} />
            <Text style={[
              styles.stepLabel,
              index <= currentStep && styles.stepLabelActive,
            ]}>
              {step.charAt(0).toUpperCase() + step.slice(1)}
            </Text>
          </View>
        ))}
      </View>

      {order.estimated_ready_time && (
        <Text style={styles.eta}>
          Estimated ready: {new Date(order.estimated_ready_time).toLocaleTimeString()}
        </Text>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, backgroundColor: '#fff' },
  orderNumber: { fontSize: 24, fontWeight: '600', textAlign: 'center' },
  progress: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginTop: 40,
    paddingHorizontal: 20,
  },
  step: { alignItems: 'center' },
  stepDot: {
    width: 24,
    height: 24,
    borderRadius: 12,
    backgroundColor: '#ddd',
    marginBottom: 8,
  },
  stepDotActive: { backgroundColor: '#007AFF' },
  stepLabel: { fontSize: 14, color: '#999' },
  stepLabelActive: { color: '#007AFF', fontWeight: '600' },
  eta: { textAlign: 'center', fontSize: 16, color: '#666', marginTop: 40 },
});

Changelog
DateChange
2026-01-15Initial publication.

ShopHero CommerceCore Platform