9. Product details

Show a product, pick a variant, and add it to the cart.

On this page

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

tsxCopy
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 (
<TouchableOpacity
style={[
styles.button,
isPrimary ? { backgroundColor: primaryBgColor } : styles.secondaryButton,
isDisabled && styles.disabled,
style,
]}
disabled={isDisabled}
{...props}
>
{loading ? (
<ActivityIndicator color={isPrimary ? primaryTextColor : colors.tint} />
) : (
<Text
style={[
styles.text,
isPrimary ? { color: primaryTextColor } : { color: colors.tint },
]}
>
{title}
</Text>
)}
</TouchableOpacity>
)
}
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

tsxCopy
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<FlatList>(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 }) => (
<View style={styles.imageSlide}>
<Image
source={{ uri: item }}
style={[styles.image, { backgroundColor: colors.imagePlaceholder }]}
contentFit="cover"
/>
</View>
)
if (images.length === 0) {
return null
}
return (
<View style={styles.container}>
<FlatList
ref={imageListRef}
data={images}
renderItem={renderImageItem}
keyExtractor={(item, index) => `image-${index}`}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
/>
{images.length > 1 && (
<View style={styles.pagination}>
{images.map((_, index) => (
<View
key={index}
style={[
styles.paginationDot,
{
backgroundColor: currentImageIndex === index
? colors.tint
: colors.icon + "40",
},
]}
/>
))}
</View>
)}
</View>
)
}
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

