diff --git a/www/apps/resources/app/storefront-development/guides/react-native-expo/page.mdx b/www/apps/resources/app/storefront-development/guides/react-native-expo/page.mdx new file mode 100644 index 0000000000..4a8caf5b6d --- /dev/null +++ b/www/apps/resources/app/storefront-development/guides/react-native-expo/page.mdx @@ -0,0 +1,5389 @@ +--- +tags: + - storefront + - cart + - product + - payment + - fulfillment + - order + - region + - example +products: + - cart + - product + - payment + - fulfillment + - order + - region +--- + +import { Card, Prerequisites, Details, CodeTabs, CodeTab } from "docs-ui" +import { Github } from "@medusajs/icons" + +export const metadata = { + title: `Implement Mobile App with React Native, Expo, and Medusa`, +} + +# {metadata.title} + +In this tutorial, you'll learn how to create a mobile app that connects to your Medusa backend with React Native and Expo. You can then publish your app to the Apple App Store and Google Play Store. + +When you install a Medusa application, you get a fully-fledged commerce server and an admin dashboard to manage the commerce store's data. Medusa's [architecture](!docs!/learn/introduction/architecture) is flexible and customizable, allowing you to build your own custom storefronts using any technology you prefer. + +[React Native](https://reactnative.dev/) allows developers to build native apps using React. [Expo](https://expo.dev/) is a platform for universal React applications that makes it easy to build, deploy, and iterate on native iOS and Android apps. + +## Summary + +By following this tutorial, you'll learn how to: + +1. Set up a Medusa application. +2. Create an app with React Native and Expo. +3. Connect the Expo app to the Medusa backend. +4. Implement essential ecommerce features such as product listing, cart management, and checkout. + +You can follow this guide whether you're new to Medusa or an advanced Medusa developer. However, this tutorial assumes you have a basic understanding of React Native and JavaScript. + +![Screenshot of home screen of the Expo app](https://res.cloudinary.com/dza7lstvk/image/upload/v1762333811/Medusa%20Resources/CleanShot_2025-11-05_at_11.09.27_lxl2tw.png) + + + +--- + +## Step 1: Install a Medusa Application + + + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. Then, when you're asked whether you want to install the Next.js Starter Storefront, choose `N` for no. You'll create the Expo app instead. + +Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. Once the installation finishes successfully, the Medusa Admin dashboard will open in your browser at `http://localhost:9000/app` with a form to create a new user. Enter the user's credentials and submit the form. + +Then, you can log in with the new user and explore the dashboard. + + + +Check out the [troubleshooting guides](../../../troubleshooting/create-medusa-app-errors/page.mdx) for help. + + + +--- + +## Step 2: Create an Expo App + +In this step, you'll create a new Expo app with React Native. + +In a directory separate from your Medusa application, run the following command to create a new Expo project: + +```bash +npx create-expo-app@latest +``` + +When prompted, enter a name for your project and wait for the installation to complete. + +Once the installation is complete, navigate to the project directory: + +```bash +cd your-project-name +``` + +The rest of this tutorial assumes you're in the Expo app's root directory. + +--- + +## Step 3: Install Dependencies + +In this step, you'll install dependencies that you'll use while building the Expo app. + +Run the following command to install the required packages: + +```bash npm2yarn +npm install @medusajs/js-sdk @medusajs/types @react-native-async-storage/async-storage @react-native-picker/picker @react-navigation/drawer +``` + +You install the following packages: + +- `@medusajs/js-sdk`: Medusa's [JS SDK](../../../js-sdk/page.mdx) to interact with the Medusa backend. +- `@medusajs/types`: TypeScript types for Medusa, which are useful when working with Medusa's JS SDK. +- `@react-native-async-storage/async-storage`: An asynchronous key-value storage system for React Native, used to store data like cart ID. +- `@react-native-picker/picker`: A cross-platform picker component for React Native, used for selecting options like country. +- `@react-navigation/drawer`: A navigation library for React Native that provides a drawer-based navigation experience. + +You'll use these packages in the upcoming steps to build the app's functionality. + +--- + +## Step 4: Update App Theme (Optional) + +In this step, you'll update the theme for the app to ensure a consistent look and feel across all screens. This is optional, and you can customize the theme based on your brand identity instead. + +In your React Native project, you should have the following directories: + +- `constants`: This directory will contain constant values used throughout the app. It should already have a `theme.ts` file. +- `hooks`: This directory will contain custom React hooks. It should already have hooks for color schemes: `use-color-scheme.ts`, `use-color-scheme.web.ts`, and `use-theme-color.ts`. + +To update the app's theme, replace the content of the `constants/theme.ts` file with the following: + +```ts title="constants/theme.ts" +/** + * 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", + }, +}) +``` + +--- + +## Step 5: Initialize Medusa JS SDK + +In this step, you'll set up Medusa's [JS SDK](../../../js-sdk/page.mdx) in your Expo app. You'll use the SDK to interact with the Medusa backend. + +To initialize the SDK, create the file `lib/sdk.ts` in your Expo project with the following content: + +```ts title="lib/sdk.ts" +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, +}) +``` + +You configure the SDK with the: + +- Medusa backend URL, which is read from the environment variable `EXPO_PUBLIC_MEDUSA_URL`. If the variable is not set, it defaults to `http://localhost:9000`. +- [Publishable API key](../../publishable-api-keys/page.mdx), which is required to retrieve products in the associated sales channel. It is read from the environment variable `EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY`. + +You also customize the JWT token storage method of the JS SDK to use `AsyncStorage`, which is suitable for React Native apps. + + + +Refer to the [JS SDK documentation](../../../js-sdk/page.mdx) for more configuration options. + + + +### Set Environment Variables + +Next, you'll set the environment variables in your Expo project. Before you do that, you need to retrieve the Medusa backend URL and the Publishable API key. + +The Medusa backend URL is the URL where your Medusa server is running. If you're running the Medusa server locally, it should be the IP address of your machine at the port `9000`. For example, `http://192.168.1.100:9000`. + +You can find your machine's local IP address by running the following command in your terminal: + + + + +```bash +ipconfig getifaddr en0 +``` + + + + +```bash +ip addr +``` + + + + +```bash +ipconfig +``` + + + + +Next, to get the Publishable API key, start the Medusa application by running the following command in its directory: + +```bash npm2yarn badgeLabel="Medusa Application" badgeColor="green" +npm run dev +``` + +Then: + +1. Open the Medusa Admin dashboard at `http://localhost:9000/app` and log in. +2. Go to **Settings** > **Publishable API Keys**. +3. Either copy the key of an existing Publishable API key, or create a new one by clicking on the **Create Publishable API Key** button. + +Once you have the Medusa backend URL and the Publishable API key, create the file `.env` in the root directory of your Expo project with the following content: + +```shell title=".env" +EXPO_PUBLIC_MEDUSA_URL=http://192.168.1.100:9000 +EXPO_PUBLIC_MEDUSA_PUBLISHABLE_API_KEY=your_publishable_api_key +``` + +Make sure to replace the values with your actual Medusa backend URL and Publishable API key. + +### Optional: Update CORS in Medusa + +If you plan to test the Expo app in a web browser, you'll need to update the CORS settings in the Medusa backend to allow requests from the Expo development server. + +In your Medusa application's directory, add the Expo development server URL to the `STORE_CORS` and `AUTH_CORS` environment variables in the `.env` file: + +```env +STORE_CORS=previous_values...,http://localhost:8081 +AUTH_CORS=previous_values...,http://localhost:8081 +``` + +Append `,http://localhost:8081` to the existing values of `STORE_CORS` and `AUTH_CORS`, which is the default URL for the Expo development server. + +Make sure to restart the Medusa server after making changes to the `.env` file. + +--- + +## Step 6: Create Region Selector + +In this step, you'll create a region selector component that allows users to select their country and currency. This is important for providing a localized shopping experience. + +To implement this, you'll: + +1. Create a region context to manage the selected region state. +2. Create a region selector component that allows users to choose their country and currency from a list. +3. Update the app's navigation to add a drawer menu for the region selector. + +### Create Region Context + +The region context will manage the selected region state and provide it to child components. You'll create a context, a provider component, and a custom hook to access the context. + +To create the region context, create the file `context/region-context.tsx` with the following content: + +export const regionContextHighlights = [ + ["7", "regions", "Available regions from the Medusa backend."], + ["8", "selectedRegion", "The currently selected region."], + ["9", "selectedCountryCode", "The selected country code within the selected region."], + ["10", "setSelectedRegion", "Update the selected region and country code."], + ["11", "loading", "Whether the regions are being loaded."], + ["12", "error", "An error message if loading the regions fails."] +] + +```tsx title="context/region-context.tsx" highlights={regionContextHighlights} +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) + + // TODO load and select regions +} + +// TODO add useRegion hook +``` + +You define the `RegionContext` with the following properties that child components can use: + +1. `regions`: An array of available regions fetched from the Medusa backend. +2. `selectedRegion`: The currently selected region. +3. `selectedCountryCode`: The country code of the selected country within the selected region. This is useful as regions can have multiple countries, and you'll allow customers to select their country. +4. `setSelectedRegion`: A function to update the selected region and country code. +5. `loading`: A boolean indicating whether the regions are being loaded. +6. `error`: An error message if loading the regions fails. + +You also define the `RegionProvider` component that will wrap the app and provide the region context to its children. + +Next, you'll implement the logic to load the regions from the Medusa backend, and manage the selected region state. + +Replace the `// TODO load and select regions` comment with the following: + +export const regionLoadingHighlights = [ + ["1", "loadRegions", "Load regions from the Medusa backend."], + ["41", "useEffect", "Load regions when the component mounts."], + ["45", "setSelectedRegion", "Update the selected region and country code."] +] + +```tsx title="context/region-context.tsx" highlights={regionLoadingHighlights} +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} + +) +``` + +You define the `loadRegions` function that fetches the regions from the Medusa backend using the JS SDK. It also loads any previously selected region and country from `AsyncStorage`, or defaults to the first region and its first country. + +Then, you run the `loadRegions` function when the component mounts using the `useEffect` hook. + +You also define the `setSelectedRegion` function that updates the selected region and country, and saves them to `AsyncStorage`. + +Then, you provide the context values to child components using the `RegionContext.Provider`. + +Finally, you'll create a custom hook in the same file to access the region context easily. Replace the `// TODO add useRegion hook` comment with the following: + +```tsx title="context/region-context.tsx" +export function useRegion() { + const context = useContext(RegionContext) + if (!context) { + throw new Error("useRegion must be used within a RegionProvider") + } + return context +} +``` + +You define the `useRegion` hook that retrieves the context value using `useContext`. It also throws an error if the hook is used by a component that is not wrapped in the `RegionProvider`. + +### Create Region Selector Component + +Next, you'll create the region selector component that allows users to choose their country and currency. + +To create the region selector component, create the file `components/region-selector.tsx` with the following content: + +```tsx title="components/region-selector.tsx" collapsibleLines="53-87" expandButtonLabel="Show Styles" +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]) + + // TODO handle country selection +} + +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, + }, +}) +``` + +You define the `RegionSelector` component that receives an optional `onRegionChange` prop. This prop is a callback function that will be called when the user selects a new region. + +In the component, you: + +1. Use the `useRegion` hook to access all regions, the selected region and country, and the function to set the selected region. +2. Use the `useColorScheme` hook to get the current color scheme and apply the appropriate colors. +3. Create a memoized list of countries by flattening the countries from all regions. Each country includes its associated region and currency code. The list is sorted alphabetically by country name. + +Next, you'll implement the logic to handle country selection and render the list of countries. Replace the `// TODO handle country selection` comment with the following: + +```tsx title="components/region-selector.tsx" +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 && ( + + )} + + ) + }) + )} + +) +``` + +You define the `handleSelectCountry` function that is called when a user selects a country. It updates the selected region and country using the `setSelectedRegion` function from the context, and calls the `onRegionChange` callback if provided. + +You also define the `isCountrySelected` function that checks if a given country is currently selected. + +Finally, you render the countries in a scrollable view. For each country, you create a touchable item that displays the country name and currency code. The selected country is highlighted, and a checkmark is shown next to it. + +### Add Drawer Content Component + +Next, you'll create a custom drawer content component that includes the region selector in the app's navigation drawer. + +Create the file `components/drawer-content.tsx` with the following content: + +```tsx title="components/drawer-content.tsx" +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, + }, +}) +``` + +The `DrawerContent` component receives the drawer navigation props. You render the `RegionSelector` component and pass an `onRegionChange` callback that closes the drawer when a region is selected. + +### Add Drawer Navigation + +Finally, you'll update the app's navigation to wrap the main screens in a drawer navigator that uses the custom drawer content. + +If you have an `app/(tabs)` directory for tab navigation, remove it for now. You'll add it later inside the drawer navigator. + +Then, create the directory `app/(drawer)` which will contain the drawer navigation setup. + +Next, create the file `app/(drawer)/_layout.tsx` with the following content: + +```tsx title="app/(drawer)/_layout.tsx" +import { Drawer } from "expo-router/drawer" + +import { DrawerContent } from "@/components/drawer-content" + +export default function DrawerLayout() { + return ( + } + screenOptions={{ + headerShown: false, + drawerPosition: "left", + }} + > + {/* TODO add tabs screens */} + + ) +} +``` + +The `DrawerLayout` component sets up the drawer navigator using Expo Router's `Drawer` component. + +The custom drawer content is shown on the left side of the screen, and the header is hidden. + +Later, you'll add the tab screens inside the drawer navigator. + +Next, replace the content of the file `app/_layout.tsx` with the following: + +```tsx title="app/_layout.tsx" +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 { RegionProvider } from "@/context/region-context" + +export default function RootLayout() { + const colorScheme = useColorScheme() + + return ( + + + + + + {/* TODO: Add checkout and order confirmation screens */} + + + + + + ) +} +``` + +The key changes are: + +1. Wrap the app in the `RegionProvider` to provide the region context to all components. +2. Set the initial screen to the `(drawer)` layout, which contains the drawer navigator. + +Later, you'll add the checkout and order confirmation screens to the stack navigator. + +You'll test the region selector after implementing the main screens of the app in the next steps. + +--- + +## Step 7: Create Cart Context + +In this step, you'll create a cart context to manage the customer's cart state throughout the app. The cart context will allow you to create, manage, and complete the cart. + +### Create Cart Context + +Create the file `context/cart-context.tsx` with the following content: + +export const cartContextHighlights = [ + ["9", "cart", "The current cart object."], + ["10", "addToCart", "Add an item to the cart."], + ["11", "updateItemQuantity", "Update the quantity of an item in the cart."], + ["12", "removeItem", "Remove an item from the cart."], + ["13", "refreshCart", "Refresh the cart data from the backend."], + ["14", "clearCart", "Clear the cart, useful after checkout."], + ["15", "loading", "Indicate whether a cart operation is in progress."], + ["16", "error", "An error message if a cart operation fails."] +] + +```tsx title="context/cart-context.tsx" highlights={cartContextHighlights} +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() + + // TODO load cart +} + +// TODO add useCart hook +``` + +You define the `CartContext` with the following properties that child components can use: + +- `cart`: The current cart object. +- `addToCart`: A function to add an item to the cart. +- `updateItemQuantity`: A function to update the quantity of an item in the cart. +- `removeItem`: A function to remove an item from the cart. +- `refreshCart`: A function to refresh the cart data from the backend. +- `clearCart`: A function to clear the cart, which is useful after checkout. +- `loading`: A boolean indicating whether a cart operation is in progress. +- `error`: An error message if a cart operation fails. + +You also define the `CartProvider` component that will wrap the app and provide the cart context to its children. + +Next, you'll implement the logic to load the cart from the Medusa backend. Replace the `// TODO load cart` comment with the following: + +```tsx title="context/cart-context.tsx" +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]) + +// TODO handle region update +``` + +The `loadCart` function creates or retrieves the saved cart from the Medusa backend using the JS SDK. This function runs when the component mounts using the `useEffect` hook. + +Next, you'll implement the logic to handle region updates, as the cart's region must match the selected region. + +Replace the `// TODO handle region update` comment with the following: + +```tsx title="context/cart-context.tsx" +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() +}, [selectedRegion]) + +// TODO implement cart operations +``` + +You add an effect that runs whenever the selected region changes. If the cart's region doesn't match the selected region, it updates the cart's region using the JS SDK. + +Next, you'll implement the cart operations that you defined in the context type. Replace the `// TODO implement cart operations` comment with the following: + +export const cartOperationsHighlights = [ + ["1", "addToCart", "Add an item to the cart."], + ["28", "updateItemQuantity", "Update the quantity of an item in the cart."], + ["52", "removeItem", "Remove an item from the cart."], + ["71", "refreshCart", "Refresh the cart data from the backend."], + ["84", "clearCart", "Clear the cart, useful after checkout."] +] + +```tsx title="context/cart-context.tsx" highlights={cartOperationsHighlights} +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} + +) +``` + +You define the following cart operation functions: + +- `addToCart`: Adds an item to the cart by creating a line item. +- `updateItemQuantity`: Updates the quantity of an item in the cart. +- `removeItem`: Removes an item from the cart. +- `refreshCart`: Refreshes the cart data from the backend. +- `clearCart`: Clears the cart and creates a new one. + +You also add a `return` statement providing the context values to child components using the `CartContext.Provider`. + +Finally, you'll create a custom hook in the same file to access the cart context easily. Replace the `// TODO add useCart hook` comment with the following: + +```tsx title="context/cart-context.tsx" +export function useCart() { + const context = useContext(CartContext) + if (!context) { + throw new Error("useCart must be used within a CartProvider") + } + return context +} +``` + +The `useCart` hook retrieves the context value using `useContext`. It throws an error if used outside of a `CartProvider`. + +### Wrap App in Cart Provider + +Next, you'll wrap the app in the `CartProvider` to provide the cart context to all components. + +In `app/_layout.tsx`, add the following import at the top of the file: + +```tsx title="app/_layout.tsx" +import { CartProvider } from "@/context/cart-context" +``` + +Then, in the `RootLayout`'s `return` statement, add the `CartProvider` as a child of the `RegionProvider` and a parent of the `Stack` component: + +```tsx title="app/_layout.tsx" highlights={[["5"]]} +return ( + + + + + + + + + + + + +) +``` + +Child components can now access the cart context using the `useCart` hook. + +--- + +## Step 8: Create Home Screen + +In this step, you'll create the home screen of the app. It will display a hero image with a list of products retrieved from the Medusa backend. + +To implement the home screen, you'll create: + +1. Necessary utilities and components for the home screen. +2. The home screen and navigation setup. + +### Add Price Formatting Utility + +First, you'll create a utility function to format prices based on the selected region's currency. + +Create the file `lib/format-price.ts` with the following content: + +```ts title="lib/format-price.ts" +/** + * 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) +} +``` + +The `formatPrice` function takes an amount and a currency code as parameters. It formats the amount using the `Intl.NumberFormat` API to display it in the appropriate currency format. + +### Add Loading Component + +Next, you'll create a loading component that can be reused across the app to indicate loading states. + +Create the file `components/loading.tsx` with the following content: + +```tsx title="components/loading.tsx" +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, + }, +}) +``` + +The `Loading` component displays a spinner and an optional message. + +### Add Product Card Component + +Next, you'll create a product card component to display individual products on the home screen. + +Create the file `components/product-card.tsx` with the following content: + +```tsx title="components/product-card.tsx" collapsibleLines="58-93" expandButtonLabel="Show Styles" +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, + }, +}) +``` + +The `ProductCard` component receives a product as a prop. + +It displays the product's thumbnail image, title, and price formatted using the `formatPrice` utility. When the card is pressed, it navigates to the product detail screen that you'll add later. + +### Add Home Screen + +Next, you'll add the home screen that displays a hero image and a list of products. + +Your app will have a tab-based navigation structure, with a tab for the home screen, and another you'll add later for the cart. + +So, to set up the tab navigation within the drawer navigator, create the directory `app/(drawer)/(tabs)`. + +Then, to create the home screen, create the file `app/(drawer)/(tabs)/(home)/index.tsx` with the following content: + +export const homeScreenHighlights = [ + ["22", "fetchProducts", "Fetch products from the Medusa backend based on the selected region."], + ["42", "useEffect", "Fetch products when the selected region changes."], + ["48", "onRefresh", "Handle pull-to-refresh to reload products."] +] + +```tsx title="app/(drawer)/(tabs)/(home)/index.tsx" collapsibleLines="56-98" expandButtonLabel="Show Styles" highlights={homeScreenHighlights} +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() + } + + // TODO add return statement +} + +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, + }, +}) +``` + +The home screen component fetches products from the Medusa backend based on the selected region. It manages loading, refreshing, and error states. + +Next, you'll add the return statement to render the home screen UI. Replace the `// TODO add return statement` comment with the following: + +```tsx title="app/(drawer)/(tabs)/(home)/index.tsx" +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 + + + } + /> + +) +``` + +You handle three main states in the return statement: + +1. Loading State: If the products are still loading, you display the `Loading` component with a message. +2. Error State: If there was an error fetching the products, you display an error message. +3. Success State: If the products are successfully fetched, you render a `FlatList` to display the products in a grid layout. The list includes a header with a hero image and a section title. + +You also configure the `FlatList` to support pull-to-refresh functionality and handle empty states. + +Next, you'll add stack navigation for the home screen, which will later allow you to add a product detail screen. + +Create the file `app/(drawer)/(tabs)/(home)/_layout.tsx` with the following content: + +```tsx title="app/(drawer)/(tabs)/(home)/_layout.tsx" +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" }} + > + + + ), + }} + /> + {/* TODO add product details screen */} + + ) +} +``` + +The `HomeStackLayout` component sets up a stack navigator for the home screen. It adds a header with a title and a menu button to open the drawer navigator. + +Next, you'll add the tab navigation layout to include the home screen tab. + +Create the file `app/(drawer)/(tabs)/_layout.tsx` with the following content: + +```tsx title="app/(drawer)/(tabs)/_layout.tsx" +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" + +export default function TabLayout() { + const colorScheme = useColorScheme() + + return ( + + + , + headerShown: false, // Let the home stack manage its own headers + }} + /> + {/* TODO add cart tab */} + + ) +} +``` + +The `TabLayout` component sets up a tab navigator using Expo Router's `Tabs` component. It adds a tab for the home screen with an icon and title. + +An `index` tab is also specified to redirect to the home screen, ensuring it's always the default tab when the app opens. + +So, create the file `app/(drawer)/(tabs)/index.tsx` with the following content: + +```tsx title="app/(drawer)/(tabs)/index.tsx" +import { Redirect } from "expo-router" +import React from "react" + +const MainScreen = () => { + return +} + +export default MainScreen +``` + +The `MainScreen` component redirects to the home screen tab when accessed. + + + +If you get a type error regarding the `href` prop in the `Redirect` component, wait until the next time you run the project. The Expo Router types should update and the error should go away. + + + +### Add Tabs to Drawer Navigator + +Finally, you'll add the tab navigator to the drawer navigator. + +In `app/(drawer)/_layout.tsx`, update the `return` statement to replace the `TODO` comment with the tabs screen: + +```tsx title="app/(drawer)/_layout.tsx" +return ( + } + screenOptions={{ + headerShown: false, + drawerPosition: "left", + }} + > + + +) +``` + +The drawer navigator now includes the tab navigator as its main screen. + +### Test the Home Screen + +You can now test the home screen of your app. + +There are different ways to run the React Native app, which you can learn about in [Expo's documentation](https://docs.expo.dev/get-started/set-up-your-environment/?platform=ios&device=physical&mode=expo-go). The recommended way is to install the [Expo Go](https://expo.dev/go) app on your mobile device and run the app on it. + +Before you run your app, start the Medusa backend by running the following command in the Medusa project directory: + +```bash badgeLabel="Medusa Application" badgeColor="green" +npm run dev +``` + +Then, run the following command in your Expo project directory to start the Expo server: + +```bash badgeLabel="Expo Application" badgeColor="blue" +npm run start +``` + +In the terminal, you should see a QR code. Scan it using your device to open the app in Expo Go. + +When the app opens, you'll see a home screen displaying a hero image and a list of products fetched from the Medusa application. + + + +Clicking on a product won't do anything yet, as you haven't implemented the product detail screen. You'll add it in the next step. + + + +![Home screen showing the hero image and list of products](https://res.cloudinary.com/dza7lstvk/image/upload/v1762344360/Medusa%20Resources/CleanShot_2025-11-05_at_13.54.13_2x_efopgu.png) + +If you click the menu icon in the top-left corner, the drawer navigator will open, allowing you to select the region using the region selector you implemented earlier. + +![Drawer navigator showing the region selector](https://res.cloudinary.com/dza7lstvk/image/upload/v1762344361/Medusa%20Resources/CleanShot_2025-11-05_at_13.54.38_2x_r0tb6i.png) + +--- + +## Step 9: Create Product Detail Screen + +In this step, you'll create the product detail screen that displays detailed information about a selected product. + +You'll create the necessary components and utilities first, then implement the product detail screen and navigation. + +### Add Button Component + +First, you'll create a button component that can be reused across the app. + +Create the file `components/ui/button.tsx` with the following content: + +```tsx title="components/ui/button.tsx" collapsibleLines="63-84" expandButtonLabel="Show Styles" +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, + }, +}) +``` + +The `Button` component accepts props for the title, variant (primary or secondary), loading state, and other touchable opacity props. + +### Add Product Image Slider Component + +Next, you'll create a product image slider component to display multiple images of a product. + +Create the file `components/product-image-slider.tsx` with the following content: + +```tsx title="components/product-image-slider.tsx" collapsibleLines="77-103" expandButtonLabel="Show Styles" +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, + }, +}) +``` + +The `ProductImageSlider` component receives an array of image URLs as a prop and displays them in a horizontally scrollable `FlatList`. It also includes pagination dots to indicate the current image being viewed. + +### Add Product Skeleton Component + +Next, you'll create a skeleton component to display while the product details are loading. This improves the user experience by providing a visual placeholder. + +Create the file `components/product-skeleton.tsx` with the following content: + +```tsx title="components/product-skeleton.tsx" collapsibleLines="160-256" expandButtonLabel="Show Styles" +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, + }, +}) +``` + +The `ProductSkeleton` component uses animated views to create a shimmering effect for various sections of the product detail screen, including the image, title, and buttons. + +### Add Toast Component + +Next, you'll create a toast component to display brief messages to the user, such as confirming that an item has been added to the cart. + +Create the file `components/ui/toast.tsx` with the following content: + +```tsx title="components/ui/toast.tsx" collapsibleLines="91-116" expandButtonLabel="Show Styles" +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", + }, +}) +``` + +The `Toast` component accepts props for the message, visibility, hide callback, duration, and type. It uses animated values to create fade-in and slide-up effects when the toast appears and disappears. + +### Add Inventory Utility + +Next, you'll create a utility function to check if a product variant is in stock. + +Create the file `lib/inventory.ts` with the following content: + +```ts title="lib/inventory.ts" +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 +} +``` + +The `isVariantInStock` function checks if a product variant is in stock. A variant is considered in stock if: + +- `manage_inventory` is false (inventory tracking is disabled), OR +- `inventory_quantity` is greater than 0. + +### Implement Product Detail Screen + +You can now implement the product detail screen using the components and utilities you've created. + +Create the file `app/(drawer)/(tabs)/(home)/product/[id].tsx` with the following content: + +export const productDetailHighlights = [ + ["18", "id", "The product ID from the URL parameters."], + ["21", "addToCart", "Function to add items to the cart from the cart context."], + ["22", "selectedRegion", "The currently selected region from the region context."], + ["24", "product", "The product details fetched from the backend."], + ["25", "selectedOptions", "The currently selected product options, such as size or color."], + ["26", "quantity", "The quantity of the product to add to the cart."], + ["27", "loading", "Loading state for fetching product details."], + ["28", "addingToCart", "Loading state for adding the product to the cart."], + ["29", "error", "Error message if fetching product details fails."], +] + +```tsx title="app/(drawer)/(tabs)/(home)/product/[id].tsx" collapsibleLines="37-143" expandButtonLabel="Show Styles" highlights={productDetailHighlights} +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 [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 navigation = useNavigation() + + // TODO fetch product +} + +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", + }, +}) +``` + +The `ProductDetailsScreen` includes the following variables: + +- `id`: The product ID from the URL parameters. +- `colorScheme` and `colors`: For theming based on the current color scheme. +- `addToCart`: Function to add items to the cart from the cart context. +- `selectedRegion`: The currently selected region from the region context. +- `product`: The product details fetched from the backend. +- `selectedOptions`: The currently selected product options, such as size or color. +- `quantity`: The quantity of the product to add to the cart. +- `loading`: Loading state for fetching product details. +- `addingToCart`: Loading state for adding the product to the cart. +- `error`: Error message if fetching product details fails. +- `toastVisible` and `toastMessage`: For displaying toast messages. + +Next, you'll implement the logic to fetch the product details based on the product ID. Replace the `// TODO fetch product` comment with the following: + +```tsx title="app/(drawer)/(tabs)/(home)/product/[id].tsx" +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]) + +// TODO select variant based on selected options +``` + +The `fetchProduct` function retrieves product details from the Medusa backend using the product ID. The region ID is passed as a query parameter to get prices specific to the selected region. + +The `selectedOptions` state is initialized with the option values of the product's first variant. + +Next, you'll implement the logic to select the appropriate product variant based on the currently selected options. Replace the `// TODO select variant based on selected options` comment with the following: + +export const productDetailVariantHighlights = [ + ["2", "selectedVariant", "The product variant that matches the currently selected options."], + ["20", "shouldShowOptions", "Whether to show the options UI."], + ["29", "images", "Array of image URLs for the product."], +] + +```tsx title="app/(drawer)/(tabs)/(home)/product/[id].tsx" highlights={productDetailVariantHighlights} +// 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]) + +// TODO handle add to cart +``` + +You define the following memoized values: + +- `selectedVariant`: The product variant that matches the currently selected options. +- `shouldShowOptions`: A boolean indicating whether to show the options UI. The options UI is hidden if there's a single option with a single value. +- `images`: An array of image URLs for the product, falling back to the thumbnail if no images are available. + +Next, you'll implement the logic to handle adding the selected product variant to the cart. Replace the `// TODO handle add to cart` comment with the following: + +```tsx title="app/(drawer)/(tabs)/(home)/product/[id].tsx" +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) + } +} + +// TODO render UI +``` + +The `handleAddToCart` function adds the selected variant to the cart using the `addToCart` function from the cart context. It displays appropriate toast messages based on the outcome. + +Finally, you'll implement the UI rendering for the product detail screen. Replace the `// TODO render UI` comment with the following: + +```tsx title="app/(drawer)/(tabs)/(home)/product/[id].tsx" +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)} + > + + + + + + +