Appearance
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 swrCreate 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
| Date | Change |
|---|---|
| 2026-03-24 | Added platform elements component registry and SectionRenderer examples. |
| 2026-02-23 | Updated examples for CloudFront CDN and Lambda@Edge delivery. |
| 2026-01-28 | Expanded documentation. |
| 2026-01-15 | Initial publication. |