Skip to content

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 pinia

Create 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
DateChange
2026-03-24Added platform elements composable registry and dynamic component 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