Appearance
Vue Examples
This guide provides Vue 3 Composition API patterns and composables for integrating with the EngageHQ API.
Setup
Install Dependencies
bash
npm install @vueuse/core
# Optional for state management
npm install piniaCreate API Composable
typescript
// composables/useEngageHQ.ts
import { ref, computed, type Ref } from 'vue';
const API_BASE = 'https://cdn.engagehq.retailsuccessplatform.com/api/v1/public';
let token: Ref<string | null> = ref(null);
let organizationId: Ref<string | null> = ref(null);
/**
* Configure EngageHQ API access.
* @param newToken - JWT token from Identity service
* @param orgId - Organization or location hashkey (org_xxx). Use a store location for location-scoped content.
*/
export function setEngageHQConfig(newToken: string, orgId: string) {
token.value = newToken;
organizationId.value = orgId;
}
export function useEngageHQ() {
const isConfigured = computed(() => !!token.value && !!organizationId.value);
async function fetcher<T>(endpoint: string): Promise<T> {
if (!token.value || !organizationId.value) {
throw new Error('EngageHQ token and organization not configured');
}
const response = await fetch(`${API_BASE}${endpoint}`, {
headers: {
'Authorization': `Bearer ${token.value}`,
'X-Organization-Context': organizationId.value,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'API request failed');
}
return response.json();
}
return {
token,
organizationId,
isConfigured,
fetcher,
};
}App Setup
typescript
// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { setEngageHQConfig } from './composables/useEngageHQ';
const app = createApp(App);
app.use(createPinia());
// Set token and organization from your auth system
setEngageHQConfig('your-jwt-token', 'org_00000a1B2c3D4e5');
app.mount('#app');Content Composables
useContent
typescript
// composables/useContent.ts
import { ref, watch, type Ref } from 'vue';
import { useEngageHQ } from './useEngageHQ';
interface ContentParams {
type?: string;
tags?: string[];
category?: string;
featured?: boolean;
per_page?: number;
page?: number;
}
export function useContentList(params: Ref<ContentParams> | ContentParams = {}) {
const { fetcher } = useEngageHQ();
const data = ref<any[]>([]);
const meta = ref<any>(null);
const loading = ref(false);
const error = ref<Error | null>(null);
async function fetchContent() {
loading.value = true;
error.value = null;
try {
const p = 'value' in params ? params.value : params;
const queryString = new URLSearchParams();
if (p.type) queryString.set('type', p.type);
if (p.category) queryString.set('category', p.category);
if (p.featured !== undefined) queryString.set('featured', String(p.featured));
if (p.per_page) queryString.set('per_page', String(p.per_page));
if (p.page) queryString.set('page', String(p.page));
p.tags?.forEach(tag => queryString.append('tags[]', tag));
const response = await fetcher<any>(`/content?${queryString}`);
data.value = response.data;
meta.value = response.meta;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
// Auto-fetch on params change if reactive
if ('value' in params) {
watch(params, fetchContent, { immediate: true, deep: true });
} else {
fetchContent();
}
return {
data,
meta,
loading,
error,
refresh: fetchContent,
};
}
export function useContent(slug: Ref<string> | string) {
const { fetcher } = useEngageHQ();
const data = ref<any>(null);
const loading = ref(false);
const error = ref<Error | null>(null);
async function fetchContent() {
const s = typeof slug === 'string' ? slug : slug.value;
if (!s) return;
loading.value = true;
error.value = null;
try {
const response = await fetcher<any>(`/content/${s}`);
data.value = response.data;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
if (typeof slug !== 'string') {
watch(slug, fetchContent, { immediate: true });
} else {
fetchContent();
}
return {
data,
loading,
error,
refresh: fetchContent,
};
}useCirculars
typescript
// composables/useCirculars.ts
import { ref, watch, type Ref } from 'vue';
import { useEngageHQ } from './useEngageHQ';
interface CircularParams {
format?: 'tabloid' | 'letter' | 'custom';
per_page?: number;
}
export function useCircularList(params: Ref<CircularParams> | CircularParams = {}) {
const { fetcher } = useEngageHQ();
const data = ref<any[]>([]);
const meta = ref<any>(null);
const loading = ref(false);
const error = ref<Error | null>(null);
async function fetchCirculars() {
loading.value = true;
error.value = null;
try {
const p = 'value' in params ? params.value : params;
const queryString = new URLSearchParams();
if (p.format) queryString.set('format', p.format);
if (p.per_page) queryString.set('per_page', String(p.per_page));
const response = await fetcher<any>(`/circulars?${queryString}`);
data.value = response.data;
meta.value = response.meta;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
if ('value' in params) {
watch(params, fetchCirculars, { immediate: true, deep: true });
} else {
fetchCirculars();
}
return { data, meta, loading, error, refresh: fetchCirculars };
}
export function useCircular(id: Ref<string> | string) {
const { fetcher } = useEngageHQ();
const data = ref<any>(null);
const loading = ref(false);
const error = ref<Error | null>(null);
async function fetchCircular() {
const circularId = typeof id === 'string' ? id : id.value;
if (!circularId) return;
loading.value = true;
error.value = null;
try {
const response = await fetcher<any>(`/circulars/${circularId}`);
data.value = response.data;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
if (typeof id !== 'string') {
watch(id, fetchCircular, { immediate: true });
} else {
fetchCircular();
}
return { data, loading, error, refresh: fetchCircular };
}useOffers
typescript
// composables/useOffers.ts
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
import { useEngageHQ } from './useEngageHQ';
interface OfferParams {
type?: 'percentage' | 'fixed' | 'bogo';
per_page?: number;
}
export function useOfferList(params: OfferParams = {}, autoRefresh = true) {
const { fetcher } = useEngageHQ();
const data = ref<any[]>([]);
const meta = ref<any>(null);
const loading = ref(false);
const error = ref<Error | null>(null);
let refreshInterval: number | null = null;
async function fetchOffers() {
loading.value = true;
error.value = null;
try {
const queryString = new URLSearchParams();
if (params.type) queryString.set('type', params.type);
if (params.per_page) queryString.set('per_page', String(params.per_page));
const response = await fetcher<any>(`/offers?${queryString}`);
data.value = response.data;
meta.value = response.meta;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchOffers();
// Auto-refresh offers every 60 seconds
if (autoRefresh) {
refreshInterval = window.setInterval(fetchOffers, 60000);
}
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
return { data, meta, loading, error, refresh: fetchOffers };
}Components
ContentList.vue
vue
<script setup lang="ts">
import { computed, type PropType } from 'vue';
import { useContentList } from '../composables/useContent';
const props = defineProps({
type: String,
featured: Boolean,
limit: {
type: Number,
default: 10,
},
});
const params = computed(() => ({
type: props.type,
featured: props.featured,
per_page: props.limit,
}));
const { data: items, loading, error } = useContentList(params);
</script>
<template>
<div>
<!-- Loading State -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="i in limit"
:key="i"
class="bg-gray-200 rounded-lg h-64 animate-pulse"
/>
</div>
<!-- Error State -->
<div v-else-if="error" class="p-4 bg-red-50 text-red-700 rounded">
Failed to load content: {{ error.message }}
</div>
<!-- Content Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<article
v-for="item in items"
:key="item.id"
class="bg-white rounded-lg shadow-md overflow-hidden"
>
<img
v-if="item.featured_image"
:src="item.featured_image.url"
:alt="item.featured_image.alt"
class="w-full h-48 object-cover"
/>
<div class="p-4">
<h3 class="text-lg font-semibold mb-2">
<router-link :to="`/content/${item.slug}`">
{{ item.title }}
</router-link>
</h3>
<p v-if="item.description" class="text-gray-600 text-sm">
{{ item.description }}
</p>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="tag in item.tags"
:key="tag"
class="px-2 py-1 bg-gray-100 text-xs rounded"
>
{{ tag }}
</span>
</div>
</div>
</article>
</div>
</div>
</template>ContentDetail.vue
vue
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { computed } from 'vue';
import { useContent } from '../composables/useContent';
import ContentBlocks from './ContentBlocks.vue';
const route = useRoute();
const slug = computed(() => route.params.slug as string);
const { data: content, loading, error } = useContent(slug);
</script>
<template>
<div class="max-w-3xl mx-auto">
<!-- Loading -->
<div v-if="loading" class="animate-pulse">
<div class="h-10 bg-gray-200 rounded w-3/4 mb-4" />
<div class="h-4 bg-gray-200 rounded w-1/4 mb-8" />
<div class="h-64 bg-gray-200 rounded mb-8" />
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-12">
<h1 class="text-2xl font-bold mb-4">Content Not Found</h1>
<p class="text-gray-600">{{ error.message }}</p>
</div>
<!-- Content -->
<article v-else-if="content">
<header class="mb-8">
<h1 class="text-4xl font-bold mb-4">{{ content.title }}</h1>
<time v-if="content.published_at" class="text-gray-500">
{{ new Date(content.published_at).toLocaleDateString() }}
</time>
</header>
<img
v-if="content.featured_image"
:src="content.featured_image.url"
:alt="content.featured_image.alt"
class="w-full rounded-lg mb-8"
/>
<div class="prose prose-lg">
<ContentBlocks :blocks="content.content" />
</div>
</article>
</div>
</template>ContentBlocks.vue
vue
<script setup lang="ts">
defineProps<{
blocks: Array<{
type: string;
text?: string;
level?: number;
url?: string;
alt?: string;
caption?: string;
items?: string[];
ordered?: boolean;
attribution?: string;
}>;
}>();
</script>
<template>
<template v-for="(block, index) in blocks" :key="index">
<!-- Heading -->
<component
v-if="block.type === 'heading'"
:is="`h${block.level || 2}`"
>
{{ block.text }}
</component>
<!-- Paragraph -->
<p v-else-if="block.type === 'paragraph'" v-html="block.text" />
<!-- Image -->
<figure v-else-if="block.type === 'image'">
<img :src="block.url" :alt="block.alt || ''" class="rounded-lg" />
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
</figure>
<!-- List -->
<component
v-else-if="block.type === 'list'"
:is="block.ordered ? 'ol' : 'ul'"
>
<li v-for="(item, i) in block.items" :key="i">{{ item }}</li>
</component>
<!-- Quote -->
<blockquote v-else-if="block.type === 'quote'">
<p>{{ block.text }}</p>
<cite v-if="block.attribution">— {{ block.attribution }}</cite>
</blockquote>
</template>
</template>ActiveOffers.vue
vue
<script setup lang="ts">
import { useOfferList } from '../composables/useOffers';
const { data: offers, loading } = useOfferList();
function formatExpiry(dateString: string) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
</script>
<template>
<section v-if="!loading && offers.length > 0" class="bg-red-50 py-8">
<div class="container mx-auto px-4">
<h2 class="text-2xl font-bold mb-6">Current Offers</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="offer in offers"
:key="offer.id"
class="bg-white rounded-lg p-4 shadow-sm border-l-4 border-red-500"
>
<div class="text-2xl font-bold text-red-600 mb-2">
{{ offer.discount.display }}
</div>
<h3 class="font-semibold">{{ offer.name }}</h3>
<p v-if="offer.description" class="text-gray-600 text-sm mt-1">
{{ offer.description }}
</p>
<p v-if="offer.valid_until" class="text-xs text-gray-400 mt-2">
Expires: {{ formatExpiry(offer.valid_until) }}
</p>
</div>
</div>
</div>
</section>
</template>CircularViewer.vue
vue
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useCircular } from '../composables/useCirculars';
const props = defineProps<{
id: string;
}>();
const { data: circular, loading, error } = useCircular(props.id);
const currentPage = ref(0);
const pages = computed(() => circular.value?.layout_data?.pages || []);
const page = computed(() => pages.value[currentPage.value]);
const totalPages = computed(() => circular.value?.total_pages || 0);
function prevPage() {
if (currentPage.value > 0) currentPage.value--;
}
function nextPage() {
if (currentPage.value < pages.value.length - 1) currentPage.value++;
}
</script>
<template>
<div class="circular-viewer">
<!-- Loading -->
<div v-if="loading" class="h-96 bg-gray-200 animate-pulse rounded-lg" />
<!-- Error -->
<div v-else-if="error" class="p-8 text-center text-red-600">
Failed to load circular
</div>
<!-- Viewer -->
<template v-else-if="circular">
<header class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">{{ circular.name }}</h2>
<span class="text-gray-500">
Page {{ currentPage + 1 }} of {{ totalPages }}
</span>
</header>
<div
class="relative bg-white border rounded-lg overflow-hidden mx-auto"
:style="{
width: `${circular.dimensions.width}px`,
height: `${circular.dimensions.height}px`,
}"
>
<div
v-for="region in page?.regions"
:key="region.id"
class="absolute"
:style="{
left: `${region.position.x}px`,
top: `${region.position.y}px`,
width: `${region.dimensions.width}px`,
height: `${region.dimensions.height}px`,
}"
>
<img
v-if="region.content?.type === 'image'"
:src="region.content.url"
:alt="region.content.alt || ''"
class="w-full h-full object-cover"
/>
</div>
</div>
<nav class="flex justify-center gap-4 mt-4">
<button
@click="prevPage"
:disabled="currentPage === 0"
class="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
>
Previous
</button>
<button
@click="nextPage"
:disabled="currentPage >= pages.length - 1"
class="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
>
Next
</button>
</nav>
</template>
</div>
</template>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
typescript
// composables/usePlatformElements.ts
import { type Component, markRaw } from 'vue';
import PopularSaleItems from '../components/platform/PopularSaleItems.vue';
import WeeklyAdBanner from '../components/platform/WeeklyAdBanner.vue';
import StoreLocatorMap from '../components/platform/StoreLocatorMap.vue';
import PlatformFallback from '../components/platform/PlatformFallback.vue';
const platformComponents: Record<string, Component> = {
'ecom360:popular-sale-items': markRaw(PopularSaleItems),
'ecom360:weekly-ad-banner': markRaw(WeeklyAdBanner),
'ecom360:store-locator-map': markRaw(StoreLocatorMap),
};
export function resolvePlatformElement(section: any): Component {
const key = `${section.platform_key}:${section.element_key}`;
return platformComponents[key] || markRaw(PlatformFallback);
}Section Renderer
vue
<!-- components/SectionRenderer.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import ContentBlockSection from './sections/ContentBlockSection.vue';
import HeroSection from './sections/HeroSection.vue';
import ImageSection from './sections/ImageSection.vue';
import { resolvePlatformElement } from '../composables/usePlatformElements';
const props = defineProps<{
sections: Array<Record<string, any>>;
}>();
const builtInComponents: Record<string, any> = {
content: ContentBlockSection,
hero: HeroSection,
image: ImageSection,
// ... other built-in types
};
function resolveComponent(section: Record<string, any>) {
if (section.type === 'platform_element') {
return resolvePlatformElement(section);
}
return builtInComponents[section.type] || null;
}
</script>
<template>
<div
v-for="section in sections"
:key="section.id"
:id="section.settings?.anchor_id || undefined"
:class="['section', `section--${section.settings?.padding || 'medium'}`]"
>
<component
:is="resolveComponent(section)"
:section="section"
:config="section.config"
:settings="section.settings"
/>
</div>
</template>Example Platform Component
vue
<!-- components/platform/PopularSaleItems.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const props = defineProps<{
section: Record<string, any>;
config: { max_items?: number; layout?: string };
settings: Record<string, any>;
}>();
const items = ref<any[]>([]);
onMounted(async () => {
// Fetch product data from your platform API
const response = await fetch(`/api/products/popular?limit=${props.config.max_items || 12}`);
const data = await response.json();
items.value = data.products;
});
</script>
<template>
<div :class="['popular-sale-items', `layout--${config.layout || 'carousel'}`]">
<div v-for="item in items" :key="item.id" class="sale-item">
<img :src="item.image" :alt="item.name" />
<h3>{{ item.name }}</h3>
<span class="price">{{ item.price }}</span>
</div>
</div>
</template>See the Platform Elements reference for the full API and storage format.
Pinia Store Integration
typescript
// stores/content.ts
import { defineStore } from 'pinia';
import { useEngageHQ } from '../composables/useEngageHQ';
interface ContentState {
items: Record<string, any>;
lists: Record<string, any[]>;
loading: boolean;
error: string | null;
}
export const useContentStore = defineStore('content', {
state: (): ContentState => ({
items: {},
lists: {},
loading: false,
error: null,
}),
getters: {
getBySlug: (state) => (slug: string) => state.items[slug],
getList: (state) => (key: string) => state.lists[key] || [],
},
actions: {
async fetchBySlug(slug: string) {
if (this.items[slug]) return this.items[slug];
this.loading = true;
this.error = null;
try {
const { fetcher } = useEngageHQ();
const response = await fetcher<any>(`/content/${slug}`);
this.items[slug] = response.data;
return response.data;
} catch (e) {
this.error = (e as Error).message;
throw e;
} finally {
this.loading = false;
}
},
async fetchList(key: string, params: Record<string, any> = {}) {
this.loading = true;
this.error = null;
try {
const { fetcher } = useEngageHQ();
const query = new URLSearchParams(params).toString();
const response = await fetcher<any>(`/content?${query}`);
this.lists[key] = response.data;
return response.data;
} catch (e) {
this.error = (e as Error).message;
throw e;
} finally {
this.loading = false;
}
},
},
});Using the Store
vue
<script setup lang="ts">
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useContentStore } from '../stores/content';
const contentStore = useContentStore();
const { loading, error } = storeToRefs(contentStore);
onMounted(() => {
contentStore.fetchList('featured', { featured: true, per_page: 6 });
});
const featuredContent = computed(() => contentStore.getList('featured'));
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<ContentGrid v-else :items="featuredContent" />
</template>Complete Example App
vue
<!-- App.vue -->
<script setup lang="ts">
import ContentList from './components/ContentList.vue';
import ActiveOffers from './components/ActiveOffers.vue';
</script>
<template>
<main>
<section class="py-8">
<div class="container mx-auto px-4">
<h1 class="text-3xl font-bold mb-6">Featured Content</h1>
<ContentList :featured="true" :limit="6" />
</div>
</section>
<ActiveOffers />
<section class="py-8">
<div class="container mx-auto px-4">
<h2 class="text-2xl font-bold mb-6">Latest Articles</h2>
<ContentList type="article" :limit="9" />
</div>
</section>
</main>
</template>Changelog
| Date | Change |
|---|---|
| 2026-03-24 | Added platform elements composable registry and dynamic component examples. |
| 2026-02-23 | Updated examples for CloudFront CDN and Lambda@Edge delivery. |
| 2026-01-28 | Expanded documentation. |
| 2026-01-15 | Initial publication. |