diff --git a/README.md b/README.md
index 15a1a35..6df90e8 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ To learn how to use an example, open its `README.md` file. You'll find the detai
| [Product Reviews](./product-reviews/README.md) | Custom Feature | Allow customers to add product reviews, and merchants to manage them. |
| [Quotes Management](./quotes-management/README.md) | Custom Feature | Allow customers to send quotes, and merchants to manage and accept them. |
| [Re-order Feature](./re-order/README.md) | Custom Feature | Allow customers to re-order a previous order. |
+| [React Native and Expo Store](./react-native-expo/README.md) | Storefront | Create a mobile app for your Medusa backend with React Native and Expo. |
| [Request Returns from Storefront](./returns-storefront/README.md) | Storefront | Let custmers request a return of their order from the storefront. |
| [Resend Integration](./resend-integration/README.md) | Integration | Integrate Resend to send notifications in Medusa. |
| [Restaurant Marketplace](./restaurant-marketplace/README.md) | Custom Feature | Build an Uber-Eats clone with Medusa. |
diff --git a/react-native-expo/.env.template b/react-native-expo/.env.template
new file mode 100644
index 0000000..8cd9456
--- /dev/null
+++ b/react-native-expo/.env.template
@@ -0,0 +1,2 @@
+EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY=
+EXPO_PUBLIC_MEDUSA_URL=
\ No newline at end of file
diff --git a/react-native-expo/.gitignore b/react-native-expo/.gitignore
new file mode 100644
index 0000000..f8c6c2e
--- /dev/null
+++ b/react-native-expo/.gitignore
@@ -0,0 +1,43 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+app-example
+
+# generated native folders
+/ios
+/android
diff --git a/react-native-expo/README.md b/react-native-expo/README.md
new file mode 100644
index 0000000..fe09ac9
--- /dev/null
+++ b/react-native-expo/README.md
@@ -0,0 +1,55 @@
+# Medusa v2 Example: React Native / Expo App
+
+This directory holds the code for the [Implement Mobile App with React Native, Expo, and Medusa](https://docs.medusajs.com/resources/storefront-development/guides/react-native-expo) guide.
+
+This codebase only includes the express checkout storefront and doesn't include the Medusa application. You can learn how to install it by following [this guide](https://docs.medusajs.com/learn/installation).
+
+## Installation
+
+1. Clone the repository and change to the `react-native-expo` directory:
+
+```bash
+git clone https://github.com/medusajs/examples.git
+cd examples/react-native-expo
+```
+
+2\. Rename the `.env.template` file to `.env` and set the following variables:
+
+```bash
+EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY=
+EXPO_PUBLIC_MEDUSA_URL=
+```
+
+Where:
+
+- `EXPO_PUBLIC_MEDUSA_URL` is the URL to your Medusa application server. If the Medusa application is running locally, it should be a local IP. For example `http://192.168.1.100:9000`.
+- `EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY` is the publishable key for your Medusa application. You can retrieve it from the Medusa Admin by going to Settings > Publishable API Keys.
+
+3\. Install dependencies:
+
+```bash
+npm install
+```
+
+4\. While the Medusa application is running, start the Expo server:
+
+```bash
+npm run start
+```
+
+You can then test the app on a simulator or with [Expo Go](https://expo.dev/go).
+
+## Testing in a Browser
+
+If you're testing the app on the web, make sure to add `localhost:8081` (default Expo server URL) to the Medusa application's `STORE_CORS` and `AUTH_CORS` environment variables:
+
+```bash
+STORE_CORS=previous_values...,http://localhost:8081
+AUTH_CORS=previous_values...,http://localhost:8081
+```
+
+## More Resources
+
+- [Medusa Documentation](https://docs.medusajs.com)
+- [React Native Documentation](https://reactnative.dev/docs/getting-started)
+- [Expo Documentation](https://docs.expo.dev/)
\ No newline at end of file
diff --git a/react-native-expo/app.json b/react-native-expo/app.json
new file mode 100644
index 0000000..c58bc49
--- /dev/null
+++ b/react-native-expo/app.json
@@ -0,0 +1,48 @@
+{
+ "expo": {
+ "name": "react-native-store",
+ "slug": "react-native-store",
+ "version": "1.0.0",
+ "orientation": "portrait",
+ "icon": "./assets/images/icon.png",
+ "scheme": "reactnativestore",
+ "userInterfaceStyle": "automatic",
+ "newArchEnabled": true,
+ "ios": {
+ "supportsTablet": true
+ },
+ "android": {
+ "adaptiveIcon": {
+ "backgroundColor": "#E6F4FE",
+ "foregroundImage": "./assets/images/android-icon-foreground.png",
+ "backgroundImage": "./assets/images/android-icon-background.png",
+ "monochromeImage": "./assets/images/android-icon-monochrome.png"
+ },
+ "edgeToEdgeEnabled": true,
+ "predictiveBackGestureEnabled": false
+ },
+ "web": {
+ "output": "static",
+ "favicon": "./assets/images/favicon.png"
+ },
+ "plugins": [
+ "expo-router",
+ [
+ "expo-splash-screen",
+ {
+ "image": "./assets/images/splash-icon.png",
+ "imageWidth": 200,
+ "resizeMode": "contain",
+ "backgroundColor": "#ffffff",
+ "dark": {
+ "backgroundColor": "#000000"
+ }
+ }
+ ]
+ ],
+ "experiments": {
+ "typedRoutes": true,
+ "reactCompiler": true
+ }
+ }
+}
diff --git a/react-native-expo/app/(drawer)/(tabs)/(cart)/_layout.tsx b/react-native-expo/app/(drawer)/(tabs)/(cart)/_layout.tsx
new file mode 100644
index 0000000..503ead3
--- /dev/null
+++ b/react-native-expo/app/(drawer)/(tabs)/(cart)/_layout.tsx
@@ -0,0 +1,37 @@
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { DrawerActions } from '@react-navigation/native';
+import { Stack, useNavigation } from 'expo-router';
+import React from 'react';
+import { TouchableOpacity } from 'react-native';
+
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+
+export default function CartStackLayout() {
+ const colorScheme = useColorScheme();
+ const navigation = useNavigation();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ return (
+
+ (
+ navigation.dispatch(DrawerActions.openDrawer())}
+ style={{ height: 36, width: 36, display: "flex", alignItems: "center", justifyContent: "center" }}
+ >
+
+
+ ),
+ }}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/react-native-expo/app/(drawer)/(tabs)/(cart)/index.tsx b/react-native-expo/app/(drawer)/(tabs)/(cart)/index.tsx
new file mode 100644
index 0000000..4d02684
--- /dev/null
+++ b/react-native-expo/app/(drawer)/(tabs)/(cart)/index.tsx
@@ -0,0 +1,158 @@
+import { CartItem } from '@/components/cart-item';
+import { Loading } from '@/components/loading';
+import { Button } from '@/components/ui/button';
+import { Colors } from '@/constants/theme';
+import { useCart } from '@/context/cart-context';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { formatPrice } from '@/lib/format-price';
+import { useRouter } from 'expo-router';
+import React from 'react';
+import { FlatList, StyleSheet, Text, View } from 'react-native';
+
+export default function CartScreen() {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const router = useRouter();
+ const { cart, updateItemQuantity, removeItem, loading } = useCart();
+
+ const isEmpty = !cart?.items || cart.items.length === 0;
+
+ if (loading && !cart) {
+ return ;
+ }
+
+ if (isEmpty) {
+ return (
+
+ Your cart is empty
+
+ Add some products to get started
+
+
+ );
+ }
+
+ return (
+
+ item.id}
+ renderItem={({ item }) => (
+ updateItemQuantity(item.id, quantity)}
+ onRemove={() => removeItem(item.id)}
+ />
+ )}
+ contentContainerStyle={styles.listContent}
+ />
+
+
+
+
+ Subtotal
+
+ {formatPrice(cart.item_subtotal, cart.currency_code)}
+
+
+ {cart.tax_total !== undefined && cart.tax_total > 0 && (
+
+ Tax
+
+ {formatPrice(cart.tax_total, cart.currency_code)}
+
+
+ )}
+ {cart.shipping_total !== undefined && cart.shipping_total > 0 && (
+
+ Shipping
+
+ {formatPrice(cart.shipping_total, cart.currency_code)}
+
+
+ )}
+
+ Total
+
+ {formatPrice(cart.total, cart.currency_code)}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 40,
+ },
+ emptyTitle: {
+ fontSize: 24,
+ fontWeight: '700',
+ marginBottom: 12,
+ },
+ emptyText: {
+ fontSize: 16,
+ textAlign: 'center',
+ marginBottom: 32,
+ },
+ browseButton: {
+ minWidth: 200,
+ },
+ listContent: {
+ paddingBottom: 20,
+ },
+ footer: {
+ padding: 16,
+ borderTopWidth: 1,
+ },
+ totals: {
+ marginBottom: 20,
+ },
+ totalRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ totalLabel: {
+ fontSize: 14,
+ },
+ totalValue: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ grandTotalRow: {
+ marginTop: 8,
+ paddingTop: 12,
+ borderTopWidth: 1,
+ },
+ grandTotalLabel: {
+ fontSize: 18,
+ fontWeight: '700',
+ },
+ grandTotalValue: {
+ fontSize: 20,
+ fontWeight: '700',
+ },
+});
\ No newline at end of file
diff --git a/react-native-expo/app/(drawer)/(tabs)/(home)/_layout.tsx b/react-native-expo/app/(drawer)/(tabs)/(home)/_layout.tsx
new file mode 100644
index 0000000..523ae89
--- /dev/null
+++ b/react-native-expo/app/(drawer)/(tabs)/(home)/_layout.tsx
@@ -0,0 +1,46 @@
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { DrawerActions } from '@react-navigation/native';
+import { Stack, useNavigation } from 'expo-router';
+import React from 'react';
+import { TouchableOpacity } from 'react-native';
+
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+
+export default function HomeStackLayout() {
+ const colorScheme = useColorScheme();
+ const navigation = useNavigation();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ return (
+
+ (
+ navigation.dispatch(DrawerActions.openDrawer())}
+ style={{ height: 36, width: 36, display: "flex", alignItems: "center", justifyContent: "center" }}
+ >
+
+
+ ),
+ }}
+ />
+
+
+ );
+}
+
diff --git a/react-native-expo/app/(drawer)/(tabs)/(home)/index.tsx b/react-native-expo/app/(drawer)/(tabs)/(home)/index.tsx
new file mode 100644
index 0000000..c4f06c7
--- /dev/null
+++ b/react-native-expo/app/(drawer)/(tabs)/(home)/index.tsx
@@ -0,0 +1,152 @@
+import { Loading } from '@/components/loading';
+import { ProductCard } from '@/components/product-card';
+import { Colors } from '@/constants/theme';
+import { useRegion } from '@/context/region-context';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { sdk } from '@/lib/sdk';
+import type { HttpTypes } from '@medusajs/types';
+import { Image } from 'expo-image';
+import React, { useCallback, useEffect, useState } from 'react';
+import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
+
+export default function HomeScreen() {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const { selectedRegion } = useRegion();
+
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchProducts = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { products: fetchedProducts } = await sdk.store.product.list({
+ region_id: selectedRegion?.id,
+ fields: '*variants.calculated_price,+variants.inventory_quantity',
+ });
+
+ setProducts(fetchedProducts);
+ } catch (err) {
+ console.error('Failed to fetch products:', err);
+ setError('Failed to load products. Please try again.');
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }, [selectedRegion]);
+
+ useEffect(() => {
+ if (selectedRegion) {
+ fetchProducts();
+ }
+ }, [selectedRegion, fetchProducts]);
+
+ const onRefresh = () => {
+ setRefreshing(true);
+ fetchProducts();
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ return (
+
+ item.id}
+ numColumns={2}
+ columnWrapperStyle={styles.row}
+ initialNumToRender={6}
+ maxToRenderPerBatch={6}
+ windowSize={5}
+ removeClippedSubviews={true}
+ ListHeaderComponent={
+
+
+
+ Latest Products
+
+
+ }
+ renderItem={({ item }) => }
+ contentContainerStyle={styles.listContent}
+ refreshControl={
+
+ }
+ ListEmptyComponent={
+
+
+ No products available
+
+
+ }
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ centerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ header: {
+ width: '100%',
+ },
+ banner: {
+ width: '100%',
+ height: 200,
+ },
+ sectionTitle: {
+ fontSize: 24,
+ fontWeight: '700',
+ marginTop: 24,
+ marginBottom: 16,
+ paddingHorizontal: 16,
+ },
+ listContent: {
+ paddingBottom: 20,
+ },
+ row: {
+ justifyContent: 'space-between',
+ paddingHorizontal: 8,
+ },
+ errorText: {
+ fontSize: 16,
+ textAlign: 'center',
+ },
+ emptyContainer: {
+ padding: 40,
+ alignItems: 'center',
+ },
+ emptyText: {
+ fontSize: 16,
+ },
+});
+
diff --git a/react-native-expo/app/(drawer)/(tabs)/(home)/product/[id].tsx b/react-native-expo/app/(drawer)/(tabs)/(home)/product/[id].tsx
new file mode 100644
index 0000000..2b56546
--- /dev/null
+++ b/react-native-expo/app/(drawer)/(tabs)/(home)/product/[id].tsx
@@ -0,0 +1,390 @@
+import { ProductImageSlider } from '@/components/product-image-slider';
+import { ProductSkeleton } from '@/components/product-skeleton';
+import { Button } from '@/components/ui/button';
+import { Toast } from '@/components/ui/toast';
+import { Colors } from '@/constants/theme';
+import { useCart } from '@/context/cart-context';
+import { useRegion } from '@/context/region-context';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { formatPrice } from '@/lib/format-price';
+import { isVariantInStock } from '@/lib/inventory';
+import { sdk } from '@/lib/sdk';
+import type { HttpTypes } from '@medusajs/types';
+import { useLocalSearchParams, useNavigation } from 'expo-router';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+export default function ProductDetailsScreen() {
+ const { id, title } = useLocalSearchParams<{ id: string; title?: string }>();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const { addToCart } = useCart();
+ const { selectedRegion } = useRegion();
+ const navigation = useNavigation();
+
+ const [product, setProduct] = useState(null);
+ const [selectedOptions, setSelectedOptions] = useState>({});
+ const [quantity, setQuantity] = useState(1);
+ const [loading, setLoading] = useState(true);
+ const [addingToCart, setAddingToCart] = useState(false);
+ const [error, setError] = useState(null);
+ const [toastVisible, setToastVisible] = useState(false);
+ const [toastMessage, setToastMessage] = useState('');
+
+ const fetchProduct = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { product: fetchedProduct } = await sdk.store.product.retrieve(id, {
+ fields: '*variants.calculated_price,+variants.inventory_quantity',
+ region_id: selectedRegion?.id,
+ });
+
+ setProduct(fetchedProduct);
+
+ // Initialize selected options with first variant's option values
+ if (fetchedProduct.variants && fetchedProduct.variants.length > 0) {
+ const firstVariant = fetchedProduct.variants[0];
+ const initialOptions: Record = {};
+ firstVariant.options?.forEach((optionValue) => {
+ if (optionValue.option_id && optionValue.value) {
+ initialOptions[optionValue.option_id] = optionValue.value;
+ }
+ });
+ setSelectedOptions(initialOptions);
+ }
+ } catch (err) {
+ console.error('Failed to fetch product:', err);
+ setError('Failed to load product. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ }, [id, selectedRegion]);
+
+ useEffect(() => {
+ if (id && selectedRegion) {
+ fetchProduct();
+ }
+ }, [id, selectedRegion, fetchProduct]);
+
+ // Update screen title immediately if passed as param, or when product is loaded
+ useEffect(() => {
+ const productTitle = title || product?.title;
+ if (productTitle) {
+ navigation.setOptions({
+ title: productTitle,
+ });
+ }
+ }, [title, product, navigation]);
+
+ // Compute selected variant based on selected options
+ const selectedVariant = useMemo(() => {
+ if (
+ !product?.variants ||
+ !product.options ||
+ Object.keys(selectedOptions).length !== product.options?.length
+ ) {
+ return;
+ }
+
+ return product.variants.find((variant) =>
+ variant.options?.every(
+ (optionValue) => optionValue.value === selectedOptions[optionValue.option_id!]
+ )
+ );
+ }, [selectedOptions, product]);
+
+ // Check if we should show options UI
+ // Hide if there's only one option with one value (or all options have only one value each)
+ const shouldShowOptions = useMemo(() => {
+ if (!product?.options || product.options.length === 0) {
+ return false;
+ }
+ // Show options only if at least one option has more than one value
+ return product.options.some((option) => (option.values?.length ?? 0) > 1);
+ }, [product]);
+
+ // Get all images from product
+ const images = useMemo(() => {
+ const productImages = product?.images?.map(img => img.url).filter(Boolean) || [];
+ // If no images, use thumbnail or fallback
+ if (productImages.length === 0 && product?.thumbnail) {
+ return [product.thumbnail];
+ }
+ return productImages.length > 0 ? productImages : [];
+ }, [product]);
+
+ const handleAddToCart = async () => {
+ if (!selectedVariant) {
+ setToastMessage(shouldShowOptions ? 'Please select all options' : 'Variant not available');
+ setToastVisible(true);
+ return;
+ }
+
+ try {
+ setAddingToCart(true);
+ await addToCart(selectedVariant.id, quantity);
+ setToastMessage('Product added to cart!');
+ setToastVisible(true);
+ } catch {
+ setToastMessage('Failed to add product to cart');
+ setToastVisible(true);
+ } finally {
+ setAddingToCart(false);
+ }
+ };
+
+ if (loading) {
+ return ;
+ }
+
+ if (error || !product) {
+ return (
+
+
+ {error || 'Product not found'}
+
+
+ );
+ }
+
+ // Get price from calculated_price.calculated_amount
+ const priceAmount = selectedVariant?.calculated_price?.calculated_amount || 0;
+
+ // Use selected region's currency code
+ const currencyCode = selectedRegion?.currency_code;
+
+ // Check if selected variant is in stock
+ const isInStock = isVariantInStock(selectedVariant);
+
+ return (
+
+
+
+
+ {product.title}
+
+ {product.description && (
+
+ {product.description}
+
+ )}
+
+
+
+ {formatPrice(priceAmount, currencyCode)}
+
+ {!isInStock && (
+
+ Out of Stock
+
+ )}
+ {isInStock && selectedVariant?.inventory_quantity !== undefined &&
+ selectedVariant.inventory_quantity! <= 10 &&
+ selectedVariant.manage_inventory !== false && (
+
+
+ Only {selectedVariant.inventory_quantity} left
+
+
+ )}
+
+
+ {shouldShowOptions && (
+
+ {product.options?.map((option) => (
+
+
+ {option.title}
+
+
+ {option.values?.map((optionValue) => {
+ const isSelected = selectedOptions[option.id!] === optionValue.value;
+ return (
+ {
+ setSelectedOptions((prev) => ({
+ ...prev,
+ [option.id!]: optionValue.value!,
+ }));
+ }}
+ >
+
+ {optionValue.value}
+
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+
+ Quantity
+
+ setQuantity(Math.max(1, quantity - 1))}
+ >
+ -
+
+ {quantity}
+ setQuantity(quantity + 1)}
+ >
+ +
+
+
+
+
+
+
+
+ setToastVisible(false)}
+ type="success"
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ centerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ content: {
+ padding: 20,
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '700',
+ marginBottom: 12,
+ },
+ description: {
+ fontSize: 16,
+ lineHeight: 24,
+ marginBottom: 20,
+ },
+ priceContainer: {
+ marginBottom: 24,
+ },
+ price: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 8,
+ },
+ stockBadge: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 6,
+ alignSelf: 'flex-start',
+ },
+ outOfStockText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '600',
+ textTransform: 'uppercase',
+ },
+ lowStockText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '600',
+ },
+ optionsSection: {
+ marginBottom: 24,
+ },
+ optionGroup: {
+ marginBottom: 20,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ marginBottom: 12,
+ },
+ optionValues: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ },
+ optionButton: {
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 8,
+ borderWidth: 1,
+ },
+ optionText: {
+ fontSize: 14,
+ },
+ quantitySection: {
+ marginBottom: 32,
+ },
+ quantityControls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ quantityButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ borderWidth: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ quantityButtonText: {
+ fontSize: 18,
+ fontWeight: '600',
+ },
+ quantity: {
+ marginHorizontal: 20,
+ fontSize: 18,
+ fontWeight: '600',
+ minWidth: 30,
+ textAlign: 'center',
+ },
+ addButton: {
+ marginTop: 8,
+ },
+ errorText: {
+ fontSize: 16,
+ textAlign: 'center',
+ },
+});
+
diff --git a/react-native-expo/app/(drawer)/(tabs)/_layout.tsx b/react-native-expo/app/(drawer)/(tabs)/_layout.tsx
new file mode 100644
index 0000000..c517ff2
--- /dev/null
+++ b/react-native-expo/app/(drawer)/(tabs)/_layout.tsx
@@ -0,0 +1,52 @@
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { Tabs } from 'expo-router';
+import React from 'react';
+
+import { HapticTab } from '@/components/haptic-tab';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+import { useCart } from '@/context/cart-context';
+
+export default function TabLayout() {
+ const colorScheme = useColorScheme();
+ const { cart } = useCart();
+
+ const itemCount = cart?.items?.length || 0;
+
+ return (
+
+
+ ,
+ headerShown: false, // Let the home stack manage its own headers
+ }}
+ />
+ ,
+ tabBarBadge: itemCount > 0 ? itemCount : undefined,
+ tabBarBadgeStyle: {
+ backgroundColor: Colors[colorScheme ?? 'light'].tint,
+ },
+ headerShown: false, // Let the cart stack manage its own headers
+ }}
+ />
+
+ );
+}
+
diff --git a/react-native-expo/app/(drawer)/(tabs)/index.tsx b/react-native-expo/app/(drawer)/(tabs)/index.tsx
new file mode 100644
index 0000000..6341908
--- /dev/null
+++ b/react-native-expo/app/(drawer)/(tabs)/index.tsx
@@ -0,0 +1,8 @@
+import { Redirect } from 'expo-router';
+import React from 'react';
+
+const MainScreen = () => {
+ return ;
+};
+
+export default MainScreen;
diff --git a/react-native-expo/app/(drawer)/_layout.tsx b/react-native-expo/app/(drawer)/_layout.tsx
new file mode 100644
index 0000000..e672c97
--- /dev/null
+++ b/react-native-expo/app/(drawer)/_layout.tsx
@@ -0,0 +1,24 @@
+import { Drawer } from 'expo-router/drawer';
+
+import { DrawerContent } from '@/components/drawer-content';
+
+export default function DrawerLayout() {
+ return (
+ }
+ screenOptions={{
+ headerShown: false,
+ drawerPosition: 'left',
+ }}
+ >
+
+
+ );
+}
+
diff --git a/react-native-expo/app/_layout.tsx b/react-native-expo/app/_layout.tsx
new file mode 100644
index 0000000..e6eb164
--- /dev/null
+++ b/react-native-expo/app/_layout.tsx
@@ -0,0 +1,47 @@
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
+import { Stack } from 'expo-router';
+import { StatusBar } from 'expo-status-bar';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import 'react-native-reanimated';
+
+import { CartProvider } from '@/context/cart-context';
+import { RegionProvider } from '@/context/region-context';
+
+export default function RootLayout() {
+ const colorScheme = useColorScheme();
+
+ return (
+
+
+
+
+
+
+
+ null,
+ gestureEnabled: false,
+ headerBackVisible: false,
+ }}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/react-native-expo/app/checkout.tsx b/react-native-expo/app/checkout.tsx
new file mode 100644
index 0000000..7ef25fb
--- /dev/null
+++ b/react-native-expo/app/checkout.tsx
@@ -0,0 +1,390 @@
+import { DeliveryStep } from '@/components/checkout/delivery-step';
+import { PaymentStep } from '@/components/checkout/payment-step';
+import { ShippingStep } from '@/components/checkout/shipping-step';
+import { Colors } from '@/constants/theme';
+import { useCart } from '@/context/cart-context';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { sdk } from '@/lib/sdk';
+import type { HttpTypes } from '@medusajs/types';
+import { useRouter } from 'expo-router';
+import React, { useCallback, useEffect, useState } from 'react';
+import { Alert, StyleSheet, Text, View } from 'react-native';
+
+type CheckoutStep = 'delivery' | 'shipping' | 'payment';
+
+export default function CheckoutScreen() {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const { cart, refreshCart } = useCart();
+
+ const [currentStep, setCurrentStep] = useState('delivery');
+ const [loading, setLoading] = useState(false);
+
+ // Contact & Address state
+ const [email, setEmail] = useState('');
+ const [shippingAddress, setShippingAddress] = useState({
+ firstName: '',
+ lastName: '',
+ address: '',
+ city: '',
+ postalCode: '',
+ countryCode: '',
+ phone: '',
+ });
+ const [useSameForBilling, setUseSameForBilling] = useState(true);
+ const [billingAddress, setBillingAddress] = useState({
+ firstName: '',
+ lastName: '',
+ address: '',
+ city: '',
+ postalCode: '',
+ countryCode: '',
+ phone: '',
+ });
+
+ // Shipping step
+ const [shippingOptions, setShippingOptions] = useState([]);
+ const [selectedShippingOption, setSelectedShippingOption] = useState(null);
+
+ // Payment step
+ const [paymentProviders, setPaymentProviders] = useState([]);
+ const [selectedPaymentProvider, setSelectedPaymentProvider] = useState(null);
+
+ // Sync form state with cart values (handles both prepopulation and reset)
+ useEffect(() => {
+ // Populate form with existing cart data or reset to empty values
+ setEmail(cart?.email || '');
+ setShippingAddress({
+ firstName: cart?.shipping_address?.first_name || '',
+ lastName: cart?.shipping_address?.last_name || '',
+ address: cart?.shipping_address?.address_1 || '',
+ city: cart?.shipping_address?.city || '',
+ postalCode: cart?.shipping_address?.postal_code || '',
+ countryCode: cart?.shipping_address?.country_code || '',
+ phone: cart?.shipping_address?.phone || '',
+ });
+
+ // Billing address - check if different from shipping
+ const hasDifferentBilling = cart?.billing_address &&
+ (cart.billing_address.address_1 !== cart.shipping_address?.address_1 ||
+ cart.billing_address.city !== cart.shipping_address?.city);
+
+ setUseSameForBilling(!hasDifferentBilling);
+ setBillingAddress({
+ firstName: cart?.billing_address?.first_name || '',
+ lastName: cart?.billing_address?.last_name || '',
+ address: cart?.billing_address?.address_1 || '',
+ city: cart?.billing_address?.city || '',
+ postalCode: cart?.billing_address?.postal_code || '',
+ countryCode: cart?.billing_address?.country_code || '',
+ phone: cart?.billing_address?.phone || '',
+ });
+
+ // Reset selections when cart is null
+ if (!cart) {
+ setSelectedShippingOption(null);
+ setSelectedPaymentProvider(null);
+ setCurrentStep('delivery');
+ }
+ }, [cart]);
+
+ const fetchShippingOptions = useCallback(async () => {
+ if (!cart) return;
+
+ try {
+ setLoading(true);
+ const { shipping_options } = await sdk.store.fulfillment.listCartOptions({
+ cart_id: cart.id,
+ });
+ setShippingOptions(shipping_options || []);
+ } catch (err) {
+ console.error('Failed to fetch shipping options:', err);
+ Alert.alert('Error', 'Failed to load shipping options');
+ } finally {
+ setLoading(false);
+ }
+ }, [cart]);
+
+ const fetchPaymentProviders = useCallback(async () => {
+ if (!cart) return;
+
+ try {
+ setLoading(true);
+ const { payment_providers } = await sdk.store.payment.listPaymentProviders({
+ region_id: cart.region_id || '',
+ });
+ setPaymentProviders(payment_providers || []);
+ } catch (err) {
+ console.error('Failed to fetch payment providers:', err);
+ Alert.alert('Error', 'Failed to load payment providers');
+ } finally {
+ setLoading(false);
+ }
+ }, [cart]);
+
+ useEffect(() => {
+ if (currentStep === 'shipping') {
+ fetchShippingOptions();
+ } else if (currentStep === 'payment') {
+ fetchPaymentProviders();
+ }
+ }, [currentStep, fetchShippingOptions, fetchPaymentProviders]);
+
+ const handleDeliveryNext = async () => {
+ // Validate shipping address
+ if (!email || !shippingAddress.firstName || !shippingAddress.lastName ||
+ !shippingAddress.address || !shippingAddress.city || !shippingAddress.postalCode ||
+ !shippingAddress.countryCode || !shippingAddress.phone) {
+ Alert.alert('Error', 'Please fill in all shipping address fields');
+ return;
+ }
+
+ // Validate billing address if different
+ if (!useSameForBilling) {
+ if (!billingAddress.firstName || !billingAddress.lastName || !billingAddress.address ||
+ !billingAddress.city || !billingAddress.postalCode || !billingAddress.countryCode ||
+ !billingAddress.phone) {
+ Alert.alert('Error', 'Please fill in all billing address fields');
+ return;
+ }
+ }
+
+ if (!cart) return;
+
+ try {
+ setLoading(true);
+ const shippingAddressData = {
+ first_name: shippingAddress.firstName,
+ last_name: shippingAddress.lastName,
+ address_1: shippingAddress.address,
+ city: shippingAddress.city,
+ postal_code: shippingAddress.postalCode,
+ country_code: shippingAddress.countryCode,
+ phone: shippingAddress.phone,
+ };
+
+ const billingAddressData = useSameForBilling ? shippingAddressData : {
+ first_name: billingAddress.firstName,
+ last_name: billingAddress.lastName,
+ address_1: billingAddress.address,
+ city: billingAddress.city,
+ postal_code: billingAddress.postalCode,
+ country_code: billingAddress.countryCode,
+ phone: billingAddress.phone,
+ };
+
+ await sdk.store.cart.update(cart.id, {
+ email,
+ shipping_address: shippingAddressData,
+ billing_address: billingAddressData,
+ });
+
+ await refreshCart();
+ setCurrentStep('shipping');
+ } catch (err) {
+ console.error('Failed to update cart:', err);
+ Alert.alert('Error', 'Failed to save delivery information');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleShippingNext = async () => {
+ if (!selectedShippingOption || !cart) {
+ Alert.alert('Error', 'Please select a shipping method');
+ return;
+ }
+
+ try {
+ setLoading(true);
+
+ await sdk.store.cart.addShippingMethod(cart.id, {
+ option_id: selectedShippingOption,
+ });
+
+ await refreshCart();
+ setCurrentStep('payment');
+ } catch (err) {
+ console.error('Failed to add shipping method:', err);
+ Alert.alert('Error', 'Failed to save shipping method');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handlePlaceOrder = async () => {
+ if (!selectedPaymentProvider || !cart) {
+ Alert.alert('Error', 'Please select a payment provider');
+ return;
+ }
+
+ try {
+ setLoading(true);
+
+ // Create payment session
+ await sdk.store.payment.initiatePaymentSession(cart, {
+ provider_id: selectedPaymentProvider,
+ });
+
+ // Complete cart (converts cart to order on backend)
+ const result = await sdk.store.cart.complete(cart.id);
+
+ if (result.type === 'order') {
+ // Navigate to order confirmation first
+ // Cart will be cleared on the order confirmation page to prevent empty cart flash
+ router.replace(`/order-confirmation/${result.order.id}`);
+ } else {
+ Alert.alert('Error', result.error?.message || 'Failed to complete order');
+ }
+ } catch (err: any) {
+ console.error('Failed to complete order:', err);
+ Alert.alert('Error', err?.message || 'Failed to complete order');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!cart) {
+ return (
+
+
+ No cart found. Please add items to your cart first.
+
+
+ );
+ }
+
+ // Active step uses inverted colors: white bg with dark text in dark mode, tint bg with white text in light mode
+ const activeStepBg = colorScheme === 'dark' ? '#fff' : colors.tint;
+ const activeStepText = colorScheme === 'dark' ? '#000' : '#fff';
+
+ return (
+
+
+ {(['delivery', 'shipping', 'payment'] as CheckoutStep[]).map((step, index) => (
+
+
+
+ {index + 1}
+
+
+
+ {step.charAt(0).toUpperCase() + step.slice(1)}
+
+
+ ))}
+
+
+
+ {currentStep === 'delivery' && (
+
+ setShippingAddress(prev => ({ ...prev, [field]: value }))
+ }
+ onBillingAddressChange={(field, value) =>
+ setBillingAddress(prev => ({ ...prev, [field]: value }))
+ }
+ onUseSameForBillingChange={setUseSameForBilling}
+ onNext={handleDeliveryNext}
+ />
+ )}
+
+ {currentStep === 'shipping' && (
+ setCurrentStep('delivery')}
+ onNext={handleShippingNext}
+ />
+ )}
+
+ {currentStep === 'payment' && (
+ setCurrentStep('shipping')}
+ onPlaceOrder={handlePlaceOrder}
+ />
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ centerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ steps: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ padding: 20,
+ borderBottomWidth: 1,
+ },
+ stepIndicator: {
+ alignItems: 'center',
+ },
+ stepCircle: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 8,
+ },
+ stepNumber: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ stepLabel: {
+ fontSize: 12,
+ },
+ content: {
+ flex: 1,
+ },
+ errorText: {
+ fontSize: 16,
+ textAlign: 'center',
+ },
+});
+
diff --git a/react-native-expo/app/order-confirmation/[id].tsx b/react-native-expo/app/order-confirmation/[id].tsx
new file mode 100644
index 0000000..cff1661
--- /dev/null
+++ b/react-native-expo/app/order-confirmation/[id].tsx
@@ -0,0 +1,434 @@
+import { Loading } from '@/components/loading';
+import { Button } from '@/components/ui/button';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+import { useCart } from '@/context/cart-context';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { formatPrice } from '@/lib/format-price';
+import { getPaymentProviderInfo } from '@/lib/payment-providers';
+import { sdk } from '@/lib/sdk';
+import type { HttpTypes } from '@medusajs/types';
+import { Image } from 'expo-image';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+
+export default function OrderConfirmationScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const { clearCart } = useCart();
+
+ const [order, setOrder] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const hasCleared = useRef(false);
+
+ const fetchOrder = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { order: fetchedOrder } = await sdk.store.order.retrieve(id, {
+ fields: '*payment_collections.payments',
+ });
+ setOrder(fetchedOrder);
+ } catch (err) {
+ console.error('Failed to fetch order:', err);
+ setError('Failed to load order details');
+ } finally {
+ setLoading(false);
+ }
+ }, [id]);
+
+ // Fetch order when id changes
+ useEffect(() => {
+ if (id) {
+ fetchOrder();
+ }
+ }, [id, fetchOrder]);
+
+ // Clear cart when order confirmation page loads (only once)
+ useEffect(() => {
+ if (!hasCleared.current) {
+ hasCleared.current = true;
+ clearCart();
+ }
+ }, [clearCart]);
+
+ if (loading) {
+ return ;
+ }
+
+ if (error || !order) {
+ return (
+
+
+ {error || 'Order not found'}
+
+
+ );
+ }
+
+ return (
+
+
+
+ ā
+
+
+ Order Confirmed!
+
+ We have received your order and will process it as soon as possible.
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ centerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ content: {
+ padding: 20,
+ },
+ successIcon: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'center',
+ marginBottom: 24,
+ },
+ checkmark: {
+ fontSize: 48,
+ color: '#fff',
+ fontWeight: '700',
+ },
+ title: {
+ fontSize: 28,
+ fontWeight: '700',
+ textAlign: 'center',
+ marginBottom: 8,
+ },
+ subtitle: {
+ fontSize: 16,
+ textAlign: 'center',
+ marginBottom: 32,
+ },
+ card: {
+ borderWidth: 1,
+ borderRadius: 12,
+ padding: 16,
+ marginBottom: 16,
+ },
+ cardTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginBottom: 16,
+ },
+ infoRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 12,
+ },
+ label: {
+ fontSize: 14,
+ },
+ value: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginTop: 16,
+ marginBottom: 8,
+ },
+ addressText: {
+ fontSize: 14,
+ marginBottom: 4,
+ },
+ paymentMethodRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 4,
+ },
+ itemRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 16,
+ paddingBottom: 16,
+ borderBottomWidth: 1,
+ },
+ lastItemRow: {
+ borderBottomWidth: 0,
+ marginBottom: 0,
+ paddingBottom: 0,
+ },
+ itemImage: {
+ width: 60,
+ height: 60,
+ borderRadius: 8,
+ marginRight: 12,
+ },
+ itemInfo: {
+ flex: 1,
+ },
+ itemTitle: {
+ fontSize: 14,
+ fontWeight: '600',
+ marginBottom: 4,
+ },
+ itemVariant: {
+ fontSize: 12,
+ marginBottom: 4,
+ },
+ itemQuantity: {
+ fontSize: 12,
+ },
+ itemPrice: {
+ fontSize: 14,
+ fontWeight: '600',
+ marginLeft: 12,
+ },
+ summaryRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 12,
+ },
+ summaryLabel: {
+ fontSize: 14,
+ },
+ summaryValue: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ totalRow: {
+ marginTop: 12,
+ paddingTop: 12,
+ borderTopWidth: 1,
+ },
+ totalLabel: {
+ fontSize: 18,
+ fontWeight: '700',
+ },
+ totalValue: {
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ button: {
+ marginTop: 20,
+ },
+ continueButton: {
+ marginBottom: 24,
+ },
+ errorText: {
+ fontSize: 16,
+ textAlign: 'center',
+ marginBottom: 20,
+ },
+});
+
diff --git a/react-native-expo/assets/images/android-icon-background.png b/react-native-expo/assets/images/android-icon-background.png
new file mode 100644
index 0000000..5ffefc5
Binary files /dev/null and b/react-native-expo/assets/images/android-icon-background.png differ
diff --git a/react-native-expo/assets/images/android-icon-foreground.png b/react-native-expo/assets/images/android-icon-foreground.png
new file mode 100644
index 0000000..3a9e501
Binary files /dev/null and b/react-native-expo/assets/images/android-icon-foreground.png differ
diff --git a/react-native-expo/assets/images/android-icon-monochrome.png b/react-native-expo/assets/images/android-icon-monochrome.png
new file mode 100644
index 0000000..77484eb
Binary files /dev/null and b/react-native-expo/assets/images/android-icon-monochrome.png differ
diff --git a/react-native-expo/assets/images/favicon.png b/react-native-expo/assets/images/favicon.png
new file mode 100644
index 0000000..408bd74
Binary files /dev/null and b/react-native-expo/assets/images/favicon.png differ
diff --git a/react-native-expo/assets/images/icon.png b/react-native-expo/assets/images/icon.png
new file mode 100644
index 0000000..7165a53
Binary files /dev/null and b/react-native-expo/assets/images/icon.png differ
diff --git a/react-native-expo/assets/images/partial-react-logo.png b/react-native-expo/assets/images/partial-react-logo.png
new file mode 100644
index 0000000..66fd957
Binary files /dev/null and b/react-native-expo/assets/images/partial-react-logo.png differ
diff --git a/react-native-expo/assets/images/react-logo.png b/react-native-expo/assets/images/react-logo.png
new file mode 100644
index 0000000..9d72a9f
Binary files /dev/null and b/react-native-expo/assets/images/react-logo.png differ
diff --git a/react-native-expo/assets/images/react-logo@2x.png b/react-native-expo/assets/images/react-logo@2x.png
new file mode 100644
index 0000000..2229b13
Binary files /dev/null and b/react-native-expo/assets/images/react-logo@2x.png differ
diff --git a/react-native-expo/assets/images/react-logo@3x.png b/react-native-expo/assets/images/react-logo@3x.png
new file mode 100644
index 0000000..a99b203
Binary files /dev/null and b/react-native-expo/assets/images/react-logo@3x.png differ
diff --git a/react-native-expo/assets/images/splash-icon.png b/react-native-expo/assets/images/splash-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/react-native-expo/assets/images/splash-icon.png differ
diff --git a/react-native-expo/components/cart-item.tsx b/react-native-expo/components/cart-item.tsx
new file mode 100644
index 0000000..19c57d8
--- /dev/null
+++ b/react-native-expo/components/cart-item.tsx
@@ -0,0 +1,138 @@
+import { IconSymbol } from "@/components/ui/icon-symbol";
+import { Colors } from "@/constants/theme";
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { formatPrice } from "@/lib/format-price";
+import type { HttpTypes } from "@medusajs/types";
+import { Image } from "expo-image";
+import React from "react";
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+
+interface CartItemProps {
+ item: HttpTypes.StoreCartLineItem;
+ currencyCode?: string;
+ onUpdateQuantity: (quantity: number) => void;
+ onRemove: () => void;
+}
+
+export const CartItem = React.memo(function CartItem({ item, currencyCode, onUpdateQuantity, onRemove }: CartItemProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? "light"];
+
+ const thumbnail = item.thumbnail || item.variant?.product?.thumbnail;
+ const total = item.subtotal || 0;
+
+ return (
+
+
+
+
+
+ {item.product_title || item.title}
+
+ {item.variant_title && (
+
+ {item.variant_title}
+
+ )}
+
+ {formatPrice(total, currencyCode)}
+
+
+
+
+ onUpdateQuantity(Math.max(1, item.quantity - 1))}
+ >
+ -
+
+
+ {item.quantity}
+
+ onUpdateQuantity(item.quantity + 1)}
+ >
+ +
+
+
+
+
+
+
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ padding: 16,
+ borderBottomWidth: 1,
+ },
+ image: {
+ width: 80,
+ height: 80,
+ borderRadius: 8,
+ },
+ content: {
+ flex: 1,
+ marginLeft: 12,
+ justifyContent: "space-between",
+ },
+ info: {
+ flex: 1,
+ },
+ title: {
+ fontSize: 14,
+ fontWeight: "600",
+ marginBottom: 4,
+ },
+ variant: {
+ fontSize: 12,
+ marginBottom: 4,
+ },
+ price: {
+ fontSize: 14,
+ fontWeight: "700",
+ marginTop: 4,
+ },
+ actions: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginTop: 8,
+ },
+ quantityContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ quantityButton: {
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ borderWidth: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ quantityButtonText: {
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ quantity: {
+ marginHorizontal: 12,
+ fontSize: 14,
+ fontWeight: "600",
+ minWidth: 20,
+ textAlign: "center",
+ },
+ removeButton: {
+ padding: 4,
+ },
+});
+
diff --git a/react-native-expo/components/checkout/address-form.tsx b/react-native-expo/components/checkout/address-form.tsx
new file mode 100644
index 0000000..5c47208
--- /dev/null
+++ b/react-native-expo/components/checkout/address-form.tsx
@@ -0,0 +1,298 @@
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import type { HttpTypes } from '@medusajs/types';
+import { Picker } from '@react-native-picker/picker';
+import React, { useRef, useState } from 'react';
+import {
+ Modal,
+ Platform,
+ StyleSheet,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+interface AddressFormProps {
+ firstName: string;
+ lastName: string;
+ address: string;
+ city: string;
+ postalCode: string;
+ countryCode: string;
+ phone: string;
+ countries: HttpTypes.StoreRegionCountry[];
+ onFirstNameChange: (value: string) => void;
+ onLastNameChange: (value: string) => void;
+ onAddressChange: (value: string) => void;
+ onCityChange: (value: string) => void;
+ onPostalCodeChange: (value: string) => void;
+ onCountryCodeChange: (value: string) => void;
+ onPhoneChange: (value: string) => void;
+}
+
+export function AddressForm({
+ firstName,
+ lastName,
+ address,
+ city,
+ postalCode,
+ countryCode,
+ phone,
+ countries,
+ onFirstNameChange,
+ onLastNameChange,
+ onAddressChange,
+ onCityChange,
+ onPostalCodeChange,
+ onCountryCodeChange,
+ onPhoneChange,
+}: AddressFormProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const [showPicker, setShowPicker] = useState(false);
+ const [tempCountryCode, setTempCountryCode] = useState(countryCode);
+
+ // Create refs for each input field
+ const lastNameRef = useRef(null);
+ const addressRef = useRef(null);
+ const cityRef = useRef(null);
+ const postalCodeRef = useRef(null);
+ const phoneRef = useRef(null);
+
+ const selectedCountry = countries.find(
+ (c) => (c.iso_2 || c.id) === countryCode
+ );
+ const selectedCountryName = selectedCountry
+ ? selectedCountry.display_name || selectedCountry.name || selectedCountry.iso_2 || selectedCountry.id
+ : 'Select Country';
+
+ const handleDone = () => {
+ onCountryCodeChange(tempCountryCode);
+ setShowPicker(false);
+ // Focus phone field after country selection
+ setTimeout(() => phoneRef.current?.focus(), 100);
+ };
+
+ const handleCancel = () => {
+ setTempCountryCode(countryCode);
+ setShowPicker(false);
+ };
+
+ return (
+ <>
+
+ lastNameRef.current?.focus()}
+ />
+ addressRef.current?.focus()}
+ />
+
+
+ cityRef.current?.focus()}
+ />
+
+
+ postalCodeRef.current?.focus()}
+ />
+ {
+ setTempCountryCode(countryCode);
+ setShowPicker(true);
+ }}
+ />
+
+
+ {
+ setTempCountryCode(countryCode);
+ setShowPicker(true);
+ }}
+ >
+
+ {selectedCountryName}
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+
+
+ Cancel
+
+ Select Country
+
+ Done
+
+
+ {
+ setTempCountryCode(value);
+ if (Platform.OS === 'android') {
+ onCountryCodeChange(value);
+ setShowPicker(false);
+ // Focus phone field after country selection on Android
+ setTimeout(() => phoneRef.current?.focus(), 100);
+ }
+ }}
+ style={[styles.picker, Platform.OS === 'android' && { color: colors.text }]}
+ itemStyle={Platform.OS === 'ios' ? styles.pickerItemIOS : undefined}
+ >
+
+ {countries.map((country) => {
+ const code = country.iso_2 || country.id;
+ const name = country.display_name || country.name || country.iso_2 || country.id;
+ return ;
+ })}
+
+
+
+
+
+
+ >
+ );
+}
+
+const styles = StyleSheet.create({
+ input: {
+ borderWidth: 1,
+ borderRadius: 8,
+ padding: 12,
+ fontSize: 16,
+ marginBottom: 12,
+ },
+ row: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ halfInput: {
+ flex: 1,
+ },
+ pickerButton: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ pickerButtonText: {
+ fontSize: 16,
+ flex: 1,
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ justifyContent: 'flex-end',
+ },
+ modalContent: {
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ maxHeight: '50%',
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 16,
+ borderBottomWidth: 1,
+ },
+ modalTitle: {
+ fontSize: 18,
+ fontWeight: '600',
+ },
+ modalButton: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ picker: {
+ width: '100%',
+ height: Platform.OS === 'ios' ? 250 : 48,
+ },
+ pickerItemIOS: {
+ height: 200,
+ },
+});
+
diff --git a/react-native-expo/components/checkout/delivery-step.tsx b/react-native-expo/components/checkout/delivery-step.tsx
new file mode 100644
index 0000000..439d6d1
--- /dev/null
+++ b/react-native-expo/components/checkout/delivery-step.tsx
@@ -0,0 +1,233 @@
+import { AddressForm } from '@/components/checkout/address-form';
+import { Button } from '@/components/ui/button';
+import { Colors } from '@/constants/theme';
+import { useRegion } from '@/context/region-context';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import React, { useEffect, useRef, useState } from 'react';
+import {
+ Keyboard,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ TextInput,
+ View
+} from 'react-native';
+
+interface Address {
+ firstName: string;
+ lastName: string;
+ address: string;
+ city: string;
+ postalCode: string;
+ countryCode: string;
+ phone: string;
+}
+
+interface DeliveryStepProps {
+ email: string;
+ shippingAddress: Address;
+ billingAddress: Address;
+ useSameForBilling: boolean;
+ loading: boolean;
+ onEmailChange: (value: string) => void;
+ onShippingAddressChange: (field: keyof Address, value: string) => void;
+ onBillingAddressChange: (field: keyof Address, value: string) => void;
+ onUseSameForBillingChange: (value: boolean) => void;
+ onNext: () => void;
+}
+
+export function DeliveryStep({
+ email,
+ shippingAddress,
+ billingAddress,
+ useSameForBilling,
+ loading,
+ onEmailChange,
+ onShippingAddressChange,
+ onBillingAddressChange,
+ onUseSameForBillingChange,
+ onNext,
+}: DeliveryStepProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const { selectedRegion } = useRegion();
+ const scrollViewRef = useRef(null);
+ const [isKeyboardVisible, setKeyboardVisible] = useState(false);
+
+ const countries = selectedRegion?.countries || [];
+
+ useEffect(() => {
+ const keyboardWillShowListener = Keyboard.addListener(
+ Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
+ () => setKeyboardVisible(true)
+ );
+ const keyboardWillHideListener = Keyboard.addListener(
+ Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
+ () => setKeyboardVisible(false)
+ );
+
+ return () => {
+ keyboardWillShowListener.remove();
+ keyboardWillHideListener.remove();
+ };
+ }, []);
+
+ return (
+
+
+
+
+ Contact Information
+
+
+
+
+
+ Shipping Address
+
+
+ onShippingAddressChange('firstName', value)}
+ onLastNameChange={(value) => onShippingAddressChange('lastName', value)}
+ onAddressChange={(value) => onShippingAddressChange('address', value)}
+ onCityChange={(value) => onShippingAddressChange('city', value)}
+ onPostalCodeChange={(value) => onShippingAddressChange('postalCode', value)}
+ onCountryCodeChange={(value) => onShippingAddressChange('countryCode', value)}
+ onPhoneChange={(value) => onShippingAddressChange('phone', value)}
+ />
+
+
+
+ Use same address for billing
+
+
+
+
+ {!useSameForBilling && (
+ <>
+
+ Billing Address
+
+
+ onBillingAddressChange('firstName', value)}
+ onLastNameChange={(value) => onBillingAddressChange('lastName', value)}
+ onAddressChange={(value) => onBillingAddressChange('address', value)}
+ onCityChange={(value) => onBillingAddressChange('city', value)}
+ onPostalCodeChange={(value) => onBillingAddressChange('postalCode', value)}
+ onCountryCodeChange={(value) => onBillingAddressChange('countryCode', value)}
+ onPhoneChange={(value) => onBillingAddressChange('phone', value)}
+ />
+ >
+ )}
+
+
+ {/* Button moved inside ScrollView for consistent behavior */}
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ paddingBottom: 20,
+ },
+ scrollContentKeyboard: {
+ paddingBottom: Platform.OS === 'ios' ? 300 : 320,
+ },
+ section: {
+ padding: 20,
+ },
+ buttonContainer: {
+ padding: 20,
+ paddingTop: 10,
+ paddingBottom: 30,
+ },
+ sectionTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 20,
+ },
+ input: {
+ borderWidth: 1,
+ borderRadius: 8,
+ padding: 12,
+ fontSize: 16,
+ marginBottom: 12,
+ },
+ switchContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginVertical: 16,
+ },
+ switchLabel: {
+ fontSize: 16,
+ flex: 1,
+ },
+ button: {
+ marginTop: 8,
+ },
+});
+
diff --git a/react-native-expo/components/checkout/payment-step.tsx b/react-native-expo/components/checkout/payment-step.tsx
new file mode 100644
index 0000000..c64c0b0
--- /dev/null
+++ b/react-native-expo/components/checkout/payment-step.tsx
@@ -0,0 +1,238 @@
+import { Loading } from '@/components/loading';
+import { Button } from '@/components/ui/button';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { formatPrice } from '@/lib/format-price';
+import { getPaymentProviderInfo } from '@/lib/payment-providers';
+import type { HttpTypes } from '@medusajs/types';
+import React from 'react';
+import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+interface PaymentStepProps {
+ cart: HttpTypes.StoreCart;
+ paymentProviders: HttpTypes.StorePaymentProvider[];
+ selectedPaymentProvider: string | null;
+ loading: boolean;
+ onSelectProvider: (providerId: string) => void;
+ onBack: () => void;
+ onPlaceOrder: () => void;
+}
+
+export function PaymentStep({
+ cart,
+ paymentProviders,
+ selectedPaymentProvider,
+ loading,
+ onSelectProvider,
+ onBack,
+ onPlaceOrder,
+}: PaymentStepProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ return (
+
+
+
+
+ Select Payment Method
+
+
+ {loading ? (
+
+ ) : paymentProviders.length === 0 ? (
+
+ No payment providers available
+
+ ) : (
+ paymentProviders.map((provider) => {
+ const providerInfo = getPaymentProviderInfo(provider.id);
+ const isSelected = selectedPaymentProvider === provider.id;
+
+ return (
+ onSelectProvider(provider.id)}
+ >
+
+
+
+ {providerInfo.title}
+
+
+ {isSelected && (
+ ā
+ )}
+
+ );
+ })
+ )}
+
+
+
+ Order Summary
+
+
+ Subtotal
+
+ {formatPrice(cart.item_subtotal || 0, cart.currency_code)}
+
+
+
+ Discount
+
+ {(cart.discount_total || 0) > 0 ? '-' : ''}{formatPrice(cart.discount_total || 0, cart.currency_code)}
+
+
+
+ Shipping
+
+ {formatPrice(cart.shipping_total || 0, cart.currency_code)}
+
+
+
+ Tax
+
+ {formatPrice(cart.tax_total || 0, cart.currency_code)}
+
+
+
+ Total
+
+ {formatPrice(cart.total, cart.currency_code)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ contentWrapper: {
+ flex: 1,
+ },
+ section: {
+ padding: 20,
+ },
+ buttonContainer: {
+ padding: 20,
+ paddingBottom: 30,
+ borderTopWidth: 1,
+ },
+ sectionTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 20,
+ },
+ optionCard: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 16,
+ borderRadius: 8,
+ borderWidth: 1,
+ marginBottom: 12,
+ },
+ optionContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ flex: 1,
+ },
+ optionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ emptyText: {
+ fontSize: 16,
+ textAlign: 'center',
+ padding: 20,
+ },
+ summary: {
+ borderWidth: 1,
+ borderRadius: 8,
+ padding: 16,
+ marginTop: 20,
+ marginBottom: 20,
+ },
+ summaryTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginBottom: 12,
+ },
+ summaryRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 8,
+ },
+ summaryLabel: {
+ fontSize: 14,
+ },
+ summaryValue: {
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ totalRow: {
+ marginTop: 12,
+ paddingTop: 12,
+ borderTopWidth: 1,
+ },
+ totalLabel: {
+ fontSize: 18,
+ fontWeight: '700',
+ },
+ totalValue: {
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ halfButton: {
+ flex: 1,
+ },
+});
+
diff --git a/react-native-expo/components/checkout/shipping-step.tsx b/react-native-expo/components/checkout/shipping-step.tsx
new file mode 100644
index 0000000..02b5b03
--- /dev/null
+++ b/react-native-expo/components/checkout/shipping-step.tsx
@@ -0,0 +1,166 @@
+import { Loading } from '@/components/loading';
+import { Button } from '@/components/ui/button';
+import { Colors } from '@/constants/theme';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { formatPrice } from '@/lib/format-price';
+import type { HttpTypes } from '@medusajs/types';
+import React from 'react';
+import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+
+interface ShippingStepProps {
+ shippingOptions: HttpTypes.StoreCartShippingOption[];
+ selectedShippingOption: string | null;
+ currencyCode?: string;
+ loading: boolean;
+ onSelectOption: (optionId: string) => void;
+ onBack: () => void;
+ onNext: () => void;
+}
+
+export function ShippingStep({
+ shippingOptions,
+ selectedShippingOption,
+ currencyCode,
+ loading,
+ onSelectOption,
+ onBack,
+ onNext,
+}: ShippingStepProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+
+ return (
+
+
+
+
+ Select Shipping Method
+
+
+ {loading ? (
+
+ ) : shippingOptions.length === 0 ? (
+
+ No shipping options available
+
+ ) : (
+ shippingOptions.map((option) => (
+ onSelectOption(option.id)}
+ >
+
+
+ {option.name}
+
+
+ {formatPrice(option.amount, currencyCode)}
+
+
+ {selectedShippingOption === option.id && (
+ ā
+ )}
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ contentWrapper: {
+ flex: 1,
+ },
+ section: {
+ padding: 20,
+ },
+ buttonContainer: {
+ padding: 20,
+ paddingBottom: 30,
+ borderTopWidth: 1,
+ },
+ sectionTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ marginBottom: 20,
+ },
+ optionCard: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 16,
+ borderRadius: 8,
+ borderWidth: 1,
+ marginBottom: 12,
+ },
+ optionInfo: {
+ flex: 1,
+ },
+ optionTitle: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 4,
+ },
+ optionPrice: {
+ fontSize: 14,
+ },
+ emptyText: {
+ fontSize: 16,
+ textAlign: 'center',
+ padding: 20,
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ halfButton: {
+ flex: 1,
+ },
+});
+
diff --git a/react-native-expo/components/drawer-content.tsx b/react-native-expo/components/drawer-content.tsx
new file mode 100644
index 0000000..8e8d6e6
--- /dev/null
+++ b/react-native-expo/components/drawer-content.tsx
@@ -0,0 +1,21 @@
+import { DrawerContentComponentProps, DrawerContentScrollView } from "@react-navigation/drawer";
+import React from "react";
+import { StyleSheet, View } from "react-native";
+import { RegionSelector } from "./region-selector";
+
+export function DrawerContent(props: DrawerContentComponentProps) {
+ return (
+
+
+ props.navigation.closeDrawer()} />
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});
+
diff --git a/react-native-expo/components/haptic-tab.tsx b/react-native-expo/components/haptic-tab.tsx
new file mode 100644
index 0000000..7f3981c
--- /dev/null
+++ b/react-native-expo/components/haptic-tab.tsx
@@ -0,0 +1,18 @@
+import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
+import { PlatformPressable } from '@react-navigation/elements';
+import * as Haptics from 'expo-haptics';
+
+export function HapticTab(props: BottomTabBarButtonProps) {
+ return (
+ {
+ if (process.env.EXPO_OS === 'ios') {
+ // Add a soft haptic feedback when pressing down on the tabs.
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ props.onPressIn?.(ev);
+ }}
+ />
+ );
+}
diff --git a/react-native-expo/components/loading.tsx b/react-native-expo/components/loading.tsx
new file mode 100644
index 0000000..240604e
--- /dev/null
+++ b/react-native-expo/components/loading.tsx
@@ -0,0 +1,36 @@
+import { Colors } from "@/constants/theme";
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import React from "react";
+import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
+
+interface LoadingProps {
+ message?: string;
+}
+
+export function Loading({ message }: LoadingProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? "light"];
+
+ return (
+
+
+ {message && (
+ {message}
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ padding: 20,
+ },
+ message: {
+ marginTop: 12,
+ fontSize: 16,
+ },
+});
+
diff --git a/react-native-expo/components/product-card.tsx b/react-native-expo/components/product-card.tsx
new file mode 100644
index 0000000..d2809d4
--- /dev/null
+++ b/react-native-expo/components/product-card.tsx
@@ -0,0 +1,97 @@
+import { Colors } from "@/constants/theme";
+import { useRegion } from "@/context/region-context";
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { formatPrice } from "@/lib/format-price";
+import type { HttpTypes } from "@medusajs/types";
+import { Image } from "expo-image";
+import { useRouter } from "expo-router";
+import React from "react";
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+
+interface ProductCardProps {
+ product: HttpTypes.StoreProduct;
+}
+
+export const ProductCard = React.memo(function ProductCard({ product }: ProductCardProps) {
+ const router = useRouter();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? "light"];
+ const { selectedRegion } = useRegion();
+
+ const thumbnail = product.thumbnail || product.images?.[0]?.url;
+ const variant = product.variants?.[0];
+
+ // Get price from calculated_price.calculated_amount
+ const priceAmount = variant?.calculated_price?.calculated_amount || 0
+
+ // Use selected region's currency code
+ const currencyCode = selectedRegion?.currency_code;
+
+ return (
+ router.push({
+ pathname: `/(home)/product/${product.id}` as any,
+ params: { title: product.title }
+ })}
+ activeOpacity={0.7}
+ >
+
+
+
+ {product.title}
+
+
+
+ {formatPrice(priceAmount, currencyCode)}
+
+
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ card: {
+ flex: 1,
+ margin: 8,
+ overflow: "hidden",
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ image: {
+ width: "100%",
+ height: 180,
+ borderRadius: 8,
+ },
+ content: {
+ padding: 12,
+ },
+ title: {
+ fontSize: 14,
+ fontWeight: "600",
+ marginBottom: 12,
+ },
+ priceRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 8,
+ },
+ price: {
+ fontSize: 14,
+ fontWeight: "400",
+ flex: 1,
+ },
+});
+
diff --git a/react-native-expo/components/product-image-slider.tsx b/react-native-expo/components/product-image-slider.tsx
new file mode 100644
index 0000000..0d085b3
--- /dev/null
+++ b/react-native-expo/components/product-image-slider.tsx
@@ -0,0 +1,104 @@
+import { Colors } from '@/constants/theme';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { Image } from 'expo-image';
+import React, { useRef, useState } from 'react';
+import { Dimensions, FlatList, StyleSheet, View } from 'react-native';
+
+const { width: SCREEN_WIDTH } = Dimensions.get('window');
+
+interface ProductImageSliderProps {
+ images: string[];
+}
+
+export function ProductImageSlider({ images }: ProductImageSliderProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? 'light'];
+ const [currentImageIndex, setCurrentImageIndex] = useState(0);
+ const imageListRef = useRef(null);
+
+ const onViewableItemsChanged = useRef(({ viewableItems }: any) => {
+ if (viewableItems.length > 0) {
+ setCurrentImageIndex(viewableItems[0].index || 0);
+ }
+ }).current;
+
+ const viewabilityConfig = useRef({
+ itemVisiblePercentThreshold: 50,
+ }).current;
+
+ const renderImageItem = ({ item }: { item: string }) => (
+
+
+
+ );
+
+ if (images.length === 0) {
+ return null;
+ }
+
+ return (
+
+ `image-${index}`}
+ horizontal
+ pagingEnabled
+ showsHorizontalScrollIndicator={false}
+ onViewableItemsChanged={onViewableItemsChanged}
+ viewabilityConfig={viewabilityConfig}
+ />
+ {images.length > 1 && (
+
+ {images.map((_, index) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'relative',
+ },
+ imageSlide: {
+ width: SCREEN_WIDTH,
+ },
+ image: {
+ width: SCREEN_WIDTH,
+ height: 400,
+ },
+ pagination: {
+ position: 'absolute',
+ bottom: 16,
+ left: 0,
+ right: 0,
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: 6,
+ },
+ paginationDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+});
+
diff --git a/react-native-expo/components/product-skeleton.tsx b/react-native-expo/components/product-skeleton.tsx
new file mode 100644
index 0000000..6b6abb9
--- /dev/null
+++ b/react-native-expo/components/product-skeleton.tsx
@@ -0,0 +1,257 @@
+import { Colors } from "@/constants/theme";
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import React, { useEffect, useRef } from "react";
+import { Animated, StyleSheet, View } from "react-native";
+
+export function ProductSkeleton() {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? "light"];
+ const shimmerAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.loop(
+ Animated.sequence([
+ Animated.timing(shimmerAnim, {
+ toValue: 1,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shimmerAnim, {
+ toValue: 0,
+ duration: 1000,
+ useNativeDriver: true,
+ }),
+ ])
+ ).start();
+ }, [shimmerAnim]);
+
+ const opacity = shimmerAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0.3, 0.7],
+ });
+
+ const skeletonColor = colorScheme === 'dark' ? '#333' : '#e0e0e0';
+
+ return (
+
+ {/* Main Image Skeleton with Pagination Dots */}
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+ {/* Title Skeleton */}
+
+
+ {/* Description Skeleton - 3 lines */}
+
+
+
+
+
+
+ {/* Price Skeleton */}
+
+
+ {/* Options Skeleton */}
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+ {/* Quantity Skeleton */}
+
+
+
+
+
+
+
+
+
+ {/* Add to Cart Button Skeleton */}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ imageContainer: {
+ position: 'relative',
+ },
+ imageSkeleton: {
+ width: '100%',
+ height: 400,
+ },
+ pagination: {
+ position: 'absolute',
+ bottom: 16,
+ left: 0,
+ right: 0,
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: 6,
+ },
+ paginationDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ content: {
+ padding: 20,
+ },
+ titleSkeleton: {
+ height: 32,
+ width: '70%',
+ borderRadius: 4,
+ marginBottom: 12,
+ },
+ descriptionContainer: {
+ marginBottom: 20,
+ },
+ descriptionLine: {
+ height: 16,
+ borderRadius: 4,
+ marginBottom: 8,
+ },
+ priceSkeleton: {
+ height: 36,
+ width: '40%',
+ borderRadius: 4,
+ marginBottom: 24,
+ },
+ optionsContainer: {
+ marginBottom: 24,
+ },
+ optionTitleSkeleton: {
+ height: 20,
+ width: '30%',
+ borderRadius: 4,
+ marginBottom: 12,
+ },
+ optionButtons: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ optionButtonSkeleton: {
+ height: 40,
+ width: 80,
+ borderRadius: 8,
+ },
+ quantityContainer: {
+ marginBottom: 32,
+ },
+ quantityLabelSkeleton: {
+ height: 20,
+ width: '25%',
+ borderRadius: 4,
+ marginBottom: 12,
+ },
+ quantityControls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ quantityButtonSkeleton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ },
+ quantityValueSkeleton: {
+ width: 30,
+ height: 20,
+ borderRadius: 4,
+ marginHorizontal: 20,
+ },
+ buttonSkeleton: {
+ height: 48,
+ borderRadius: 8,
+ marginTop: 8,
+ },
+});
+
diff --git a/react-native-expo/components/region-selector.tsx b/react-native-expo/components/region-selector.tsx
new file mode 100644
index 0000000..3979f70
--- /dev/null
+++ b/react-native-expo/components/region-selector.tsx
@@ -0,0 +1,140 @@
+import { Colors } from "@/constants/theme";
+import { useRegion } from "@/context/region-context";
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import type { HttpTypes } from "@medusajs/types";
+import React, { useMemo } from "react";
+import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from "react-native";
+
+interface RegionSelectorProps {
+ onRegionChange?: () => void;
+}
+
+interface CountryWithRegion {
+ countryCode: string;
+ countryName: string;
+ region: HttpTypes.StoreRegion;
+ currencyCode: string;
+}
+
+export function RegionSelector({ onRegionChange }: RegionSelectorProps) {
+ const { regions, selectedRegion, selectedCountryCode, setSelectedRegion } = useRegion();
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? "light"];
+
+ // Flatten countries from all regions
+ const countries = useMemo(() => {
+ const countryList: CountryWithRegion[] = [];
+
+ regions.forEach((region) => {
+ if (region.countries) {
+ region.countries.forEach((country) => {
+ countryList.push({
+ countryCode: country.iso_2 || country.id,
+ countryName: country.display_name || country.name || country.iso_2 || country.id,
+ region: region,
+ currencyCode: region.currency_code || '',
+ });
+ });
+ }
+ });
+
+ // Sort alphabetically by country name
+ return countryList.sort((a, b) => a.countryName.localeCompare(b.countryName));
+ }, [regions]);
+
+ const handleSelectCountry = async (countryWithRegion: CountryWithRegion) => {
+ setSelectedRegion(countryWithRegion.region, countryWithRegion.countryCode);
+ onRegionChange?.();
+ };
+
+ const isCountrySelected = (countryWithRegion: CountryWithRegion) => {
+ return selectedRegion?.id === countryWithRegion.region.id &&
+ selectedCountryCode === countryWithRegion.countryCode;
+ };
+
+ return (
+
+ Select Country
+ {countries.length === 0 ? (
+
+ No countries available
+
+ ) : (
+ countries.map((country) => {
+ const isSelected = isCountrySelected(country);
+
+ return (
+ handleSelectCountry(country)}
+ >
+
+
+ {country.countryName}
+
+
+ {country.currencyCode.toUpperCase()}
+
+
+ {isSelected && (
+ ā
+ )}
+
+ );
+ })
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 16,
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: "700",
+ marginBottom: 20,
+ },
+ countryItem: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ padding: 16,
+ borderRadius: 8,
+ borderWidth: 1,
+ marginBottom: 8,
+ },
+ countryInfo: {
+ flex: 1,
+ },
+ countryName: {
+ fontSize: 16,
+ marginBottom: 4,
+ },
+ currencyCode: {
+ fontSize: 12,
+ },
+ emptyText: {
+ fontSize: 14,
+ textAlign: "center",
+ marginTop: 20,
+ },
+});
+
diff --git a/react-native-expo/components/themed-text.tsx b/react-native-expo/components/themed-text.tsx
new file mode 100644
index 0000000..d79d0a1
--- /dev/null
+++ b/react-native-expo/components/themed-text.tsx
@@ -0,0 +1,60 @@
+import { StyleSheet, Text, type TextProps } from 'react-native';
+
+import { useThemeColor } from '@/hooks/use-theme-color';
+
+export type ThemedTextProps = TextProps & {
+ lightColor?: string;
+ darkColor?: string;
+ type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
+};
+
+export function ThemedText({
+ style,
+ lightColor,
+ darkColor,
+ type = 'default',
+ ...rest
+}: ThemedTextProps) {
+ const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
+
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create({
+ default: {
+ fontSize: 16,
+ lineHeight: 24,
+ },
+ defaultSemiBold: {
+ fontSize: 16,
+ lineHeight: 24,
+ fontWeight: '600',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 'bold',
+ lineHeight: 32,
+ },
+ subtitle: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ link: {
+ lineHeight: 30,
+ fontSize: 16,
+ color: '#0a7ea4',
+ },
+});
diff --git a/react-native-expo/components/themed-view.tsx b/react-native-expo/components/themed-view.tsx
new file mode 100644
index 0000000..6f181d8
--- /dev/null
+++ b/react-native-expo/components/themed-view.tsx
@@ -0,0 +1,14 @@
+import { View, type ViewProps } from 'react-native';
+
+import { useThemeColor } from '@/hooks/use-theme-color';
+
+export type ThemedViewProps = ViewProps & {
+ lightColor?: string;
+ darkColor?: string;
+};
+
+export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
+ const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
+
+ return ;
+}
diff --git a/react-native-expo/components/ui/button.tsx b/react-native-expo/components/ui/button.tsx
new file mode 100644
index 0000000..597ae12
--- /dev/null
+++ b/react-native-expo/components/ui/button.tsx
@@ -0,0 +1,85 @@
+import { Colors } from "@/constants/theme";
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import React from "react";
+import {
+ ActivityIndicator,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ TouchableOpacityProps,
+} from "react-native";
+
+interface ButtonProps extends TouchableOpacityProps {
+ title: string;
+ variant?: "primary" | "secondary";
+ loading?: boolean;
+}
+
+export function Button({
+ title,
+ variant = "primary",
+ loading = false,
+ disabled,
+ style,
+ ...props
+}: ButtonProps) {
+ const colorScheme = useColorScheme();
+ const colors = Colors[colorScheme ?? "light"];
+
+ const isPrimary = variant === "primary";
+ const isDisabled = disabled || loading;
+
+ // Primary button: white background with dark text in dark mode, tint background with white text in light mode
+ const primaryBgColor = colorScheme === 'dark' ? '#fff' : colors.tint;
+ const primaryTextColor = colorScheme === 'dark' ? '#000' : '#fff';
+
+ return (
+
+ {loading ? (
+
+ ) : (
+
+ {title}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ button: {
+ paddingVertical: 14,
+ paddingHorizontal: 24,
+ borderRadius: 8,
+ alignItems: "center",
+ justifyContent: "center",
+ minHeight: 48,
+ },
+ secondaryButton: {
+ backgroundColor: "transparent",
+ borderWidth: 1,
+ borderColor: "#ccc",
+ },
+ text: {
+ fontSize: 16,
+ fontWeight: "600",
+ },
+ disabled: {
+ opacity: 0.5,
+ },
+});
+
diff --git a/react-native-expo/components/ui/collapsible.tsx b/react-native-expo/components/ui/collapsible.tsx
new file mode 100644
index 0000000..a5d2726
--- /dev/null
+++ b/react-native-expo/components/ui/collapsible.tsx
@@ -0,0 +1,45 @@
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import { PropsWithChildren, useState } from 'react';
+import { StyleSheet, TouchableOpacity } from 'react-native';
+
+import { ThemedText } from '@/components/themed-text';
+import { ThemedView } from '@/components/themed-view';
+import { IconSymbol } from '@/components/ui/icon-symbol';
+import { Colors } from '@/constants/theme';
+
+export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const theme = useColorScheme() ?? 'light';
+
+ return (
+
+ setIsOpen((value) => !value)}
+ activeOpacity={0.8}>
+
+
+ {title}
+
+ {isOpen && {children}}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ heading: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ content: {
+ marginTop: 6,
+ marginLeft: 24,
+ },
+});
diff --git a/react-native-expo/components/ui/icon-symbol.ios.tsx b/react-native-expo/components/ui/icon-symbol.ios.tsx
new file mode 100644
index 0000000..9177f4d
--- /dev/null
+++ b/react-native-expo/components/ui/icon-symbol.ios.tsx
@@ -0,0 +1,32 @@
+import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
+import { StyleProp, ViewStyle } from 'react-native';
+
+export function IconSymbol({
+ name,
+ size = 24,
+ color,
+ style,
+ weight = 'regular',
+}: {
+ name: SymbolViewProps['name'];
+ size?: number;
+ color: string;
+ style?: StyleProp;
+ weight?: SymbolWeight;
+}) {
+ return (
+
+ );
+}
diff --git a/react-native-expo/components/ui/icon-symbol.tsx b/react-native-expo/components/ui/icon-symbol.tsx
new file mode 100644
index 0000000..b7ece6b
--- /dev/null
+++ b/react-native-expo/components/ui/icon-symbol.tsx
@@ -0,0 +1,41 @@
+// Fallback for using MaterialIcons on Android and web.
+
+import MaterialIcons from '@expo/vector-icons/MaterialIcons';
+import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
+import { ComponentProps } from 'react';
+import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
+
+type IconMapping = Record['name']>;
+type IconSymbolName = keyof typeof MAPPING;
+
+/**
+ * Add your SF Symbols to Material Icons mappings here.
+ * - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
+ * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
+ */
+const MAPPING = {
+ 'house.fill': 'home',
+ 'paperplane.fill': 'send',
+ 'chevron.left.forwardslash.chevron.right': 'code',
+ 'chevron.right': 'chevron-right',
+} as IconMapping;
+
+/**
+ * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
+ * This ensures a consistent look across platforms, and optimal resource usage.
+ * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
+ */
+export function IconSymbol({
+ name,
+ size = 24,
+ color,
+ style,
+}: {
+ name: IconSymbolName;
+ size?: number;
+ color: string | OpaqueColorValue;
+ style?: StyleProp;
+ weight?: SymbolWeight;
+}) {
+ return ;
+}
diff --git a/react-native-expo/components/ui/toast.tsx b/react-native-expo/components/ui/toast.tsx
new file mode 100644
index 0000000..af9fe71
--- /dev/null
+++ b/react-native-expo/components/ui/toast.tsx
@@ -0,0 +1,117 @@
+import { useColorScheme } from '@/hooks/use-color-scheme';
+import React, { useCallback, useEffect, useRef } from 'react';
+import { Animated, StyleSheet, Text, View } from 'react-native';
+
+interface ToastProps {
+ message: string;
+ visible: boolean;
+ onHide: () => void;
+ duration?: number;
+ type?: 'success' | 'error' | 'info';
+}
+
+export function Toast({
+ message,
+ visible,
+ onHide,
+ duration = 3000,
+ type = 'success'
+}: ToastProps) {
+ const colorScheme = useColorScheme();
+ const opacity = useRef(new Animated.Value(0)).current;
+ const translateY = useRef(new Animated.Value(50)).current;
+
+ const hideToast = useCallback(() => {
+ Animated.parallel([
+ Animated.timing(opacity, {
+ toValue: 0,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ Animated.timing(translateY, {
+ toValue: 50,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ ]).start(() => {
+ onHide();
+ });
+ }, [opacity, translateY, onHide]);
+
+ useEffect(() => {
+ if (visible) {
+ // Fade in and slide up
+ Animated.parallel([
+ Animated.timing(opacity, {
+ toValue: 1,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ Animated.timing(translateY, {
+ toValue: 0,
+ duration: 300,
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ // Auto hide after duration
+ const timer = setTimeout(() => {
+ hideToast();
+ }, duration);
+
+ return () => clearTimeout(timer);
+ }
+ }, [visible, duration, opacity, translateY, hideToast]);
+
+ if (!visible) {
+ return null;
+ }
+
+ // Use inverted colors for minimal design: white in dark mode, black in light mode
+ const backgroundColor = colorScheme === 'dark' ? '#fff' : '#000';
+ const textColor = colorScheme === 'dark' ? '#000' : '#fff';
+
+ return (
+
+
+ {message}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ position: 'absolute',
+ bottom: 100,
+ left: 20,
+ right: 20,
+ alignItems: 'center',
+ zIndex: 1000,
+ },
+ toast: {
+ paddingHorizontal: 20,
+ paddingVertical: 14,
+ borderRadius: 12,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 8,
+ minWidth: 200,
+ },
+ message: {
+ fontSize: 15,
+ fontWeight: '600',
+ textAlign: 'center',
+ },
+});
+
diff --git a/react-native-expo/constants/theme.ts b/react-native-expo/constants/theme.ts
new file mode 100644
index 0000000..dd7dcd1
--- /dev/null
+++ b/react-native-expo/constants/theme.ts
@@ -0,0 +1,65 @@
+/**
+ * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
+ * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
+ */
+
+import { Platform } from 'react-native';
+
+const tintColorLight = '#000';
+const tintColorDark = '#fff';
+
+export const Colors = {
+ light: {
+ text: '#11181C',
+ background: '#fff',
+ tint: tintColorLight,
+ icon: '#687076',
+ tabIconDefault: '#687076',
+ tabIconSelected: tintColorLight,
+ border: '#e0e0e0',
+ cardBackground: '#f9f9f9',
+ error: '#ff3b30',
+ warning: '#ff9500',
+ success: '#4CAF50',
+ imagePlaceholder: '#f0f0f0',
+ },
+ dark: {
+ text: '#ECEDEE',
+ background: '#151718',
+ tint: tintColorDark,
+ icon: '#9BA1A6',
+ tabIconDefault: '#9BA1A6',
+ tabIconSelected: tintColorDark,
+ border: '#333',
+ cardBackground: '#1a1a1a',
+ error: '#ff3b30',
+ warning: '#ff9500',
+ success: '#4CAF50',
+ imagePlaceholder: '#2a2a2a',
+ },
+};
+
+export const Fonts = Platform.select({
+ ios: {
+ /** iOS `UIFontDescriptorSystemDesignDefault` */
+ sans: 'system-ui',
+ /** iOS `UIFontDescriptorSystemDesignSerif` */
+ serif: 'ui-serif',
+ /** iOS `UIFontDescriptorSystemDesignRounded` */
+ rounded: 'ui-rounded',
+ /** iOS `UIFontDescriptorSystemDesignMonospaced` */
+ mono: 'ui-monospace',
+ },
+ default: {
+ sans: 'normal',
+ serif: 'serif',
+ rounded: 'normal',
+ mono: 'monospace',
+ },
+ web: {
+ sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
+ serif: "Georgia, 'Times New Roman', serif",
+ rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
+ mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
+ },
+});
diff --git a/react-native-expo/context/cart-context.tsx b/react-native-expo/context/cart-context.tsx
new file mode 100644
index 0000000..c254d2f
--- /dev/null
+++ b/react-native-expo/context/cart-context.tsx
@@ -0,0 +1,223 @@
+import { sdk } from "@/lib/sdk";
+import { FetchError } from "@medusajs/js-sdk";
+import type { HttpTypes } from "@medusajs/types";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import React, { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react";
+import { useRegion } from "./region-context";
+
+interface CartContextType {
+ cart: HttpTypes.StoreCart | null;
+ addToCart: (variantId: string, quantity: number) => Promise;
+ updateItemQuantity: (itemId: string, quantity: number) => Promise;
+ removeItem: (itemId: string) => Promise;
+ refreshCart: () => Promise;
+ clearCart: () => Promise;
+ loading: boolean;
+ error: string | null;
+}
+
+const CartContext = createContext(undefined);
+
+const CART_STORAGE_KEY = "cart_id";
+
+export function CartProvider({ children }: { children: ReactNode }) {
+ const [cart, setCart] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const { selectedRegion } = useRegion();
+
+ const loadCart = useCallback(async () => {
+ if (!selectedRegion) return null;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const savedCartId = await AsyncStorage.getItem(CART_STORAGE_KEY);
+
+ if (savedCartId) {
+ try {
+ const { cart: fetchedCart } = await sdk.store.cart.retrieve(savedCartId, {
+ fields: "+items.*"
+ });
+
+ setCart(fetchedCart);
+ return fetchedCart;
+ } catch {
+ // Cart not found or invalid, remove from storage
+ await AsyncStorage.removeItem(CART_STORAGE_KEY);
+ }
+ }
+
+ // Create new cart for current region
+ const { cart: newCart } = await sdk.store.cart.create({
+ region_id: selectedRegion.id,
+ }, {
+ fields: "+items.*"
+ });
+ setCart(newCart);
+ await AsyncStorage.setItem(CART_STORAGE_KEY, newCart.id);
+ return newCart;
+ } catch (err) {
+ setError(`Failed to load cart: ${err instanceof FetchError ? err.message : String(err)}`);
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedRegion]);
+
+ // Load cart on mount
+ useEffect(() => {
+ loadCart()
+ }, [loadCart]);
+
+ // Update cart's region when selected region changes
+ useEffect(() => {
+ const updateCartRegion = async () => {
+ if (!cart || !selectedRegion || cart.region_id === selectedRegion.id) {
+ return;
+ }
+
+ try {
+ setLoading(true);
+ const { cart: updatedCart } = await sdk.store.cart.update(cart.id, {
+ region_id: selectedRegion.id,
+ }, {
+ fields: "+items.*"
+ });
+ setCart(updatedCart);
+ } catch (err) {
+ setError(`Failed to update cart region: ${err instanceof FetchError ? err.message : String(err)}`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ updateCartRegion();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedRegion]);
+
+ const addToCart = async (variantId: string, quantity: number) => {
+ let currentCart = cart;
+
+ if (!currentCart) {
+ currentCart = await loadCart();
+ if (!currentCart) throw new Error("Could not create cart");
+ }
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { cart: updatedCart } = await sdk.store.cart.createLineItem(currentCart.id, {
+ variant_id: variantId,
+ quantity,
+ }, {
+ fields: "+items.*"
+ });
+ setCart(updatedCart);
+ } catch (err) {
+ setError(`Failed to add item to cart: ${err instanceof FetchError ? err.message : String(err)}`);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const updateItemQuantity = async (itemId: string, quantity: number) => {
+ if (!cart) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { cart: updatedCart } = await sdk.store.cart.updateLineItem(
+ cart.id,
+ itemId,
+ { quantity },
+ {
+ fields: "+items.*"
+ }
+ );
+ setCart(updatedCart);
+ } catch (err) {
+ setError(`Failed to update quantity: ${err instanceof FetchError ? err.message : String(err)}`);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const removeItem = async (itemId: string) => {
+ if (!cart) return;
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { parent: updatedCart } = await sdk.store.cart.deleteLineItem(cart.id, itemId, {
+ fields: "+items.*"
+ });
+ setCart(updatedCart!);
+ } catch (err) {
+ setError(`Failed to remove item: ${err instanceof FetchError ? err.message : String(err)}`);
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const refreshCart = async () => {
+ if (!cart) return;
+
+ try {
+ const { cart: updatedCart } = await sdk.store.cart.retrieve(cart.id, {
+ fields: "+items.*"
+ });
+ setCart(updatedCart);
+ } catch (err) {
+ setError(`Failed to refresh cart: ${err instanceof FetchError ? err.message : String(err)}`);
+ }
+ };
+
+ const clearCart = async () => {
+ setCart(null);
+ await AsyncStorage.removeItem(CART_STORAGE_KEY);
+ // Create a new cart
+ if (selectedRegion) {
+ const { cart: newCart } = await sdk.store.cart.create({
+ region_id: selectedRegion.id,
+ }, {
+ fields: "+items.*"
+ });
+ setCart(newCart);
+ await AsyncStorage.setItem(CART_STORAGE_KEY, newCart.id);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCart() {
+ const context = useContext(CartContext);
+ if (!context) {
+ throw new Error("useCart must be used within a CartProvider");
+ }
+ return context;
+}
+
diff --git a/react-native-expo/context/region-context.tsx b/react-native-expo/context/region-context.tsx
new file mode 100644
index 0000000..9c79104
--- /dev/null
+++ b/react-native-expo/context/region-context.tsx
@@ -0,0 +1,101 @@
+import { sdk } from "@/lib/sdk";
+import type { HttpTypes } from "@medusajs/types";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import React, { createContext, ReactNode, useContext, useEffect, useState } from "react";
+
+interface RegionContextType {
+ regions: HttpTypes.StoreRegion[];
+ selectedRegion: HttpTypes.StoreRegion | null;
+ selectedCountryCode: string | null;
+ setSelectedRegion: (region: HttpTypes.StoreRegion, countryCode: string) => void;
+ loading: boolean;
+ error: string | null;
+}
+
+const RegionContext = createContext(undefined);
+
+const REGION_STORAGE_KEY = "selected_region_id";
+const COUNTRY_STORAGE_KEY = "selected_country_code";
+
+export function RegionProvider({ children }: { children: ReactNode }) {
+ const [regions, setRegions] = useState([]);
+ const [selectedRegion, setSelectedRegionState] = useState(null);
+ const [selectedCountryCode, setSelectedCountryCode] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const loadRegions = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const { regions: fetchedRegions } = await sdk.store.region.list();
+ setRegions(fetchedRegions);
+
+ // Load saved region and country or use first region's first country
+ const savedRegionId = await AsyncStorage.getItem(REGION_STORAGE_KEY);
+ const savedCountryCode = await AsyncStorage.getItem(COUNTRY_STORAGE_KEY);
+
+ const regionToSelect = savedRegionId
+ ? fetchedRegions.find((r) => r.id === savedRegionId) || fetchedRegions[0]
+ : fetchedRegions[0];
+
+ if (regionToSelect) {
+ setSelectedRegionState(regionToSelect);
+ await AsyncStorage.setItem(REGION_STORAGE_KEY, regionToSelect.id);
+
+ // Set country code - use saved one if it exists in the region, otherwise use first country
+ const countryCodeToSelect = savedCountryCode &&
+ regionToSelect.countries?.some(c => (c.iso_2 || c.id) === savedCountryCode)
+ ? savedCountryCode
+ : regionToSelect.countries?.[0]?.iso_2 || regionToSelect.countries?.[0]?.id || null;
+
+ setSelectedCountryCode(countryCodeToSelect);
+ if (countryCodeToSelect) {
+ await AsyncStorage.setItem(COUNTRY_STORAGE_KEY, countryCodeToSelect);
+ }
+ }
+ } catch (err) {
+ console.error("Failed to load regions:", err);
+ setError("Failed to load regions. Please try again.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Load regions on mount
+ useEffect(() => {
+ loadRegions();
+ }, []);
+
+ const setSelectedRegion = async (region: HttpTypes.StoreRegion, countryCode: string) => {
+ setSelectedRegionState(region);
+ setSelectedCountryCode(countryCode);
+ await AsyncStorage.setItem(REGION_STORAGE_KEY, region.id);
+ await AsyncStorage.setItem(COUNTRY_STORAGE_KEY, countryCode);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useRegion() {
+ const context = useContext(RegionContext);
+ if (!context) {
+ throw new Error("useRegion must be used within a RegionProvider");
+ }
+ return context;
+}
+
diff --git a/react-native-expo/eslint.config.js b/react-native-expo/eslint.config.js
new file mode 100644
index 0000000..5025da6
--- /dev/null
+++ b/react-native-expo/eslint.config.js
@@ -0,0 +1,10 @@
+// https://docs.expo.dev/guides/using-eslint/
+const { defineConfig } = require('eslint/config');
+const expoConfig = require('eslint-config-expo/flat');
+
+module.exports = defineConfig([
+ expoConfig,
+ {
+ ignores: ['dist/*'],
+ },
+]);
diff --git a/react-native-expo/hooks/use-color-scheme.ts b/react-native-expo/hooks/use-color-scheme.ts
new file mode 100644
index 0000000..17e3c63
--- /dev/null
+++ b/react-native-expo/hooks/use-color-scheme.ts
@@ -0,0 +1 @@
+export { useColorScheme } from 'react-native';
diff --git a/react-native-expo/hooks/use-color-scheme.web.ts b/react-native-expo/hooks/use-color-scheme.web.ts
new file mode 100644
index 0000000..7eb1c1b
--- /dev/null
+++ b/react-native-expo/hooks/use-color-scheme.web.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from 'react';
+import { useColorScheme as useRNColorScheme } from 'react-native';
+
+/**
+ * To support static rendering, this value needs to be re-calculated on the client side for web
+ */
+export function useColorScheme() {
+ const [hasHydrated, setHasHydrated] = useState(false);
+
+ useEffect(() => {
+ setHasHydrated(true);
+ }, []);
+
+ const colorScheme = useRNColorScheme();
+
+ if (hasHydrated) {
+ return colorScheme;
+ }
+
+ return 'light';
+}
diff --git a/react-native-expo/hooks/use-theme-color.ts b/react-native-expo/hooks/use-theme-color.ts
new file mode 100644
index 0000000..0cbc3a6
--- /dev/null
+++ b/react-native-expo/hooks/use-theme-color.ts
@@ -0,0 +1,21 @@
+/**
+ * Learn more about light and dark modes:
+ * https://docs.expo.dev/guides/color-schemes/
+ */
+
+import { Colors } from '@/constants/theme';
+import { useColorScheme } from '@/hooks/use-color-scheme';
+
+export function useThemeColor(
+ props: { light?: string; dark?: string },
+ colorName: keyof typeof Colors.light & keyof typeof Colors.dark
+) {
+ const theme = useColorScheme() ?? 'light';
+ const colorFromProps = props[theme];
+
+ if (colorFromProps) {
+ return colorFromProps;
+ } else {
+ return Colors[theme][colorName];
+ }
+}
diff --git a/react-native-expo/lib/format-price.ts b/react-native-expo/lib/format-price.ts
new file mode 100644
index 0000000..def33d2
--- /dev/null
+++ b/react-native-expo/lib/format-price.ts
@@ -0,0 +1,19 @@
+/**
+ * Format a price amount with currency code
+ * Note: Medusa stores prices in major units (e.g., dollars, euros)
+ * so no conversion is needed
+ */
+export function formatPrice(
+ amount: number | undefined,
+ currencyCode: string | undefined
+): string {
+ if (amount === undefined || !currencyCode) {
+ return 'N/A';
+ }
+
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currencyCode.toUpperCase(),
+ }).format(amount);
+}
+
diff --git a/react-native-expo/lib/inventory.ts b/react-native-expo/lib/inventory.ts
new file mode 100644
index 0000000..a1a84f0
--- /dev/null
+++ b/react-native-expo/lib/inventory.ts
@@ -0,0 +1,18 @@
+import type { HttpTypes } from "@medusajs/types";
+
+/**
+ * Check if a product variant is in stock
+ * A variant is in stock if:
+ * - manage_inventory is false (inventory tracking disabled), OR
+ * - inventory_quantity is greater than 0
+ */
+export function isVariantInStock(
+ variant: HttpTypes.StoreProductVariant | undefined | null
+): boolean {
+ if (!variant) {
+ return false;
+ }
+
+ return variant.manage_inventory === false || (variant.inventory_quantity || 0) > 0;
+}
+
diff --git a/react-native-expo/lib/payment-providers.ts b/react-native-expo/lib/payment-providers.ts
new file mode 100644
index 0000000..374d238
--- /dev/null
+++ b/react-native-expo/lib/payment-providers.ts
@@ -0,0 +1,27 @@
+/**
+ * Information about a payment provider for display purposes
+ */
+export interface PaymentProviderInfo {
+ icon: string;
+ title: string;
+}
+
+/**
+ * Get display information for a payment provider based on its ID
+ * Returns an icon name and formatted title for the payment provider
+ */
+export function getPaymentProviderInfo(providerId: string): PaymentProviderInfo {
+ switch (providerId) {
+ case 'pp_system_default':
+ return {
+ icon: 'creditcard',
+ title: 'Manual Payment',
+ };
+ default:
+ return {
+ icon: 'creditcard',
+ title: providerId.replace('pp_', '').replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
+ };
+ }
+}
+
diff --git a/react-native-expo/lib/sdk.ts b/react-native-expo/lib/sdk.ts
new file mode 100644
index 0000000..9df012f
--- /dev/null
+++ b/react-native-expo/lib/sdk.ts
@@ -0,0 +1,25 @@
+import Medusa from "@medusajs/js-sdk";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import Constants from "expo-constants";
+
+const MEDUSA_BACKEND_URL =
+ Constants.expoConfig?.extra?.EXPO_PUBLIC_MEDUSA_URL ||
+ process.env.EXPO_PUBLIC_MEDUSA_URL ||
+ "http://localhost:9000";
+
+const MEDUSA_PUBLISHABLE_API_KEY =
+ Constants.expoConfig?.extra?.EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY ||
+ process.env.EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY ||
+ "";
+
+export const sdk = new Medusa({
+ baseUrl: MEDUSA_BACKEND_URL,
+ debug: __DEV__,
+ auth: {
+ type: "jwt",
+ jwtTokenStorageMethod: "custom",
+ storage: AsyncStorage,
+ },
+ publishableKey: MEDUSA_PUBLISHABLE_API_KEY,
+});
+
diff --git a/react-native-expo/package.json b/react-native-expo/package.json
new file mode 100644
index 0000000..af67081
--- /dev/null
+++ b/react-native-expo/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "react-native-expo",
+ "main": "expo-router/entry",
+ "version": "1.0.0",
+ "scripts": {
+ "start": "expo start",
+ "reset-project": "node ./scripts/reset-project.js",
+ "android": "expo start --android",
+ "ios": "expo start --ios",
+ "web": "expo start --web",
+ "lint": "expo lint"
+ },
+ "dependencies": {
+ "@expo/vector-icons": "^15.0.3",
+ "@medusajs/js-sdk": "^2.11.3",
+ "@medusajs/types": "^2.11.3",
+ "@react-native-async-storage/async-storage": "2.2.0",
+ "@react-native-picker/picker": "2.11.1",
+ "@react-navigation/bottom-tabs": "^7.4.0",
+ "@react-navigation/drawer": "^7.7.2",
+ "@react-navigation/elements": "^2.6.3",
+ "@react-navigation/native": "^7.1.8",
+ "expo": "~54.0.22",
+ "expo-constants": "~18.0.10",
+ "expo-font": "~14.0.9",
+ "expo-haptics": "~15.0.7",
+ "expo-image": "~3.0.10",
+ "expo-linking": "~8.0.8",
+ "expo-router": "~6.0.13",
+ "expo-splash-screen": "~31.0.10",
+ "expo-status-bar": "~3.0.8",
+ "expo-symbols": "~1.0.7",
+ "expo-system-ui": "~6.0.8",
+ "expo-web-browser": "~15.0.9",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "react-native": "0.81.5",
+ "react-native-gesture-handler": "~2.28.0",
+ "react-native-reanimated": "~4.1.1",
+ "react-native-safe-area-context": "~5.6.0",
+ "react-native-screens": "~4.16.0",
+ "react-native-web": "~0.21.0",
+ "react-native-worklets": "0.5.1"
+ },
+ "devDependencies": {
+ "@types/react": "~19.1.0",
+ "eslint": "^9.25.0",
+ "eslint-config-expo": "~10.0.0",
+ "typescript": "~5.9.2"
+ },
+ "private": true
+}
diff --git a/react-native-expo/scripts/reset-project.js b/react-native-expo/scripts/reset-project.js
new file mode 100755
index 0000000..51dff15
--- /dev/null
+++ b/react-native-expo/scripts/reset-project.js
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+
+/**
+ * This script is used to reset the project to a blank state.
+ * It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
+ * You can remove the `reset-project` script from package.json and safely delete this file after running it.
+ */
+
+const fs = require("fs");
+const path = require("path");
+const readline = require("readline");
+
+const root = process.cwd();
+const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
+const exampleDir = "app-example";
+const newAppDir = "app";
+const exampleDirPath = path.join(root, exampleDir);
+
+const indexContent = `import { Text, View } from "react-native";
+
+export default function Index() {
+ return (
+
+ Edit app/index.tsx to edit this screen.
+
+ );
+}
+`;
+
+const layoutContent = `import { Stack } from "expo-router";
+
+export default function RootLayout() {
+ return ;
+}
+`;
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+});
+
+const moveDirectories = async (userInput) => {
+ try {
+ if (userInput === "y") {
+ // Create the app-example directory
+ await fs.promises.mkdir(exampleDirPath, { recursive: true });
+ console.log(`š /${exampleDir} directory created.`);
+ }
+
+ // Move old directories to new app-example directory or delete them
+ for (const dir of oldDirs) {
+ const oldDirPath = path.join(root, dir);
+ if (fs.existsSync(oldDirPath)) {
+ if (userInput === "y") {
+ const newDirPath = path.join(root, exampleDir, dir);
+ await fs.promises.rename(oldDirPath, newDirPath);
+ console.log(`ā”ļø /${dir} moved to /${exampleDir}/${dir}.`);
+ } else {
+ await fs.promises.rm(oldDirPath, { recursive: true, force: true });
+ console.log(`ā /${dir} deleted.`);
+ }
+ } else {
+ console.log(`ā”ļø /${dir} does not exist, skipping.`);
+ }
+ }
+
+ // Create new /app directory
+ const newAppDirPath = path.join(root, newAppDir);
+ await fs.promises.mkdir(newAppDirPath, { recursive: true });
+ console.log("\nš New /app directory created.");
+
+ // Create index.tsx
+ const indexPath = path.join(newAppDirPath, "index.tsx");
+ await fs.promises.writeFile(indexPath, indexContent);
+ console.log("š app/index.tsx created.");
+
+ // Create _layout.tsx
+ const layoutPath = path.join(newAppDirPath, "_layout.tsx");
+ await fs.promises.writeFile(layoutPath, layoutContent);
+ console.log("š app/_layout.tsx created.");
+
+ console.log("\nā
Project reset complete. Next steps:");
+ console.log(
+ `1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
+ userInput === "y"
+ ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
+ : ""
+ }`
+ );
+ } catch (error) {
+ console.error(`ā Error during script execution: ${error.message}`);
+ }
+};
+
+rl.question(
+ "Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
+ (answer) => {
+ const userInput = answer.trim().toLowerCase() || "y";
+ if (userInput === "y" || userInput === "n") {
+ moveDirectories(userInput).finally(() => rl.close());
+ } else {
+ console.log("ā Invalid input. Please enter 'Y' or 'N'.");
+ rl.close();
+ }
+ }
+);
diff --git a/react-native-expo/tsconfig.json b/react-native-expo/tsconfig.json
new file mode 100644
index 0000000..909e901
--- /dev/null
+++ b/react-native-expo/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ }
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".expo/types/**/*.ts",
+ "expo-env.d.ts"
+ ]
+}