Appearance
React Native Examples
Complete React Native examples for building mobile ordering apps with KitchenClick.
Project Setup
Install Dependencies
bash
npm install @stripe/stripe-react-nativeConfigure 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 };
}Menu Screen
Menu Browser Component
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
| Date | Change |
|---|---|
| 2026-01-15 | Initial publication. |