tsxCopy
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 (
<View style={[styles.container, { backgroundColor: colors.background }]}>
{/* Main Image Skeleton with Pagination Dots */}
<View style={styles.imageContainer}>
<Animated.View
style={[
styles.imageSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
<View style={styles.pagination}>
{[1, 2, 3].map((i) => (
<Animated.View
key={i}
style={[
styles.paginationDot,
{ backgroundColor: skeletonColor, opacity },
]}
/>
))}
</View>
</View>
<View style={styles.content}>
{/* Title Skeleton */}
<Animated.View
style={[
styles.titleSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
{/* Description Skeleton - 3 lines */}
<View style={styles.descriptionContainer}>
<Animated.View
style={[
styles.descriptionLine,
{ backgroundColor: skeletonColor, opacity, width: "100%" },
]}
/>
<Animated.View
style={[
styles.descriptionLine,
{ backgroundColor: skeletonColor, opacity, width: "90%" },
]}
/>
<Animated.View
style={[
styles.descriptionLine,
{ backgroundColor: skeletonColor, opacity, width: "70%" },
]}
/>
</View>
{/* Price Skeleton */}
<Animated.View
style={[
styles.priceSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
{/* Options Skeleton */}
<View style={styles.optionsContainer}>
<Animated.View
style={[
styles.optionTitleSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
<View style={styles.optionButtons}>
{[1, 2, 3].map((i) => (
<Animated.View
key={i}
style={[
styles.optionButtonSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
))}
</View>
</View>
{/* Quantity Skeleton */}
<View style={styles.quantityContainer}>
<Animated.View
style={[
styles.quantityLabelSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
<View style={styles.quantityControls}>
<Animated.View
style={[
styles.quantityButtonSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
<Animated.View
style={[
styles.quantityValueSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
<Animated.View
style={[
styles.quantityButtonSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
</View>
</View>
{/* Add to Cart Button Skeleton */}
<Animated.View
style={[
styles.buttonSkeleton,
{ backgroundColor: skeletonColor, opacity },
]}
/>
</View>
</View>
)
}
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

tsxCopy
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 (
<Animated.View
style={[
styles.container,
{
opacity,
transform: [{ translateY }],
},
]}
>
<View style={[styles.toast, { backgroundColor }]}>
<Text style={[styles.message, { color: textColor }]}>{message}</Text>
</View>
</Animated.View>
)
}
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

tsCopy
import type { HttpTypes } from "@aiminaabeejs/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:

tsx

tsxCopy
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 "@aiminaabeejs/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<HttpTypes.StoreProduct | null>(null)
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({})
const [quantity, setQuantity] = useState(1)
const [loading, setLoading] = useState(true)
const [addingToCart, setAddingToCart] = useState(false)
const [error, setError] = useState<string | null>(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

tsxCopy
const fetchProduct = useCallback(async () => {
try {
setLoading(true)
setError(null)
const { product: fetchedProduct } = await sdk.store.product.retrieve("us", 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<string, string> = {}
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 Aiminaabee 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:

tsx

tsxCopy
// 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

tsxCopy
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

tsxCopy
if (loading) {
return <ProductSkeleton />
}
if (error || !product) {
return (
<View style={[styles.centerContainer, { backgroundColor: colors.background }]}>
<Text style={[styles.errorText, { color: colors.text }]}>
{error || "Product not found"}
</Text>
</View>
)
}
// 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 (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<ProductImageSlider images={images} />
<View style={styles.content}>
<Text style={[styles.title, { color: colors.text }]}>{product.title}</Text>
{product.description && (
<Text style={[styles.description, { color: colors.icon }]}>
{product.description}
</Text>
)}
<View style={styles.priceContainer}>
<Text style={[styles.price, { color: colors.tint }]}>
{formatPrice(priceAmount, currencyCode)}
</Text>
{!isInStock && (
<View style={[styles.stockBadge, { backgroundColor: colors.error }]}>
<Text style={styles.outOfStockText}>Out of Stock</Text>
</View>
)}
{isInStock && selectedVariant?.inventory_quantity !== undefined &&
selectedVariant.inventory_quantity! <= 10 &&
selectedVariant.manage_inventory !== false && (
<View style={[styles.stockBadge, { backgroundColor: colors.warning }]}>
<Text style={styles.lowStockText}>
Only {selectedVariant.inventory_quantity} left
</Text>
</View>
)}
</View>
{shouldShowOptions && (
<View style={styles.optionsSection}>
{product.options?.map((option) => (
<View key={option.id} style={styles.optionGroup}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>
{option.title}
</Text>
<View style={styles.optionValues}>
{option.values?.map((optionValue) => {
const isSelected = selectedOptions[option.id!] === optionValue.value
return (
<TouchableOpacity
key={optionValue.id}
style={[
styles.optionButton,
{
backgroundColor: isSelected
? colors.tint + "20"
: "transparent",
borderColor: isSelected
? colors.tint
: colors.icon + "30",
},
]}
onPress={() => {
setSelectedOptions((prev) => ({
...prev,
[option.id!]: optionValue.value!,
}))
}}
>
<Text
style={[
styles.optionText,
{
color: isSelected ? colors.tint : colors.text,
fontWeight: isSelected ? "600" : "400",
},
]}
>
{optionValue.value}
</Text>
</TouchableOpacity>
)
})}
</View>
</View>
))}
</View>
)}
<View style={styles.quantitySection}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Quantity</Text>
<View style={styles.quantityControls}>
<TouchableOpacity
style={[styles.quantityButton, { borderColor: colors.icon }]}
onPress={() => setQuantity(Math.max(1, quantity - 1))}
>
<Text style={[styles.quantityButtonText, { color: colors.text }]}>-</Text>
</TouchableOpacity>
<Text style={[styles.quantity, { color: colors.text }]}>{quantity}</Text>
<TouchableOpacity
style={[styles.quantityButton, { borderColor: colors.icon }]}
onPress={() => setQuantity(quantity + 1)}
>
<Text style={[styles.quantityButtonText, { color: colors.text }]}>+</Text>
</TouchableOpacity>
</View>
</View>
<Button
title={isInStock ? "Add to Cart" : "Out of Stock"}
onPress={handleAddToCart}
loading={addingToCart}
disabled={!isInStock}
style={styles.addButton}
/>
</View>
<Toast
message={toastMessage}
visible={toastVisible}
onHide={() => setToastVisible(false)}
type="success"
/>
</ScrollView>
)

You handle three main states:

  1. Loading: Displays the ProductSkeleton while fetching product details.
  2. Error: Displays an error message if fetching fails or the product is not found.
  3. Success: Renders the product detail screen with the image slider, title, description, price, stock status, options, quantity selector, and add to cart button.

Add Product Detail Screen to Navigation

Finally, you'll add the product detail screen to the home stack navigator.

In app/(drawer)/(tabs)/(home)/_layout.tsx, replace the TODO add product details screen comment with the following:

tsx

tsxCopy
<Stack.Screen
name="product/[id]"
options={{
title: "Product Details",
presentation: "card",
headerBackButtonDisplayMode: "minimal",
}}
/>

A new screen is added to the stack navigator for the product detail screen, allowing users to navigate between the home screen and the product detail screen seamlessly.

Test Product Detail Screen

To test out the product detail screen, run your Aiminaabee application and Expo app.

Then, in the home screen, click on a product to view its details. This will open the product detail screen where you can view its images, select options, adjust quantity, and add it to the cart.

Product Detail Screen

You can try adding a product to the cart and see the toast notification confirming the action. You'll implement the cart screen in the next step.


Next: 10. Cart screen