Skip to content

React Examples

This guide provides React patterns and hooks for integrating with the EngageHQ API.

Setup

Install Dependencies

bash
npm install @tanstack/react-query
# or
npm install swr

Create API Context

tsx
// context/EngageHQContext.tsx
import React, { createContext, useContext, useMemo } from 'react';

const API_BASE = 'https://cdn.engagehq.retailsuccessplatform.com/api/v1/public';

interface EngageHQContextValue {
  token: string;
  organizationId: string;
  fetcher: <T>(endpoint: string) => Promise<T>;
}

const EngageHQContext = createContext<EngageHQContextValue | null>(null);

interface EngageHQProviderProps {
  token: string;
  /** Organization or location hashkey (org_xxx). Use a store location for location-scoped content. */
  organizationId: string;
  children: React.ReactNode;
}

export function EngageHQProvider({ token, organizationId, children }: EngageHQProviderProps) {
  const value = useMemo(() => ({
    token,
    organizationId,
    fetcher: async <T,>(endpoint: string): Promise<T> => {
      const response = await fetch(`${API_BASE}${endpoint}`, {
        headers: {
          'Authorization': `Bearer ${token}`,
          'X-Organization-Context': organizationId,
          'Content-Type': 'application/json',
        },
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'API request failed');
      }

      return response.json();
    },
  }), [token, organizationId]);

  return (
    <EngageHQContext.Provider value={value}>
      {children}
    </EngageHQContext.Provider>
  );
}

export function useEngageHQ() {
  const context = useContext(EngageHQContext);
  if (!context) {
    throw new Error('useEngageHQ must be used within EngageHQProvider');
  }
  return context;
}

React Query Integration

Query Client Setup

tsx
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EngageHQProvider } from './context/EngageHQContext';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute
      retry: 1,
    },
  },
});

export function Providers({ children, token, organizationId }: { children: React.ReactNode; token: string; organizationId: string }) {
  return (
    <QueryClientProvider client={queryClient}>
      <EngageHQProvider token={token} organizationId={organizationId}>
        {children}
      </EngageHQProvider>
    </QueryClientProvider>
  );
}

Content Hooks

tsx
// hooks/useContent.ts
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { useEngageHQ } from '../context/EngageHQContext';

interface ContentParams {
  type?: string;
  tags?: string[];
  category?: string;
  featured?: boolean;
  per_page?: number;
}

export function useContentList(params: ContentParams = {}) {
  const { fetcher } = useEngageHQ();

  const queryString = new URLSearchParams();
  if (params.type) queryString.set('type', params.type);
  if (params.category) queryString.set('category', params.category);
  if (params.featured !== undefined) queryString.set('featured', String(params.featured));
  if (params.per_page) queryString.set('per_page', String(params.per_page));
  params.tags?.forEach(tag => queryString.append('tags[]', tag));

  return useQuery({
    queryKey: ['content', params],
    queryFn: () => fetcher(`/content?${queryString}`),
    staleTime: 60 * 1000, // Cache for 1 minute
  });
}

export function useContent(slug: string) {
  const { fetcher } = useEngageHQ();

  return useQuery({
    queryKey: ['content', slug],
    queryFn: () => fetcher(`/content/${slug}`),
    staleTime: 5 * 60 * 1000, // Cache for 5 minutes
    enabled: !!slug,
  });
}

export function useInfiniteContent(params: ContentParams = {}) {
  const { fetcher } = useEngageHQ();

  return useInfiniteQuery({
    queryKey: ['content', 'infinite', params],
    queryFn: ({ pageParam = 1 }) => {
      const queryString = new URLSearchParams();
      if (params.type) queryString.set('type', params.type);
      if (params.per_page) queryString.set('per_page', String(params.per_page));
      queryString.set('page', String(pageParam));
      return fetcher(`/content?${queryString}`);
    },
    getNextPageParam: (lastPage: any) =>
      lastPage.meta.current_page < lastPage.meta.last_page
        ? lastPage.meta.current_page + 1
        : undefined,
    initialPageParam: 1,
  });
}

Circular Hooks

tsx
// hooks/useCirculars.ts
import { useQuery } from '@tanstack/react-query';
import { useEngageHQ } from '../context/EngageHQContext';

interface CircularParams {
  format?: 'tabloid' | 'letter' | 'custom';
  per_page?: number;
}

export function useCircularList(params: CircularParams = {}) {
  const { fetcher } = useEngageHQ();

  const queryString = new URLSearchParams();
  if (params.format) queryString.set('format', params.format);
  if (params.per_page) queryString.set('per_page', String(params.per_page));

  return useQuery({
    queryKey: ['circulars', params],
    queryFn: () => fetcher(`/circulars?${queryString}`),
    staleTime: 60 * 1000,
  });
}

