npm package: @hg-storefront/product-ingredients
Description: A React hook library for fetching and structuring product ingredients, nutrition, and allergy data from Vendure e-commerce products. This library provides a clean, type-safe interface for accessing product nutritional information while preserving CMS form
Latest version: 0.0.30

# @hg-storefront/product-ingredients

A React hook library for fetching and structuring product ingredients, nutrition, and allergy data from Vendure e-commerce products. This library provides a clean, type-safe interface for accessing product nutritional information while preserving CMS formatting.

## Features

- **Type-safe data access**: Full TypeScript support with comprehensive interfaces
- **Structured nutrition data**: Validated nutrition values (energy, protein, carbs, etc.)
- **Allergy information**: Organized allergy data with "contains" and "traces of" categories
- **CMS formatting preservation**: Maintains HTML formatting from the CMS for ingredients
- **Cross-platform compatibility**: Works in both React web and React Native applications
- **Loading and error states**: Built-in loading and error handling
- **Memoized performance**: Optimized with React useMemo for efficient re-renders

## Installation

```bash
yarn add @hg-storefront/product-ingredients
```

## Dependencies

This library requires the following peer dependencies:

- `@haus-storefront-react/hooks` (^0.0.7-7)
- `@haus-storefront-react/shared-types` (^0.0.7-7)
- `react` (^18.0.0)
- `react-dom` (^18.0.0) - for React web applications

## Usage

### Basic Usage

```tsx
import { useProductIngredients } from '@hg-storefront/product-ingredients'

function ProductDetails({ productId }: { productId: string }) {
  const { 
    ingredients, 
    nutrition, 
    allergies, 
    isLoading, 
    error 
  } = useProductIngredients({ productId })

  if (isLoading) return <div>Loading product information...</div>
  if (error) return <div>Error loading product data</div>

  return (
    <div>
      {/* Your UI implementation */}
    </div>
  )
}
```

### React Web Example

```tsx
import { useProductIngredients } from '@hg-storefront/product-ingredients'
import * as Accordion from '@radix-ui/react-accordion'

function ProductIngredientsAccordion({ productId }: { productId: string }) {
  const { ingredients, nutrition, allergies, isLoading, error } = useProductIngredients({ productId })

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error loading product data</div>

  return (
    <Accordion.Root type="single" collapsible>
      {/* Ingredients */}
      {ingredients && (
        <Accordion.Item value="ingredients">
          <Accordion.Trigger>Ingredienser</Accordion.Trigger>
          <Accordion.Content>
            <div dangerouslySetInnerHTML={{ __html: ingredients.htmlContent }} />
          </Accordion.Content>
        </Accordion.Item>
      )}

      {/* Nutrition */}
      {nutrition && (
        <Accordion.Item value="nutrition">
          <Accordion.Trigger>Näringsinnehåll</Accordion.Trigger>
          <Accordion.Content>
            <table>
              <tbody>
                <tr>
                  <td>Energi</td>
                  <td>{nutrition.energyKj} kj/{nutrition.energyKcal} kcal</td>
                </tr>
                <tr>
                  <td>Protein</td>
                  <td>{nutrition.protein} g</td>
                </tr>
                <tr>
                  <td>Kolhydrater</td>
                  <td>{nutrition.carbohydrates} g</td>
                </tr>
                <tr>
                  <td>Varav sockerarter</td>
                  <td>{nutrition.sugar} g</td>
                </tr>
                <tr>
                  <td>Fett</td>
                  <td>{nutrition.fat} g</td>
                </tr>
                <tr>
                  <td>Varav mättat fett</td>
                  <td>{nutrition.saturatedFat} g</td>
                </tr>
                <tr>
                  <td>Salt</td>
                  <td>{nutrition.salt} g</td>
                </tr>
              </tbody>
            </table>
          </Accordion.Content>
        </Accordion.Item>
      )}

      {/* Allergies */}
      {allergies.map((allergy, index) => (
        <Accordion.Item key={index} value={`allergies-${index}`}>
          <Accordion.Trigger>{allergy.heading}</Accordion.Trigger>
          <Accordion.Content>
            <p>{allergy.items.join(', ')}</p>
          </Accordion.Content>
        </Accordion.Item>
      ))}
    </Accordion.Root>
  )
}
```

### React Native Example

