Skip to content

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.

  • Vue 3.3 or later (for toValue), bundled with Vite (the key is read from import.meta.env).
  • An agency API key — see Getting started.
  • An agency with an approved licence and at least one approved developer contract. See Authentication.
  1. 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.

  2. Add the key to .env.local in the project root. Vite exposes only variables prefixed with VITE_:

    .env.local
    VITE_AZIZ_API_KEY=azk_…
  3. Make sure .env.local is listed in .gitignore, and set the same variable in your CI or hosting provider for production builds.

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')}`;
}

While a new page loads, the composable keeps the previous data, so lists do not flash empty during pagination.

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.

src/components/ProjectList.vue
<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>

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.

src/components/ProjectPage.vue
<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>

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.

src/components/ApplicationForm.vue
<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 | sent
const 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>
  • Handle 401 and 403 as configuration errors. missing api key, invalid api key and origin is not allowed for this api key will 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 GET responses. Keep list and project responses in a module-level Map or sessionStorage for a few minutes, or let a server proxy cache them. Never cache POST requests.
  • 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 Origin or Referer and are rejected by a restricted key.
  • Use a separate key for local development. A key restricted to your production domains rejects requests from localhost.