export function useCircular(id: string) {
  const { fetcher } = useEngageHQ();

  return useQuery({
    queryKey: ['circulars', id],
    queryFn: () => fetcher(`/circulars/${id}`),
    staleTime: 5 * 60 * 1000,
    enabled: !!id,
  });
}

Offer Hooks

tsx
// hooks/useOffers.ts
import { useQuery } from '@tanstack/react-query';
import { useEngageHQ } from '../context/EngageHQContext';

interface OfferParams {
  type?: 'percentage' | 'fixed' | 'bogo';
  per_page?: number;
}

export function useOfferList(params: OfferParams = {}) {
  const { fetcher } = useEngageHQ();

  const queryString = new URLSearchParams();
  if (params.type) queryString.set('type', params.type);
  if (params.per_page) queryString.set('per_page', String(params.per_page));

  return useQuery({
    queryKey: ['offers', params],
    queryFn: () => fetcher(`/offers?${queryString}`),
    staleTime: 0, // Always refetch - offers are time-sensitive
    refetchOnWindowFocus: true,
  });
}

export function useOffer(id: string) {
  const { fetcher } = useEngageHQ();

  return useQuery({
    queryKey: ['offers', id],
    queryFn: () => fetcher(`/offers/${id}`),
    staleTime: 0,
    enabled: !!id,
  });
}

Components

Content List Component

tsx
// components/ContentList.tsx
import { useContentList } from '../hooks/useContent';

interface ContentListProps {
  type?: string;
  featured?: boolean;
  limit?: number;
}

export function ContentList({ type, featured, limit = 10 }: ContentListProps) {
  const { data, isLoading, error } = useContentList({
    type,
    featured,
    per_page: limit,
  });

  if (isLoading) {
    return <ContentListSkeleton count={limit} />;
  }

  if (error) {
    return <ErrorMessage message="Failed to load content" />;
  }

  const items = data?.data || [];

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {items.map((item: any) => (
        <ContentCard key={item.id} content={item} />
      ))}
    </div>
  );
}

function ContentCard({ content }: { content: any }) {
  return (
    <article className="bg-white rounded-lg shadow-md overflow-hidden">
      {content.featured_image && (
        <img
          src={content.featured_image.url}
          alt={content.featured_image.alt}
          className="w-full h-48 object-cover"
        />
      )}
      <div className="p-4">
        <h3 className="text-lg font-semibold mb-2">
          <a href={`/content/${content.slug}`}>{content.title}</a>
        </h3>
        {content.description && (
          <p className="text-gray-600 text-sm">{content.description}</p>
        )}
        <div className="mt-4 flex flex-wrap gap-2">
          {content.tags?.map((tag: string) => (
            <span key={tag} className="px-2 py-1 bg-gray-100 text-xs rounded">
              {tag}
            </span>
          ))}
        </div>
      </div>
    </article>
  );
}

function ContentListSkeleton({ count }: { count: number }) {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {Array.from({ length: count }).map((_, i) => (
        <div key={i} className="bg-gray-200 rounded-lg h-64 animate-pulse" />
      ))}
    </div>
  );
}

Content Detail Component

tsx
// components/ContentDetail.tsx
import { useContent } from '../hooks/useContent';
import { ContentBlocks } from './ContentBlocks';

interface ContentDetailProps {
  slug: string;
}

export function ContentDetail({ slug }: ContentDetailProps) {
  const { data, isLoading, error } = useContent(slug);

  if (isLoading) {
    return <ContentDetailSkeleton />;
  }

  if (error || !data?.data) {
    return <ErrorMessage message="Content not found" />;
  }

  const content = data.data;

  return (
    <article className="max-w-3xl mx-auto">
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-4">{content.title}</h1>
        {content.published_at && (
          <time className="text-gray-500">
            {new Date(content.published_at).toLocaleDateString()}
          </time>
        )}
      </header>

      {content.featured_image && (
        <img
          src={content.featured_image.url}
          alt={content.featured_image.alt}
          className="w-full rounded-lg mb-8"
        />
      )}

      <div className="prose prose-lg">
        <ContentBlocks blocks={content.content} />
      </div>
    </article>
  );
}

Content Blocks Renderer

tsx
// components/ContentBlocks.tsx
interface ContentBlock {
  type: string;
  text?: string;
  level?: number;
  url?: string;
  alt?: string;
  caption?: string;
  items?: string[];
  ordered?: boolean;
  attribution?: string;
}

interface ContentBlocksProps {
  blocks: ContentBlock[];
}

export function ContentBlocks({ blocks }: ContentBlocksProps) {
  if (!blocks || !Array.isArray(blocks)) {
    return null;
  }

  return (
    <>
      {blocks.map((block, index) => (
        <ContentBlock key={index} block={block} />
      ))}
    </>
  );
}

