Vue
This guide builds a project catalogue in a Vue 3 app created with Vite: a small API client, a useAzizApi composable on ref and watchEffect, and three components. No data-fetching library is required.
Prerequisites
Section titled “Prerequisites”- Vue 3.3 or later (for
toValue), bundled with Vite (the key is read fromimport.meta.env). - An agency API key — see Getting started.
- An agency with an approved licence and at least one approved developer contract. See Authentication.
Set up your key
Section titled “Set up your key”-
On estate.azizdubai.com, open API keys and issue a key for the site, for example
Website (browser). Restrict it to the domains that serve the app. -
Add the key to
.env.localin the project root. Vite exposes only variables prefixed withVITE_:.env.local VITE_AZIZ_API_KEY=azk_… -
Make sure
.env.localis listed in.gitignore, and set the same variable in your CI or hosting provider for production builds.
API client
Section titled “API client”The client is the same as in the JavaScript guide, with the key read from the environment. The composable accepts a path and parameters as plain values, refs or getters; watchEffect re-runs the request whenever they change and aborts the previous one.
const AZIZ_API_BASE = 'https://agent-dev.azizdubai.com';const AZIZ_API_KEY = import.meta.env.VITE_AZIZ_API_KEY;
export class AzizApiError extends Error { constructor(status, message) { super(message); this.name = 'AzizApiError'; this.status = status; }}
function toQuery(params) { const query = new URLSearchParams(); for (const [name, value] of Object.entries(params)) { if (value === undefined || value === null || value === '') continue; if (Array.isArray(value)) { if (value.length > 0) query.set(name, value.join(',')); } else { query.set(name, String(value)); } } return query.toString();}
export async function azizFetch(path, params = {}, { method = 'GET', body, signal } = {}) { const url = new URL(path, AZIZ_API_BASE); url.search = toQuery(params);
const headers = { 'X-API-Key': AZIZ_API_KEY, Accept: 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(url, { method, headers, signal, body: body === undefined ? undefined : JSON.stringify(body), });
let payload; try { payload = await response.json(); } catch { throw new AzizApiError(response.status, `unexpected ${response.status} response`); } if (payload.status !== 'success') { throw new AzizApiError(response.status, payload.message ?? 'unknown error'); } return payload.data;}
export function formatPrice(amount, currency = 'AED') { return amount == null ? 'Price on request' : `${currency} ${Math.round(amount).toLocaleString('en')}`;}import { ref, toValue, watchEffect } from 'vue';import { azizFetch } from '../lib/aziz';
export function useAzizApi(path, params = {}) { const data = ref(null); const error = ref(null); const loading = ref(true);
watchEffect((onCleanup) => { // Read the reactive inputs synchronously so watchEffect tracks them. const url = toValue(path); const query = { ...toValue(params) };
const controller = new AbortController(); onCleanup(() => controller.abort()); loading.value = true; error.value = null;
azizFetch(url, query, { signal: controller.signal }) .then((result) => { data.value = result; loading.value = false; }) .catch((err) => { if (err.name === 'AbortError') return; data.value = null; error.value = err; loading.value = false; }); });
return { data, error, loading };}While a new page loads, the composable keeps the previous data, so lists do not flash empty during pagination.
Recipes
Section titled “Recipes”Paginated project list with filters
Section titled “Paginated project list with filters”The form edits a draft; submitting it replaces filters, which the computed params picks up. price_min and price_max are in the key’s prices; bedrooms is a CSV where 0 is a studio and 5 means 5+. See Filtering projects and Pagination.
<script setup>import { computed, reactive, ref } from 'vue';import { useAzizApi } from '../composables/useAzizApi';import { formatPrice } from '../lib/aziz';
const PAGE_SIZE = 12;const BEDROOMS = [ ['0', 'Studio'], ['1', '1'], ['2', '2'], ['3', '3'], ['4', '4'], ['5', '5+'],];
const draft = reactive({ price_min: '', price_max: '', bedrooms: [] });const filters = ref({ ...draft });const offset = ref(0);const params = computed(() => ({ ...filters.value, limit: PAGE_SIZE, offset: offset.value }));const { data, error, loading } = useAzizApi('/projects', params);
const total = computed(() => data.value?.total ?? 0);const items = computed(() => data.value?.items ?? []);
function search() { filters.value = { ...draft, bedrooms: [...draft.bedrooms] }; offset.value = 0;}</script>
<template> <section> <form @submit.prevent="search"> <input v-model="draft.price_min" type="number" min="0" step="1000" placeholder="Min price" /> <input v-model="draft.price_max" type="number" min="0" step="1000" placeholder="Max price" /> <fieldset> <legend>Bedrooms</legend> <label v-for="[value, label] in BEDROOMS" :key="value"> <input v-model="draft.bedrooms" type="checkbox" :value="value" /> {{ label }} </label> </fieldset> <button type="submit">Search</button> </form>
<p v-if="error" role="alert">Could not load projects: {{ error.message }}</p> <p v-else-if="!loading && total === 0">No projects match these filters.</p>
<div class="project-grid" :aria-busy="loading"> <a v-for="project in items" :key="project.id" class="project-card" :href="`/projects/${project.id}`"> <img v-if="project.logo" :src="project.logo" alt="" loading="lazy" /> <h3>{{ project.name }}</h3> <p>{{ project.company?.name }}</p> <p>From {{ formatPrice(project.price_from, project.currency) }}</p> </a> </div>
<nav v-if="total > 0"> <button :disabled="offset === 0" @click="offset = Math.max(0, offset - PAGE_SIZE)">Previous</button> <span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span> <button :disabled="offset + items.length >= total" @click="offset += PAGE_SIZE">Next</button> </nav> </section></template>Project details page
Section titled “Project details page”GET /projects/{id} returns location, cost, payment plan, media and units in one response. Pass the ID from your router — for example, useRoute().params.id in Vue Router. A draft, deleted or unknown project answers 404.
<script setup>import { computed } from 'vue';import { useAzizApi } from '../composables/useAzizApi';import { formatPrice } from '../lib/aziz';import ApplicationForm from './ApplicationForm.vue';
const props = defineProps({ id: { type: [Number, String], required: true } });const { data: project, error, loading } = useAzizApi(() => `/projects/${props.id}`);
const currency = computed(() => project.value?.cost?.currency ?? 'AED');const images = computed(() => (project.value?.media?.images ?? []).filter((image) => image.file));</script>
<template> <p v-if="error?.status === 404">This project is no longer available.</p> <p v-else-if="error" role="alert">Could not load the project: {{ error.message }}</p> <p v-else-if="loading || !project">Loading…</p> <article v-else> <h1>{{ project.name }}</h1> <p>{{ [project.area_name, project.area_city].filter(Boolean).join(', ') }}</p> <div class="gallery"> <img v-for="image in images" :key="image.id" :src="image.file.url" alt="" loading="lazy" /> </div> <p>{{ project.description }}</p>
<table> <thead> <tr><th>Unit</th><th>Bedrooms</th><th>Area, m²</th><th>Price</th></tr> </thead> <tbody> <tr v-for="unit in project.units" :key="unit.id"> <td>{{ unit.unit_number }}</td> <td>{{ unit.bedrooms === 0 ? 'Studio' : unit.bedrooms }}</td> <td>{{ unit.unit_area }}</td> <td>{{ formatPrice(unit.price, currency) }}</td> </tr> </tbody> </table>
<ApplicationForm v-if="project.units.length" :units="project.units" /> </article></template>Unit application form
Section titled “Unit application form”POST /units/{id}/applications requires client.first_name and at least one of client.email or client.mobile_number. A 400 carries the validation message — show it next to the form. See Submitting applications for the full body.
<script setup>import { reactive, ref } from 'vue';import { AzizApiError, azizFetch } from '../lib/aziz';
const props = defineProps({ units: { type: Array, required: true } });
const empty = () => ({ unit_id: props.units[0]?.id, first_name: '', last_name: '', email: '', mobile_number: '', currency: '', message: '',});const form = reactive(empty());const status = ref('idle'); // idle | sending | sentconst feedback = ref('');
const orNull = (value) => (value.trim() === '' ? null : value.trim());
async function submit() { const client = { first_name: orNull(form.first_name), last_name: orNull(form.last_name), email: orNull(form.email), mobile_number: orNull(form.mobile_number), }; if (!client.email && !client.mobile_number) { feedback.value = 'Please enter an email address or a mobile number.'; return; }
status.value = 'sending'; feedback.value = ''; try { await azizFetch(`/units/${form.unit_id}/applications`, {}, { method: 'POST', body: { client, preferences: { based_on_unit: true }, message: orNull(form.message), currency: orNull(form.currency), }, }); Object.assign(form, empty()); status.value = 'sent'; } catch (error) { status.value = 'idle'; if (error instanceof AzizApiError && error.status === 400) { feedback.value = `Please check the form: ${error.message}`; } else if (error instanceof AzizApiError && error.status === 404) { feedback.value = 'This unit is no longer available.'; } else { feedback.value = 'Something went wrong. Please try again later.'; console.error(error); } }}</script>
<template> <p v-if="status === 'sent'" role="status">Thank you! An agent will contact you shortly.</p> <form v-else @submit.prevent="submit"> <select v-model="form.unit_id" required> <option v-for="unit in units" :key="unit.id" :value="unit.id">Unit {{ unit.unit_number }}</option> </select> <input v-model="form.first_name" required maxlength="255" placeholder="First name" /> <input v-model="form.last_name" maxlength="255" placeholder="Last name" /> <input v-model="form.email" type="email" maxlength="255" placeholder="Email" /> <input v-model="form.mobile_number" type="tel" maxlength="50" placeholder="+971 50 000 00 00" /> <select v-model="form.currency"> <option value="">Project currency</option> <option value="AED">AED</option> <option value="USD">USD</option> </select> <textarea v-model="form.message" maxlength="5000" placeholder="Message" /> <!-- Disabled while sending: applications are not idempotent. --> <button type="submit" :disabled="status === 'sending'"> {{ status === 'sending' ? 'Sending…' : 'Send enquiry' }} </button> <p v-if="feedback" role="alert">{{ feedback }}</p> </form></template>Production checklist
Section titled “Production checklist”- Handle
401and403as configuration errors.missing api key,invalid api keyandorigin is not allowed for this api keywill not fix themselves on retry — log them and show a generic message. See Troubleshooting. - Never commit the key. Keep it in
.env.local(gitignored) and in your CI or hosting environment settings. - Cache
GETresponses. Keep list and project responses in a module-levelMaporsessionStoragefor a few minutes, or let a server proxy cache them. Never cachePOSTrequests. - Use separate keys for browser and server. The browser key carries a domain list; a key used by a proxy or backend must have no domain list, because server requests carry no
OriginorRefererand are rejected by a restricted key. - Use a separate key for local development. A key restricted to your production domains rejects requests from
localhost.