```tsx
import React from 'react'
import { View, Text, ScrollView, StyleSheet } from 'react-native'
import { useProductIngredients } from '@hg-storefront/product-ingredients'
import { Collapsible } from 'react-native-collapsible'

function ProductIngredientsMobile({ productId }: { productId: string }) {
  const { ingredients, nutrition, allergies, isLoading, error } = useProductIngredients({ productId })

  if (isLoading) return <Text>Loading...</Text>
  if (error) return <Text>Error loading product data</Text>

  return (
    <ScrollView style={styles.container}>
      {/* Ingredients */}
      {ingredients && (
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>Ingredienser</Text>
          <Text style={styles.content}>
            {/* Note: In React Native, you'll need to parse HTML or use a library like react-native-htmlview */}
            {ingredients.htmlContent.replace(/<[^>]*>/g, '')}
          </Text>
        </View>
      )}

      {/* Nutrition */}
      {nutrition && (
        <View style={styles.section}>
          <Text style={styles.sectionTitle}>Näringsinnehåll</Text>
          <View style={styles.nutritionTable}>
            <View style={styles.nutritionRow}>
              <Text style={styles.nutritionLabel}>Energi</Text>
              <Text style={styles.nutritionValue}>
                {nutrition.energyKj} kj/{nutrition.energyKcal} kcal
              </Text>
            </View>
            <View style={styles.nutritionRow}>
              <Text style={styles.nutritionLabel}>Protein</Text>
              <Text style={styles.nutritionValue}>{nutrition.protein} g</Text>
            </View>
            <View style={styles.nutritionRow}>
              <Text style={styles.nutritionLabel}>Kolhydrater</Text>
              <Text style={styles.nutritionValue}>{nutrition.carbohydrates} g</Text>
            </View>
            <View style={styles.nutritionRow}>
              <Text style={styles.nutritionLabel}>Varav sockerarter</Text>
              <Text style={styles.nutritionValue}>{nutrition.sugar} g</Text>
            </View>
            <View style={styles.nutritionRow}>
              <Text style={styles.nutritionLabel}>Fett</Text>
              <Text style={styles.nutritionValue}>{nutrition.fat} g</Text>
            </View>
            <View style={styles.nutritionRow}>
              <Text style={styles.nutritionLabel}>Varav mättat fett</Text>
              <Text style={styles.nutritionValue}>{nutrition.saturatedFat} g</Text>
            </View>
            <View style={styles.nutritionRow}>
              <Text style={styles.nutritionLabel}>Salt</Text>
              <Text style={styles.nutritionValue}>{nutrition.salt} g</Text>
            </View>
          </View>
        </View>
      )}

      {/* Allergies */}
      {allergies.map((allergy, index) => (
        <View key={index} style={styles.section}>
          <Text style={styles.sectionTitle}>{allergy.heading}</Text>
          <Text style={styles.content}>{allergy.items.join(', ')}</Text>
        </View>
      ))}
    </ScrollView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 16,
  },
  section: {
    marginBottom: 20,
  },
  sectionTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  content: {
    fontSize: 16,
    lineHeight: 24,
  },
  nutritionTable: {
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
  },
  nutritionRow: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    padding: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  nutritionLabel: {
    fontSize: 16,
    flex: 1,
  },
  nutritionValue: {
    fontSize: 16,
    fontWeight: '500',
  },
})

export default ProductIngredientsMobile
```

## API Reference

### Hook: `useProductIngredients`

#### Parameters

```typescript
interface UseProductIngredientsProps {
  productId: string
}
```

#### Returns

```typescript
interface UseProductIngredientsReturn {
  productData: Product | undefined
  isLoading: boolean
  error: unknown
  ingredients: IngredientsData | null
  nutrition: NutritionData | null
  allergies: AllergyData[]
}
```

### Data Types

#### `IngredientsData`

```typescript
interface IngredientsData {
  htmlContent: string // Preserves CMS formatting
}
```

#### `NutritionData`

```typescript
interface NutritionData {
  energyKj: number
  energyKcal: number
  protein: number
  carbohydrates: number
  sugar: number
  fat: number
  saturatedFat: number
  salt: number
}
```

#### `AllergyData`

```typescript
interface AllergyData {
  heading: string // "Innehåller" or "Spår av"
  items: string[] // Array of allergy items
}
```

## Data Structure

### Ingredients

- **`htmlContent`**: String containing HTML from CMS that preserves formatting (bold, italic, line breaks, etc.)
- **Note for React Native**: HTML content needs to be parsed or stripped of tags for display

### Nutrition

- **Structured nutrition values**: All values are validated numbers representing grams (g) or kilojoules/kilocalories
- **Complete nutrition profile**: Energy (kJ/kcal), protein, carbohydrates, sugar, fat, saturated fat, and salt
- **Validation**: Returns `null` if any nutrition values are missing or invalid

### Allergies

- **Organized by type**: Separated into "Innehåller" (Contains) and "Spår av" (Traces of) categories
- **Facet-based**: Data is extracted from Vendure facet values with codes `'contains'` and `'traces-of'`
- **Array structure**: Each allergy group has a heading and an array of allergy items

## Error Handling

The hook provides comprehensive error handling:

- **`isLoading`**: Boolean indicating if data is being fetched
- **`error`**: Error object if the request fails
- **Null safety**: Returns `null` for missing or invalid data instead of throwing errors
- **Type validation**: Validates nutrition data types before returning

## Performance Considerations

- **Memoized calculations**: All data processing is memoized with `useMemo`
- **Efficient re-renders**: Only recalculates when `productData` changes
- **Type safety**: Prevents unnecessary re-renders through proper TypeScript typing

## Browser/Platform Compatibility

- **React Web**: Full compatibility with all modern browsers
- **React Native**: Compatible with iOS and Android
- **HTML rendering**: For React Native, consider using libraries like `react-native-htmlview` for ingredients display

## Dependencies

This library depends on the Haus Storefront React ecosystem:

- `@haus-storefront-react/hooks`: For product data fetching
- `@haus-storefront-react/shared-types`: For TypeScript type definitions

Make sure your project has these dependencies properly configured.