function ContentBlock({ block }: { block: ContentBlock }) {
  switch (block.type) {
    case 'heading':
      const HeadingTag = `h${block.level || 2}` as keyof JSX.IntrinsicElements;
      return <HeadingTag>{block.text}</HeadingTag>;

    case 'paragraph':
      return <p dangerouslySetInnerHTML={{ __html: block.text || '' }} />;

    case 'image':
      return (
        <figure>
          <img src={block.url} alt={block.alt || ''} className="rounded-lg" />
          {block.caption && <figcaption>{block.caption}</figcaption>}
        </figure>
      );

    case 'list':
      const ListTag = block.ordered ? 'ol' : 'ul';
      return (
        <ListTag>
          {block.items?.map((item, i) => (
            <li key={i}>{item}</li>
          ))}
        </ListTag>
      );

    case 'quote':
      return (
        <blockquote>
          <p>{block.text}</p>
          {block.attribution && <cite>— {block.attribution}</cite>}
        </blockquote>
      );

    default:
      return null;
  }
}

Offers Component

tsx
// components/ActiveOffers.tsx
import { useOfferList } from '../hooks/useOffers';

export function ActiveOffers() {
  const { data, isLoading } = useOfferList();

  if (isLoading) {
    return <OffersSkeleton />;
  }

  const offers = data?.data || [];

  if (offers.length === 0) {
    return null;
  }

  return (
    <section className="bg-red-50 py-8">
      <div className="container mx-auto px-4">
        <h2 className="text-2xl font-bold mb-6">Current Offers</h2>
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {offers.map((offer: any) => (
            <OfferCard key={offer.id} offer={offer} />
          ))}
        </div>
      </div>
    </section>
  );
}

function OfferCard({ offer }: { offer: any }) {
  return (
    <div className="bg-white rounded-lg p-4 shadow-sm border-l-4 border-red-500">
      <div className="text-2xl font-bold text-red-600 mb-2">
        {offer.discount.display}
      </div>
      <h3 className="font-semibold">{offer.name}</h3>
      {offer.description && (
        <p className="text-gray-600 text-sm mt-1">{offer.description}</p>
      )}
      {offer.valid_until && (
        <p className="text-xs text-gray-400 mt-2">
          Expires: {new Date(offer.valid_until).toLocaleDateString()}
        </p>
      )}
    </div>
  );
}

Circular Viewer Component

tsx
// components/CircularViewer.tsx
import { useState } from 'react';
import { useCircular } from '../hooks/useCirculars';

interface CircularViewerProps {
  id: string;
}

export function CircularViewer({ id }: CircularViewerProps) {
  const { data, isLoading, error } = useCircular(id);
  const [currentPage, setCurrentPage] = useState(0);

  if (isLoading) {
    return <CircularSkeleton />;
  }

  if (error || !data?.data) {
    return <ErrorMessage message="Circular not found" />;
  }

  const circular = data.data;
  const pages = circular.layout_data?.pages || [];
  const page = pages[currentPage];

  return (
    <div className="circular-viewer">
      <header className="flex justify-between items-center mb-4">
        <h2 className="text-xl font-bold">{circular.name}</h2>
        <span className="text-gray-500">
          Page {currentPage + 1} of {circular.total_pages}
        </span>
      </header>

      <div
        className="relative bg-white border rounded-lg overflow-hidden"
        style={{
          width: circular.dimensions.width,
          height: circular.dimensions.height,
        }}
      >
        {page?.regions.map((region: any) => (
          <CircularRegion key={region.id} region={region} />
        ))}
      </div>

      <nav className="flex justify-center gap-4 mt-4">
        <button
          onClick={() => setCurrentPage(p => p - 1)}
          disabled={currentPage === 0}
          className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
        >
          Previous
        </button>
        <button
          onClick={() => setCurrentPage(p => p + 1)}
          disabled={currentPage >= pages.length - 1}
          className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
        >
          Next
        </button>
      </nav>
    </div>
  );
}

function CircularRegion({ region }: { region: any }) {
  return (
    <div
      className="absolute"
      style={{
        left: region.position.x,
        top: region.position.y,
        width: region.dimensions.width,
        height: region.dimensions.height,
      }}
    >
      {/* Render region content based on type */}
      {region.content?.type === 'image' && (
        <img
          src={region.content.url}
          alt={region.content.alt || ''}
          className="w-full h-full object-cover"
        />
      )}
    </div>
  );
}

Platform Elements

When rendering content sections, the content array may include platform_element entries. These represent components owned by your platform and are resolved client-side.

Component Registry

tsx
// components/platformElements.ts
import { ComponentType } from 'react';
import { PopularSaleItems } from './platform/PopularSaleItems';
import { WeeklyAdBanner } from './platform/WeeklyAdBanner';
import { StoreLocatorMap } from './platform/StoreLocatorMap';
import { PlatformFallback } from './platform/PlatformFallback';

interface PlatformElementProps {
  section: any;
  config: Record<string, any>;
  settings: Record<string, any>;
}

const platformComponents: Record<string, ComponentType<PlatformElementProps>> = {
  'ecom360:popular-sale-items': PopularSaleItems,
  'ecom360:weekly-ad-banner': WeeklyAdBanner,
  'ecom360:store-locator-map': StoreLocatorMap,
};

export function resolvePlatformElement(section: any): ComponentType<PlatformElementProps> {
  const key = `${section.platform_key}:${section.element_key}`;
  return platformComponents[key] || PlatformFallback;
}

Section Renderer

tsx
// components/SectionRenderer.tsx
import { resolvePlatformElement } from './platformElements';
import { ContentBlockSection } from './sections/ContentBlockSection';
import { HeroSection } from './sections/HeroSection';
import { ImageSection } from './sections/ImageSection';

const builtInComponents: Record<string, ComponentType<any>> = {
  content: ContentBlockSection,
  hero: HeroSection,
  image: ImageSection,
  // ... other built-in types
};

interface SectionRendererProps {
  sections: any[];
}

export function SectionRenderer({ sections }: SectionRendererProps) {
  return (
    <>
      {sections.map((section) => {
        const Component =
          section.type === 'platform_element'
            ? resolvePlatformElement(section)
            : builtInComponents[section.type];

        if (!Component) return null;

        return (
          <div
            key={section.id}
            id={section.settings?.anchor_id || undefined}
            className={`section section--${section.settings?.padding || 'medium'}`}
          >
            <Component
              section={section}
              config={section.config || {}}
              settings={section.settings || {}}
            />
          </div>
        );
      })}
    </>
  );
}

Example Platform Component

tsx
// components/platform/PopularSaleItems.tsx
import { useState, useEffect } from 'react';

interface PopularSaleItemsProps {
  section: any;
  config: { max_items?: number; layout?: string };
  settings: Record<string, any>;
}

export function PopularSaleItems({ config }: PopularSaleItemsProps) {
  const [items, setItems] = useState<any[]>([]);
  const maxItems = config.max_items || 12;
  const layout = config.layout || 'carousel';

  useEffect(() => {
    fetch(`/api/products/popular?limit=${maxItems}`)
      .then((res) => res.json())
      .then((data) => setItems(data.products));
  }, [maxItems]);

  return (
    <div className={`popular-sale-items layout--${layout}`}>
      {items.map((item) => (
        <div key={item.id} className="sale-item">
          <img src={item.image} alt={item.name} />
          <h3>{item.name}</h3>
          <span className="price">{item.price}</span>
        </div>
      ))}
    </div>
  );
}

See the Platform Elements reference for the full API and storage format.


Error Handling

Error Boundary

tsx
// components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-4 bg-red-50 text-red-700 rounded">
          <h3 className="font-bold">Something went wrong</h3>
          <p>{this.state.error?.message}</p>
        </div>
      );
    }

    return this.props.children;
  }
}

Query Error Handler

tsx
// hooks/useQueryErrorHandler.ts
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';

export function useQueryErrorHandler() {
  const queryClient = useQueryClient();

  useEffect(() => {
    const unsubscribe = queryClient.getQueryCache().subscribe(event => {
      if (event?.query.state.status === 'error') {
        const error = event.query.state.error as Error;

        // Handle auth errors globally
        if (error.message === 'Unauthorized') {
          window.location.href = '/login';
        }
      }
    });

    return () => unsubscribe();
  }, [queryClient]);
}

Complete Example App

tsx
// App.tsx
import { Providers } from './providers';
import { ContentList } from './components/ContentList';
import { ActiveOffers } from './components/ActiveOffers';
import { ErrorBoundary } from './components/ErrorBoundary';

function App() {
  const token = useAuthToken(); // Your auth hook

  return (
    <Providers token={token}>
      <ErrorBoundary>
        <main>
          <section className="py-8">
            <h1 className="text-3xl font-bold mb-6">Featured Content</h1>
            <ContentList featured={true} limit={6} />
          </section>

          <ActiveOffers />

          <section className="py-8">
            <h2 className="text-2xl font-bold mb-6">Latest Articles</h2>
            <ContentList type="article" limit={9} />
          </section>
        </main>
      </ErrorBoundary>
    </Providers>
  );
}

export default App;

Changelog
DateChange
2026-03-24Added platform elements component registry and SectionRenderer examples.
2026-02-23Updated examples for CloudFront CDN and Lambda@Edge delivery.
2026-01-28Expanded documentation.
2026-01-15Initial publication.

EngageHQ Public Content Delivery API