Concept project · runs in your browserTypeScript · Vue 3 · Pinia · Leaflet
Vue 3 Property Listing with Filters and Leaflet Map Demo
A property search page written in Vue 3 and Pinia, mounted inside this Next.js page. Filter 60 generated listings by price, bedrooms, type and keyword, hover a card to light up its pin on the map, drag the map and press “Search this area”, save a search, then copy the link and open it in a new tab.
Interactive demo
$ mounting Vue app…
## How it works
All state lives in one Pinia setup store. filters and sort are the only inputs; the result list is a computed getter over plain functions in listings.ts, so the list, the count, the pins and the saved-search counts can never disagree.
The list and the map share hoveredId and selectedId. Hovering a card restyles its pin; hovering a pin highlights the card, and clicking a pin scrolls the card into view. Leaflet objects are kept out of Vue reactivity on purpose, and pins are restyled by swapping classes instead of replacing icons, so the hover never flickers.
Filters serialise to a short query string (defaults are dropped). It is written to the URL hash and to localStorage, so a copied link or a reload restores the same search. “Search this area” stores the map bounds as a bbox filter, the same shape a PostGIS ST_MakeEnvelope query takes.
## View source
The code running in the window above, exactly as deployed.
app/lab/vue-property-listing/listings.ts277 lines
/**
* Property search domain logic: seed data, filtering, sorting, saved searches and
* query-string serialisation. No Vue, no DOM — the same module could run in a Nuxt
* server route that answers GET /api/listings?deal=sale&minBeds=2&bbox=…
*/
export type PropertyType = "apartment" | "house" | "townhouse" | "studio";
export type Deal = "sale" | "rent";
export type SortKey = "newest" | "price-asc" | "price-desc" | "size-desc";
export type Listing = {
id: number;
title: string;
street: string;
district: string;
type: PropertyType;
deal: Deal;
/** Sale price, or monthly rent, in EUR. */
price: number;
beds: number;
baths: number;
areaM2: number;
lat: number;
lng: number;
/** Days since it was listed (0 = today). Relative, so the seed never goes stale. */
listedDaysAgo: number;
features: string[];
/** Placeholder "photo" colour. */
hue: number;
};
/** [south, west, north, east] — same order as a PostGIS ST_MakeEnvelope call. */
export type BBox = [number, number, number, number];
export type Filters = {
deal: Deal;
types: PropertyType[];
minPrice: number | null;
maxPrice: number | null;
minBeds: number;
q: string;
bbox: BBox | null;
};
export type SavedSearch = { id: string; name: string; query: string; createdAt: number };
export const TYPES: PropertyType[] = ["apartment", "house", "townhouse", "studio"];
export const SORTS: { key: SortKey; label: string }[] = [
{ key: "newest", label: "Newest first" },
{ key: "price-asc", label: "Price: low to high" },
{ key: "price-desc", label: "Price: high to low" },
{ key: "size-desc", label: "Largest first" },
];
export const DEFAULT_FILTERS: Filters = {
deal: "sale",
types: [],
minPrice: null,
maxPrice: null,
minBeds: 0,
q: "",
bbox: null,
};
/* ------------------------------------------------------------------ */
/* Seed data */
/* ------------------------------------------------------------------ */
/** Deterministic PRNG so every visitor gets the same 60 listings. */
function mulberry32(seed: number) {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Fictional district names placed over a real map area (tiles are OpenStreetMap). */
const DISTRICTS = [
{ name: "Harbour Quarter", lat: 38.7075, lng: -9.1395, premium: 1.35 },
{ name: "Old Hill", lat: 38.7145, lng: -9.1305, premium: 1.15 },
{ name: "Garden Park", lat: 38.7330, lng: -9.1530, premium: 1.2 },
{ name: "Riverside", lat: 38.7020, lng: -9.1700, premium: 1.1 },
{ name: "North Fields", lat: 38.7520, lng: -9.1450, premium: 0.85 },
{ name: "Station Row", lat: 38.7260, lng: -9.1250, premium: 0.95 },
] as const;
const STREETS = ["Linden Way", "Mill Street", "Quay Road", "Orchard Lane", "Chapel Rise", "Tannery Walk", "Beacon Avenue", "Willow Court", "Signal Street", "Maple Terrace"];
const FEATURES = ["Balcony", "Lift", "Parking", "Garden", "Terrace", "River view", "Renovated", "Air conditioning", "Storage room", "Pet friendly"];
const ADJ: Record<PropertyType, string[]> = {
apartment: ["Bright", "Corner", "Top-floor", "Renovated", "Quiet"],
house: ["Family", "Detached", "Character", "Spacious", "Garden"],
townhouse: ["Narrow", "Restored", "Three-storey", "Courtyard", "Classic"],
studio: ["Compact", "Loft", "Smart", "Sunny", "Minimal"],
};
export function seedListings(count = 60, seed = 1042): Listing[] {
const rnd = mulberry32(seed);
const pick = <T,>(arr: readonly T[]) => arr[Math.floor(rnd() * arr.length)]!;
const out: Listing[] = [];
for (let i = 0; i < count; i++) {
const d = pick(DISTRICTS);
const roll = rnd();
const type: PropertyType = roll < 0.45 ? "apartment" : roll < 0.65 ? "house" : roll < 0.82 ? "townhouse" : "studio";
const deal: Deal = rnd() < 0.62 ? "sale" : "rent";
const beds = type === "studio" ? 0 : type === "apartment" ? 1 + Math.floor(rnd() * 3) : 2 + Math.floor(rnd() * 4);
const baths = Math.max(1, Math.min(beds, 1 + Math.floor(rnd() * 3)));
const areaM2 = Math.round(type === "studio" ? 28 + rnd() * 20 : 45 + beds * 22 + rnd() * 40);
const perM2 = deal === "sale" ? 3800 + rnd() * 2200 : 14 + rnd() * 8;
const raw = areaM2 * perM2 * d.premium;
const price = deal === "sale" ? Math.round(raw / 5000) * 5000 : Math.round(raw / 25) * 25;
const feats = FEATURES.filter(() => rnd() < 0.3).slice(0, 4);
if (type === "house" && !feats.includes("Garden")) feats.unshift("Garden");
out.push({
id: 1001 + i,
title: `${pick(ADJ[type])} ${type === "studio" ? "studio" : beds + "-bed " + type}`,
street: `${3 + Math.floor(rnd() * 140)} ${pick(STREETS)}`,
district: d.name,
type,
deal,
price,
beds,
baths,
areaM2,
lat: +(d.lat + (rnd() - 0.5) * 0.016).toFixed(5),
lng: +(d.lng + (rnd() - 0.5) * 0.02).toFixed(5),
listedDaysAgo: Math.floor(rnd() * 45),
features: feats,
hue: Math.floor(rnd() * 360),
});
}
return out;
}
/* ------------------------------------------------------------------ */
/* Query */
/* ------------------------------------------------------------------ */
export const inBBox = (l: Pick<Listing, "lat" | "lng">, [s, w, n, e]: BBox) =>
l.lat >= s && l.lat <= n && l.lng >= w && l.lng <= e;
export function filterListings(all: Listing[], f: Filters): Listing[] {
const q = f.q.trim().toLowerCase();
return all.filter(
(l) =>
l.deal === f.deal &&
(f.types.length === 0 || f.types.includes(l.type)) &&
(f.minPrice === null || l.price >= f.minPrice) &&
(f.maxPrice === null || l.price <= f.maxPrice) &&
l.beds >= f.minBeds &&
(!q || `${l.title} ${l.street} ${l.district} ${l.features.join(" ")}`.toLowerCase().includes(q)) &&
(!f.bbox || inBBox(l, f.bbox)),
);
}
export function sortListings(list: Listing[], key: SortKey): Listing[] {
const by: Record<SortKey, (a: Listing, b: Listing) => number> = {
newest: (a, b) => a.listedDaysAgo - b.listedDaysAgo || a.id - b.id,
"price-asc": (a, b) => a.price - b.price || a.id - b.id,
"price-desc": (a, b) => b.price - a.price || a.id - b.id,
"size-desc": (a, b) => b.areaM2 - a.areaM2 || a.id - b.id,
};
return [...list].sort(by[key]);
}
/** Validation the API would run on the query string: min must not exceed max. */
export function validateFilters(f: Filters): string | null {
if (f.minPrice !== null && f.minPrice < 0) return "Minimum price cannot be negative.";
if (f.minPrice !== null && f.maxPrice !== null && f.minPrice > f.maxPrice)
return "Minimum price is higher than maximum price.";
return null;
}
/* ------------------------------------------------------------------ */
/* URL state */
/* ------------------------------------------------------------------ */
/** Filters + sort → stable query string. Defaults are omitted so URLs stay short. */
export function toQuery(f: Filters, sort: SortKey): string {
const p = new URLSearchParams();
if (f.deal !== DEFAULT_FILTERS.deal) p.set("deal", f.deal);
if (f.types.length) p.set("type", [...f.types].sort().join(","));
if (f.minPrice !== null) p.set("min", String(f.minPrice));
if (f.maxPrice !== null) p.set("max", String(f.maxPrice));
if (f.minBeds) p.set("beds", String(f.minBeds));
if (f.q.trim()) p.set("q", f.q.trim());
if (f.bbox) p.set("bbox", f.bbox.map((n) => n.toFixed(4)).join(","));
if (sort !== "newest") p.set("sort", sort);
return p.toString();
}
const num = (v: string | null) => (v !== null && v !== "" && Number.isFinite(Number(v)) ? Number(v) : null);
/** Query string → filters. Unknown or malformed values fall back to defaults instead of throwing. */
export function fromQuery(qs: string): { filters: Filters; sort: SortKey } {
const p = new URLSearchParams(qs);
const deal = p.get("deal") === "rent" ? "rent" : "sale";
const types = (p.get("type") ?? "").split(",").filter((t): t is PropertyType => (TYPES as string[]).includes(t));
const bboxParts = (p.get("bbox") ?? "").split(",").map(Number);
const bbox = bboxParts.length === 4 && bboxParts.every(Number.isFinite) ? (bboxParts as BBox) : null;
const sortRaw = p.get("sort");
const sort: SortKey = SORTS.some((s) => s.key === sortRaw) ? (sortRaw as SortKey) : "newest";
return {
filters: {
deal,
types,
minPrice: num(p.get("min")),
maxPrice: num(p.get("max")),
minBeds: Math.max(0, Math.min(5, num(p.get("beds")) ?? 0)),
q: p.get("q") ?? "",
bbox,
},
sort,
};
}
/** Human name for a saved search, e.g. "2+ bed apartment for sale, ≤ €450k, in map area". */
export function describeSearch(f: Filters): string {
const parts: string[] = [];
parts.push(f.minBeds ? `${f.minBeds}+ bed` : "Any size");
parts.push(f.types.length ? f.types.join("/") : "home");
parts.push(f.deal === "sale" ? "for sale" : "to rent");
const fmt = (n: number) => formatPrice(n, f.deal, true);
if (f.minPrice !== null && f.maxPrice !== null) parts.push(`${fmt(f.minPrice)}–${fmt(f.maxPrice)}`);
else if (f.maxPrice !== null) parts.push(`≤ ${fmt(f.maxPrice)}`);
else if (f.minPrice !== null) parts.push(`≥ ${fmt(f.minPrice)}`);
if (f.q.trim()) parts.push(`“${f.q.trim()}”`);
if (f.bbox) parts.push("in map area");
return parts.join(" · ");
}
/* ------------------------------------------------------------------ */
/* Formatting & maths */
/* ------------------------------------------------------------------ */
export function formatPrice(n: number, deal: Deal, short = false): string {
if (short && n >= 1_000_000) return `€${(n / 1_000_000).toFixed(n % 1_000_000 ? 2 : 0)}m`;
if (short && n >= 10_000) return `€${Math.round(n / 1000)}k`;
const s = `€${n.toLocaleString("en-GB")}`;
return deal === "rent" && !short ? `${s}/mo` : s;
}
/** Standard annuity formula: monthly repayment for a fixed-rate mortgage. */
export function monthlyPayment(price: number, depositPct: number, ratePct: number, years: number): number {
const principal = price * (1 - depositPct / 100);
const r = ratePct / 100 / 12;
const n = years * 12;
if (r === 0) return Math.round(principal / n);
return Math.round((principal * r) / (1 - Math.pow(1 + r, -n)));
}
export const listedLabel = (days: number) => (days === 0 ? "Listed today" : days === 1 ? "Listed yesterday" : `Listed ${days} days ago`);
/** Great-circle distance in metres (haversine). PostGIS would use ST_Distance on geography. */
export function distanceM(a: Pick<Listing, "lat" | "lng">, b: Pick<Listing, "lat" | "lng">): number {
const R = 6_371_000;
const rad = (d: number) => (d * Math.PI) / 180;
const dLat = rad(b.lat - a.lat);
const dLng = rad(b.lng - a.lng);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
/** The n closest listings with the same deal type: the "similar nearby" strip on the detail page. */
export function nearby(all: Listing[], from: Listing, n = 3): { listing: Listing; metres: number }[] {
return all
.filter((l) => l.id !== from.id && l.deal === from.deal)
.map((l) => ({ listing: l, metres: distanceM(from, l) }))
.sort((a, b) => a.metres - b.metres)
.slice(0, n);
}
export const formatDistance = (m: number) => (m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`);app/lab/vue-property-listing/app/store.ts155 lines
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { clearState, loadState, saveState } from "@/components/lab/vue/persist";
import {
DEFAULT_FILTERS,
describeSearch,
filterListings,
fromQuery,
seedListings,
sortListings,
toQuery,
validateFilters,
type BBox,
type Filters,
type SavedSearch,
type SortKey,
} from "../listings";
const KEY = "lab:vue-property-listing:v1";
type Persisted = {
query: string;
favourites: number[];
saved: SavedSearch[];
};
/** Reads `#search=…` so a copied link restores the same search. */
function queryFromHash(): string | null {
const m = /[#&]search=([^&]*)/.exec(window.location.hash);
return m ? decodeURIComponent(m[1]!) : null;
}
export const useListingsStore = defineStore("listings", () => {
const all = ref(seedListings());
const persisted = loadState<Persisted>(KEY);
const initial = fromQuery(queryFromHash() ?? persisted?.query ?? "");
const filters = ref<Filters>(initial.filters);
const sort = ref<SortKey>(initial.sort);
const favourites = ref<number[]>(persisted?.favourites ?? [1004, 1017]);
const saved = ref<SavedSearch[]>(persisted?.saved ?? []);
const hoveredId = ref<number | null>(null);
const selectedId = ref<number | null>(null);
const detailId = ref<number | null>(null);
const onlyFavourites = ref(false);
/** Mobile only: which pane is visible. Desktop shows both. */
const pane = ref<"list" | "map">("list");
// ---- getters ---------------------------------------------------------
const error = computed(() => validateFilters(filters.value));
const results = computed(() => {
if (error.value) return [];
let list = filterListings(all.value, filters.value);
if (onlyFavourites.value) list = list.filter((l) => favourites.value.includes(l.id));
return sortListings(list, sort.value);
});
const query = computed(() => toQuery(filters.value, sort.value));
const shareUrl = computed(() => `${window.location.origin}${window.location.pathname}#search=${encodeURIComponent(query.value)}`);
const detail = computed(() => all.value.find((l) => l.id === detailId.value) ?? null);
const isFavourite = (id: number) => favourites.value.includes(id);
const activeFilterCount = computed(() => {
const f = filters.value;
return (
f.types.length + (f.minPrice !== null ? 1 : 0) + (f.maxPrice !== null ? 1 : 0) + (f.minBeds ? 1 : 0) + (f.q.trim() ? 1 : 0) + (f.bbox ? 1 : 0)
);
});
// ---- actions ---------------------------------------------------------
function patch(p: Partial<Filters>) {
filters.value = { ...filters.value, ...p };
}
function toggleType(t: Filters["types"][number]) {
const has = filters.value.types.includes(t);
patch({ types: has ? filters.value.types.filter((x) => x !== t) : [...filters.value.types, t] });
}
function setDeal(deal: Filters["deal"]) {
// Sale and rent prices are different scales, so a price range never carries over.
patch({ deal, minPrice: null, maxPrice: null });
}
function searchArea(bbox: BBox | null) {
patch({ bbox });
}
function clearFilters() {
filters.value = { ...DEFAULT_FILTERS, deal: filters.value.deal };
onlyFavourites.value = false;
}
function toggleFavourite(id: number) {
favourites.value = isFavourite(id) ? favourites.value.filter((x) => x !== id) : [...favourites.value, id];
}
function select(id: number | null) {
selectedId.value = id;
}
function openDetail(id: number) {
selectedId.value = id;
detailId.value = id;
}
function closeDetail() {
detailId.value = null;
}
/** Returns false when an identical search is already saved (the UI shows a message). */
function saveSearch(): boolean {
if (saved.value.some((s) => s.query === query.value)) return false;
saved.value = [
{ id: `s${Date.now().toString(36)}`, name: describeSearch(filters.value), query: query.value, createdAt: Date.now() },
...saved.value,
].slice(0, 8);
return true;
}
function applySaved(s: SavedSearch) {
const r = fromQuery(s.query);
filters.value = r.filters;
sort.value = r.sort;
onlyFavourites.value = false;
}
function deleteSaved(id: string) {
saved.value = saved.value.filter((s) => s.id !== id);
}
/** Matches for a saved search right now: what an email alert would count. */
function countFor(s: SavedSearch) {
return filterListings(all.value, fromQuery(s.query).filters).length;
}
function reset() {
clearState(KEY);
filters.value = { ...DEFAULT_FILTERS };
sort.value = "newest";
favourites.value = [1004, 1017];
saved.value = [];
onlyFavourites.value = false;
selectedId.value = null;
detailId.value = null;
hoveredId.value = null;
}
// ---- side effects: persistence + URL --------------------------------
watch(
[query, favourites, saved],
() => {
saveState(KEY, { query: query.value, favourites: favourites.value, saved: saved.value } satisfies Persisted);
// replaceState keeps the back button clean; the hash keeps the Next.js router out of it.
const url = query.value
? `#search=${encodeURIComponent(query.value)}`
: window.location.pathname + window.location.search;
history.replaceState(history.state, "", url);
},
{ deep: true, immediate: true },
);
return {
all, filters, sort, favourites, saved, hoveredId, selectedId, detailId, onlyFavourites, pane,
error, results, query, shareUrl, detail, activeFilterCount,
isFavourite, patch, toggleType, setDeal, searchArea, clearFilters, toggleFavourite, select,
openDetail, closeDetail, saveSearch, applySaved, deleteSaved, countFor, reset,
};
});app/lab/vue-property-listing/app/App.ts112 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables (useXStore) are not React hooks; the React lint rule misreads them. */
import { computed, defineComponent, nextTick, ref, watch } from "vue";
import { FilterBar } from "./FilterBar";
import { ListingCard } from "./ListingCard";
import { ListingDetail } from "./ListingDetail";
import { ListingMap } from "./ListingMap";
import { useListingsStore } from "./store";
export default defineComponent({
name: "PropertySearchApp",
components: { FilterBar, ListingCard, ListingMap, ListingDetail },
setup() {
const store = useListingsStore();
const copied = ref("");
const showSaved = ref(store.saved.length > 0);
const selected = computed(() => store.results.find((l) => l.id === store.selectedId) ?? null);
// Map → list: when a pin is clicked, bring its card into view.
watch(
() => store.selectedId,
async (id) => {
if (id === null) return;
await nextTick();
document.getElementById(`listing-${id}`)?.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
);
async function copyLink() {
try {
await navigator.clipboard.writeText(store.shareUrl);
copied.value = "Link copied. Opening it restores this search.";
} catch {
copied.value = "Copy blocked by the browser. The address bar already has the link.";
}
}
function reset() {
store.reset();
showSaved.value = false;
copied.value = "Demo reset.";
}
return { store, copied, showSaved, selected, copyLink, reset };
},
template: `
<div class="text-fg">
<header class="flex flex-wrap items-center gap-3 border-b border-border bg-bg-elev px-3 py-2 sm:px-4">
<p class="font-semibold"><span class="text-accent">⌂</span> Acme Homes</p>
<span class="hidden font-mono text-xs text-fg-muted sm:inline">property search · Vue 3 + Pinia</span>
<div class="ml-auto flex flex-wrap items-center gap-2 text-xs">
<button type="button" class="rounded border border-border px-2 py-1 font-mono hover:border-accent hover:text-accent" @click="copyLink">copy link</button>
<button type="button" class="rounded border border-border px-2 py-1 font-mono hover:border-accent hover:text-accent"
:aria-expanded="showSaved" aria-controls="pl-saved" @click="showSaved = !showSaved">
saved searches ({{ store.saved.length }})
</button>
<button type="button" class="rounded border border-border px-2 py-1 font-mono hover:border-danger hover:text-danger" @click="reset">reset demo</button>
</div>
</header>
<section v-if="showSaved" id="pl-saved" aria-label="Saved searches" class="border-b border-border bg-bg-muted px-3 py-3 sm:px-4">
<p v-if="!store.saved.length" class="text-sm text-fg-muted">No saved searches yet. Set some filters and press “+ save search”.</p>
<ul v-else class="space-y-1.5">
<li v-for="s in store.saved" :key="s.id" class="flex flex-wrap items-center gap-2 text-sm">
<button type="button" class="min-w-0 flex-1 truncate text-left hover:text-accent" @click="store.applySaved(s)">{{ s.name }}</button>
<span class="font-mono text-xs text-fg-muted">{{ store.countFor(s) }} matches</span>
<button type="button" class="rounded border border-border px-1.5 font-mono text-xs hover:border-danger hover:text-danger"
:aria-label="'Delete saved search: ' + s.name" @click="store.deleteSaved(s.id)">×</button>
</li>
</ul>
</section>
<FilterBar />
<div class="flex items-center justify-between gap-3 px-3 py-2 sm:px-4">
<p aria-live="polite" class="text-sm">
<span class="font-mono font-semibold">{{ store.results.length }}</span>
<span class="text-fg-muted"> {{ store.results.length === 1 ? 'home' : 'homes' }} {{ store.filters.deal === 'sale' ? 'for sale' : 'to rent' }}</span>
</p>
<div class="flex rounded-md border border-border p-0.5 lg:hidden" role="group" aria-label="Show list or map">
<button type="button" class="rounded px-3 py-1 text-xs" :aria-pressed="store.pane === 'list'"
:class="store.pane === 'list' ? 'bg-accent text-accent-fg' : 'text-fg-muted'" @click="store.pane = 'list'">List</button>
<button type="button" class="rounded px-3 py-1 text-xs" :aria-pressed="store.pane === 'map'"
:class="store.pane === 'map' ? 'bg-accent text-accent-fg' : 'text-fg-muted'" @click="store.pane = 'map'">Map</button>
</div>
</div>
<p class="sr-only" aria-live="polite">{{ copied }}</p>
<p v-if="copied" class="px-3 pb-2 text-xs text-accent sm:px-4">{{ copied }}</p>
<div class="grid border-t border-border lg:h-[40rem] lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<section aria-label="Results" class="lg:block lg:overflow-y-auto" :class="store.pane === 'map' ? 'hidden' : ''">
<ol class="space-y-2 p-3 sm:p-4">
<li v-for="l in store.results" :key="l.id"><ListingCard :listing="l" /></li>
</ol>
<div v-if="!store.results.length" class="p-6 text-center text-sm text-fg-muted">
<p v-if="store.error">Fix the price range above to see results.</p>
<template v-else>
<p>No homes match these filters.</p>
<button type="button" class="mt-2 rounded border border-border px-3 py-1 hover:border-accent hover:text-accent" @click="store.clearFilters()">Clear filters</button>
</template>
</div>
</section>
<section aria-label="Map" class="border-border lg:block lg:border-l" :class="store.pane === 'list' ? 'hidden' : ''">
<div class="h-[26rem] lg:h-full"><ListingMap /></div>
<div v-if="selected" class="border-t border-border p-3 lg:hidden"><ListingCard :listing="selected" /></div>
</section>
</div>
<ListingDetail v-if="store.detail" :listing="store.detail" />
</div>
`,
});app/lab/vue-property-listing/app/FilterBar.ts122 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables (useXStore) are not React hooks; the React lint rule misreads them. */
import { computed, defineComponent, ref } from "vue";
import { SORTS, TYPES, formatPrice, type SortKey } from "../listings";
import { useListingsStore } from "./store";
/** Price presets differ for sale and rent; a free-text number is also accepted. */
const PRICE_STEPS = {
sale: [150_000, 250_000, 350_000, 450_000, 600_000, 800_000, 1_000_000],
rent: [800, 1_000, 1_250, 1_500, 2_000, 2_500, 3_500],
} as const;
export const FilterBar = defineComponent({
name: "FilterBar",
setup() {
const store = useListingsStore();
const steps = computed(() => PRICE_STEPS[store.filters.deal]);
const fmt = (n: number) => formatPrice(n, store.filters.deal, true);
const flash = ref("");
let timer: ReturnType<typeof setTimeout> | undefined;
const onPrice = (which: "minPrice" | "maxPrice", e: Event) => {
const v = (e.target as HTMLSelectElement).value;
store.patch({ [which]: v === "" ? null : Number(v) });
};
const onSort = (e: Event) => {
store.sort = (e.target as HTMLSelectElement).value as SortKey;
};
const save = () => {
flash.value = store.saveSearch() ? "Search saved." : "This search is already saved.";
clearTimeout(timer);
timer = setTimeout(() => (flash.value = ""), 2500);
};
return { store, steps, fmt, onPrice, onSort, save, flash, TYPES, SORTS };
},
template: `
<form class="space-y-3 border-b border-border p-3 sm:p-4" role="search" aria-label="Filter listings" @submit.prevent>
<div class="flex flex-wrap items-end gap-3">
<fieldset class="flex rounded-md border border-border p-0.5">
<legend class="sr-only">Buy or rent</legend>
<label v-for="d in ['sale', 'rent']" :key="d"
class="cursor-pointer rounded px-3 py-1.5 text-sm has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-accent"
:class="store.filters.deal === d ? 'bg-accent text-accent-fg' : 'text-fg-muted hover:text-fg'">
<input type="radio" name="deal" class="sr-only" :value="d" :checked="store.filters.deal === d" @change="store.setDeal(d)" />
{{ d === 'sale' ? 'Buy' : 'Rent' }}
</label>
</fieldset>
<label class="min-w-[10rem] flex-1">
<span class="text-xs text-fg-muted">Keyword</span>
<input
:value="store.filters.q"
@input="store.patch({ q: $event.target.value })"
type="search"
placeholder="garden, river view, Old Hill…"
class="mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-1.5 text-sm outline-none focus:border-accent"
/>
</label>
<label>
<span class="text-xs text-fg-muted">Min price</span>
<select :value="store.filters.minPrice ?? ''" @change="onPrice('minPrice', $event)"
class="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm" :aria-invalid="!!store.error">
<option value="">No min</option>
<option v-for="s in steps" :key="s" :value="s">{{ fmt(s) }}</option>
</select>
</label>
<label>
<span class="text-xs text-fg-muted">Max price</span>
<select :value="store.filters.maxPrice ?? ''" @change="onPrice('maxPrice', $event)"
class="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm" :aria-invalid="!!store.error"
:aria-describedby="store.error ? 'pl-filter-error' : undefined">
<option value="">No max</option>
<option v-for="s in steps" :key="s" :value="s">{{ fmt(s) }}</option>
</select>
</label>
<label>
<span class="text-xs text-fg-muted">Bedrooms</span>
<select :value="store.filters.minBeds" @change="store.patch({ minBeds: Number($event.target.value) })"
class="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm">
<option :value="0">Any</option>
<option v-for="n in 4" :key="n" :value="n">{{ n }}+</option>
</select>
</label>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-fg-muted" id="pl-type-label">Type</span>
<div class="flex flex-wrap gap-1.5" role="group" aria-labelledby="pl-type-label">
<button v-for="t in TYPES" :key="t" type="button" :aria-pressed="store.filters.types.includes(t)"
class="rounded-full border px-2.5 py-0.5 text-xs capitalize"
:class="store.filters.types.includes(t) ? 'border-accent bg-accent-soft text-accent' : 'border-border text-fg-muted hover:text-fg'"
@click="store.toggleType(t)">{{ t }}</button>
</div>
<button v-if="store.filters.bbox" type="button" class="rounded-full border border-accent-2 px-2.5 py-0.5 text-xs text-accent-2"
@click="store.searchArea(null)" aria-label="Remove the map area filter">map area ×</button>
<label class="ml-auto flex items-center gap-2 text-xs text-fg-muted">
Sort
<select :value="store.sort" @change="onSort" class="rounded-md border border-border bg-bg-elev px-2 py-1 text-xs text-fg">
<option v-for="s in SORTS" :key="s.key" :value="s.key">{{ s.label }}</option>
</select>
</label>
</div>
<p v-if="store.error" id="pl-filter-error" role="alert" class="text-sm text-danger">{{ store.error }}</p>
<div class="flex flex-wrap items-center gap-2 text-xs">
<button type="button" class="rounded border border-border px-2 py-1 font-mono hover:border-accent hover:text-accent" @click="save">
+ save search
</button>
<button type="button" class="rounded border border-border px-2 py-1 font-mono hover:border-accent hover:text-accent"
:aria-pressed="store.onlyFavourites" @click="store.onlyFavourites = !store.onlyFavourites">
{{ store.onlyFavourites ? '♥ favourites only' : '♡ show favourites' }} ({{ store.favourites.length }})
</button>
<button v-if="store.activeFilterCount" type="button" class="rounded border border-border px-2 py-1 font-mono text-fg-muted hover:text-fg" @click="store.clearFilters()">
clear {{ store.activeFilterCount }} filter{{ store.activeFilterCount === 1 ? '' : 's' }}
</button>
<span aria-live="polite" class="text-accent">{{ flash }}</span>
</div>
</form>
`,
});app/lab/vue-property-listing/app/ListingMap.ts167 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables (useXStore) are not React hooks; the React lint rule misreads them. */
import { defineComponent, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type * as Leaflet from "leaflet";
import { formatPrice, type BBox, type Listing } from "../listings";
import { OSM_ATTRIBUTION, OSM_URL, loadLeaflet } from "./leaflet";
import { useListingsStore } from "./store";
const PIN = "inline-block whitespace-nowrap rounded-full border px-2 py-0.5 font-mono text-[11px] font-semibold shadow-md transition-colors";
const PIN_IDLE = "border-border bg-bg-elev text-fg";
const PIN_ACTIVE = "border-accent bg-accent text-accent-fg";
/**
* Leaflet map synced with the store. The Leaflet objects live in plain closure variables,
* never in reactive state: Vue proxies would wrap every internal Leaflet object for nothing.
*/
export const ListingMap = defineComponent({
name: "ListingMap",
setup() {
const store = useListingsStore();
const el = ref<HTMLDivElement | null>(null);
const moved = ref(false);
const failed = ref(false);
let L: typeof Leaflet | null = null;
let map: Leaflet.Map | null = null;
let layer: Leaflet.LayerGroup | null = null;
let areaRect: Leaflet.Rectangle | null = null;
let resize: ResizeObserver | null = null;
let programmatic = false;
const markers = new Map<number, Leaflet.Marker>();
const pinHtml = (l: Listing, active: boolean) =>
`<span class="${PIN} ${active ? PIN_ACTIVE : PIN_IDLE}" style="position:absolute;left:0;top:0;transform:translate(-50%,-130%)">${formatPrice(l.price, l.deal, true)}</span>`;
function styleMarker(id: number) {
const m = markers.get(id);
const pin = m?.getElement()?.querySelector("span");
if (!m || !pin) return;
const active = store.hoveredId === id || store.selectedId === id;
// Swap classes on the existing element. Replacing the icon would fire mouseout under the cursor.
pin.className = `${PIN} ${active ? PIN_ACTIVE : PIN_IDLE}`;
m.setZIndexOffset(active ? 1000 : 0);
}
function fit() {
if (!map || !L || store.filters.bbox || !store.results.length) return;
programmatic = true;
map.fitBounds(L.latLngBounds(store.results.map((l) => [l.lat, l.lng] as [number, number])), { padding: [36, 36], maxZoom: 15 });
moved.value = false;
}
function drawMarkers() {
if (!map || !L || !layer) return;
layer.clearLayers();
markers.clear();
for (const l of store.results) {
const m = L.marker([l.lat, l.lng], {
icon: L.divIcon({ className: "", html: pinHtml(l, false), iconSize: [0, 0] }),
title: `${l.title}, ${formatPrice(l.price, l.deal)}`,
keyboard: true,
});
m.on("mouseover", () => (store.hoveredId = l.id));
m.on("mouseout", () => (store.hoveredId = null));
m.on("click", () => store.select(l.id));
m.on("keypress", (e: Leaflet.LeafletKeyboardEvent) => {
if (e.originalEvent.key === "Enter") store.openDetail(l.id);
});
m.addTo(layer);
markers.set(l.id, m);
styleMarker(l.id);
}
}
function drawArea(b: BBox | null) {
if (!map || !L) return;
areaRect?.remove();
areaRect = null;
if (b) {
areaRect = L.rectangle(
[[b[0], b[1]], [b[2], b[3]]],
{ color: "#60a5fa", weight: 1.5, dashArray: "4 4", fillOpacity: 0.04, interactive: false },
).addTo(map);
}
}
function searchThisArea() {
if (!map) return;
const b = map.getBounds();
store.searchArea([b.getSouth(), b.getWest(), b.getNorth(), b.getEast()]);
moved.value = false;
}
onMounted(async () => {
try {
L = await loadLeaflet();
} catch {
failed.value = true;
return;
}
if (!el.value) return;
map = L.map(el.value, { zoomControl: true, scrollWheelZoom: false }).setView([38.722, -9.145], 13);
L.tileLayer(OSM_URL, { maxZoom: 19, attribution: OSM_ATTRIBUTION }).addTo(map);
layer = L.layerGroup().addTo(map);
map.on("moveend", () => {
if (programmatic) programmatic = false;
else moved.value = true;
});
// The map can start hidden (mobile list view): re-measure whenever the box changes size.
resize = new ResizeObserver(() => map?.invalidateSize());
resize.observe(el.value);
drawMarkers();
drawArea(store.filters.bbox);
fit();
});
// Results changed → redraw pins and refit (unless the user pinned a map area).
watch(
() => store.results.map((l) => l.id).join(","),
() => {
drawMarkers();
fit();
},
);
watch(() => store.filters.bbox, (b) => drawArea(b));
// List → map: highlight the hovered/selected pin. Selecting also pans to it.
watch(
() => [store.hoveredId, store.selectedId] as const,
([h, s], [ph, ps]) => {
for (const id of new Set([h, s, ph, ps])) if (id !== null && id !== undefined) styleMarker(id);
if (s !== ps && s !== null && map) {
const l = store.all.find((x) => x.id === s);
if (l && !map.getBounds().contains([l.lat, l.lng])) {
programmatic = true;
map.panTo([l.lat, l.lng]);
}
}
},
);
onBeforeUnmount(() => {
resize?.disconnect();
map?.remove();
map = null;
});
return { el, moved, failed, store, searchThisArea, fit };
},
template: `
<div class="relative h-full min-h-[22rem]">
<div ref="el" class="absolute inset-0 z-0" aria-label="Map of the listings. The list next to it contains the same results." role="region"></div>
<div class="pointer-events-none absolute inset-x-0 top-3 z-[500] flex justify-center gap-2">
<button v-if="moved" type="button" class="pointer-events-auto rounded-full bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg shadow-lg"
@click="searchThisArea">Search this area</button>
<button v-if="store.filters.bbox" type="button" class="pointer-events-auto rounded-full border border-border bg-bg-elev px-3 py-1.5 text-sm shadow-lg"
@click="store.searchArea(null)">Clear area</button>
</div>
<p v-if="failed" role="alert" class="absolute inset-0 grid place-items-center p-6 text-center text-sm text-danger">
The map could not load. The list still shows every result.
</p>
<p v-else-if="!store.results.length" class="pointer-events-none absolute inset-x-0 bottom-8 z-[500] mx-auto w-fit rounded-md bg-bg-elev px-3 py-1.5 text-xs text-fg-muted shadow">
No listings match. Widen the filters or clear the area.
</p>
</div>
`,
});app/lab/vue-property-listing/app/ListingCard.ts121 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables (useXStore) are not React hooks; the React lint rule misreads them. */
import { computed, defineComponent, type PropType } from "vue";
import { formatPrice, listedLabel, type Listing } from "../listings";
import { useListingsStore } from "./store";
let uid = 0;
/** Placeholder "photo": an SVG scene tinted per listing. No external images. */
export const PropertyPhoto = defineComponent({
name: "PropertyPhoto",
props: {
hue: { type: Number, required: true },
variant: { type: Number, default: 0 },
label: { type: String, required: true },
},
setup(props) {
const gid = `ph-grad-${++uid}`;
const c1 = computed(() => `hsl(${props.hue} 45% 62%)`);
const c2 = computed(() => `hsl(${(props.hue + 40) % 360} 40% 38%)`);
return { gid, c1, c2 };
},
template: `
<svg viewBox="0 0 160 100" class="block h-full w-full" role="img" :aria-label="label" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient :id="gid" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" :stop-color="c1" />
<stop offset="1" :stop-color="c2" />
</linearGradient>
</defs>
<rect width="160" height="100" :fill="'url(#' + gid + ')'" />
<g v-if="variant === 0" fill="#fff" fill-opacity="0.85">
<polygon points="40,50 80,24 120,50" fill-opacity="0.6" />
<rect x="48" y="50" width="64" height="40" />
<rect x="56" y="58" width="12" height="12" :fill="c2" />
<rect x="92" y="58" width="12" height="12" :fill="c2" />
<rect x="74" y="68" width="12" height="22" :fill="c2" />
<rect x="0" y="90" width="160" height="10" fill-opacity="0.35" />
</g>
<g v-else-if="variant === 1" fill="#fff" fill-opacity="0.8">
<rect x="62" y="14" width="36" height="34" fill-opacity="0.35" />
<rect x="30" y="62" width="80" height="20" rx="4" />
<rect x="30" y="54" width="14" height="28" rx="4" />
<rect x="96" y="54" width="14" height="28" rx="4" />
<rect x="118" y="70" width="20" height="12" rx="2" fill-opacity="0.55" />
<rect x="0" y="82" width="160" height="18" fill-opacity="0.25" />
</g>
<g v-else-if="variant === 2" fill="#fff" fill-opacity="0.8">
<rect x="16" y="20" width="128" height="16" fill-opacity="0.45" />
<rect x="16" y="56" width="128" height="30" />
<rect x="26" y="62" width="22" height="18" :fill="c2" />
<rect x="58" y="62" width="22" height="18" :fill="c2" />
<circle cx="112" cy="50" r="5" fill-opacity="0.6" />
<circle cx="126" cy="50" r="5" fill-opacity="0.6" />
</g>
<g v-else fill="#fff" fill-opacity="0.8">
<rect x="40" y="52" width="80" height="30" rx="3" />
<rect x="40" y="40" width="80" height="14" rx="3" fill-opacity="0.55" />
<rect x="48" y="46" width="24" height="10" rx="3" />
<rect x="88" y="46" width="24" height="10" rx="3" />
<rect x="124" y="16" width="20" height="28" fill-opacity="0.35" />
</g>
</svg>
`,
});
export const ListingCard = defineComponent({
name: "ListingCard",
components: { PropertyPhoto },
props: {
listing: { type: Object as PropType<Listing>, required: true },
},
setup(props) {
const store = useListingsStore();
const active = computed(() => store.hoveredId === props.listing.id || store.selectedId === props.listing.id);
const fav = computed(() => store.isFavourite(props.listing.id));
const price = computed(() => formatPrice(props.listing.price, props.listing.deal));
const listed = computed(() => listedLabel(props.listing.listedDaysAgo));
return { store, active, fav, price, listed };
},
template: `
<article
:id="'listing-' + listing.id"
class="group relative flex gap-3 rounded-lg border p-2 transition-colors"
:class="active ? 'border-accent bg-accent-soft/50' : 'border-border bg-bg-elev hover:border-fg-muted'"
@mouseenter="store.hoveredId = listing.id"
@mouseleave="store.hoveredId = null"
@focusin="store.hoveredId = listing.id"
@focusout="store.hoveredId = null"
>
<div class="h-24 w-28 shrink-0 overflow-hidden rounded-md sm:w-32">
<PropertyPhoto :hue="listing.hue" :label="'Placeholder photo of ' + listing.title" />
</div>
<div class="min-w-0 flex-1">
<p class="font-mono text-base font-semibold">{{ price }}</p>
<h3 class="truncate text-sm font-medium">
<button
type="button"
class="text-left after:absolute after:inset-0 hover:text-accent"
@click="store.openDetail(listing.id)"
>{{ listing.title }}</button>
</h3>
<p class="truncate text-xs text-fg-muted">{{ listing.street }}, {{ listing.district }}</p>
<p class="mt-1 flex flex-wrap gap-x-3 font-mono text-[11px] text-fg-muted">
<span>{{ listing.beds === 0 ? 'studio' : listing.beds + ' bd' }}</span>
<span>{{ listing.baths }} ba</span>
<span>{{ listing.areaM2 }} m²</span>
<span class="hidden sm:inline">{{ listed }}</span>
</p>
</div>
<button
type="button"
class="relative z-10 self-start rounded-md border px-2 py-1 text-sm leading-none"
:class="fav ? 'border-accent-3 text-accent-3' : 'border-border text-fg-muted hover:text-accent-3'"
:aria-pressed="fav"
:aria-label="(fav ? 'Remove ' : 'Save ') + listing.title + ' ' + (fav ? 'from' : 'to') + ' favourites'"
@click="store.toggleFavourite(listing.id)"
>{{ fav ? '♥' : '♡' }}</button>
</article>
`,
});app/lab/vue-property-listing/app/ListingDetail.ts185 lines
/* eslint-disable react-hooks/rules-of-hooks -- Vue composables (useXStore) are not React hooks; the React lint rule misreads them. */
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
import type * as Leaflet from "leaflet";
import { formatDistance, formatPrice, listedLabel, monthlyPayment, nearby, type Listing } from "../listings";
import { PropertyPhoto } from "./ListingCard";
import { OSM_ATTRIBUTION, OSM_URL, loadLeaflet } from "./leaflet";
import { useListingsStore } from "./store";
const ROOMS = ["Front", "Living room", "Kitchen", "Bedroom"];
/** Small static location map for the detail view. */
const MiniMap = defineComponent({
name: "MiniMap",
props: { listing: { type: Object as PropType<Listing>, required: true } },
setup(props) {
const el = ref<HTMLDivElement | null>(null);
let map: Leaflet.Map | null = null;
let dot: Leaflet.CircleMarker | null = null;
onMounted(async () => {
const L = await loadLeaflet().catch(() => null);
if (!L || !el.value) return;
map = L.map(el.value, { zoomControl: false, scrollWheelZoom: false, dragging: false, keyboard: false }).setView(
[props.listing.lat, props.listing.lng],
15,
);
L.tileLayer(OSM_URL, { maxZoom: 19, attribution: OSM_ATTRIBUTION }).addTo(map);
dot = L.circleMarker([props.listing.lat, props.listing.lng], { radius: 9, color: "#16a34a", fillColor: "#4ade80", fillOpacity: 0.9, weight: 3 }).addTo(map);
});
watch(
() => props.listing.id,
() => {
map?.setView([props.listing.lat, props.listing.lng], 15);
dot?.setLatLng([props.listing.lat, props.listing.lng]);
},
);
onBeforeUnmount(() => map?.remove());
return { el };
},
template: `<div ref="el" class="h-40 w-full overflow-hidden rounded-md border border-border" role="img" :aria-label="'Location of ' + listing.street + ', ' + listing.district"></div>`,
});
export const ListingDetail = defineComponent({
name: "ListingDetail",
components: { PropertyPhoto, MiniMap },
props: { listing: { type: Object as PropType<Listing>, required: true } },
setup(props) {
const store = useListingsStore();
const photo = ref(0);
const closeBtn = ref<HTMLButtonElement | null>(null);
const deposit = ref(20);
const rate = ref(3.6);
const years = ref(30);
const returnFocus = document.activeElement as HTMLElement | null;
const price = computed(() => formatPrice(props.listing.price, props.listing.deal));
const perM2 = computed(() => formatPrice(Math.round(props.listing.price / props.listing.areaM2), "sale"));
const monthly = computed(() =>
monthlyPayment(props.listing.price, clamp(deposit.value, 0, 90), clamp(rate.value, 0, 15), clamp(years.value, 5, 40)),
);
const moveIn = computed(() => props.listing.price * 3); // first month + two months' deposit
const close = computed(() => nearby(store.all, props.listing, 3));
const fav = computed(() => store.isFavourite(props.listing.id));
const listed = computed(() => listedLabel(props.listing.listedDaysAgo));
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") store.closeDetail();
if (e.key === "ArrowRight") photo.value = (photo.value + 1) % 4;
if (e.key === "ArrowLeft") photo.value = (photo.value + 3) % 4;
};
onMounted(() => {
window.addEventListener("keydown", onKey);
nextTick(() => closeBtn.value?.focus());
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKey);
returnFocus?.focus?.();
});
watch(
() => props.listing.id,
() => (photo.value = 0),
);
return { store, photo, closeBtn, deposit, rate, years, price, perM2, monthly, moveIn, close, fav, listed, formatPrice, formatDistance, ROOMS };
},
template: `
<div class="fixed inset-0 z-[1000] flex items-end justify-center bg-black/60 sm:items-center sm:p-4" @click.self="store.closeDetail()">
<div role="dialog" aria-modal="true" aria-labelledby="pl-detail-title"
class="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-t-xl border border-border bg-bg shadow-2xl sm:rounded-xl">
<div class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-bg-elev px-4 py-2">
<p class="truncate font-mono text-xs text-fg-muted">#{{ listing.id }} · {{ listing.district }}</p>
<button ref="closeBtn" type="button" class="rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-accent" @click="store.closeDetail()">
close <span class="text-fg-muted">esc</span>
</button>
</div>
<div class="grid gap-0 md:grid-cols-[1.3fr_1fr]">
<section aria-label="Photos" class="p-4">
<div class="aspect-[16/10] overflow-hidden rounded-lg">
<PropertyPhoto :hue="listing.hue" :variant="photo" :label="ROOMS[photo] + ' (placeholder)'" />
</div>
<div class="mt-2 grid grid-cols-4 gap-2">
<button v-for="(room, i) in ROOMS" :key="room" type="button"
class="aspect-[16/10] overflow-hidden rounded border-2"
:class="photo === i ? 'border-accent' : 'border-transparent opacity-70 hover:opacity-100'"
:aria-label="'Show ' + room + ' photo'" :aria-current="photo === i"
@click="photo = i">
<PropertyPhoto :hue="listing.hue" :variant="i" :label="room" />
</button>
</div>
<p class="mt-1 text-[11px] text-fg-muted">Generated placeholder images. Use ← → to browse.</p>
</section>
<section class="space-y-4 p-4 md:border-l md:border-border">
<div>
<p class="font-mono text-2xl font-bold">{{ price }}</p>
<h2 id="pl-detail-title" class="text-lg font-semibold">{{ listing.title }}</h2>
<p class="text-sm text-fg-muted">{{ listing.street }}, {{ listing.district }}</p>
<p class="mt-1 text-xs text-fg-muted">{{ listed }}</p>
</div>
<dl class="grid grid-cols-3 gap-2 text-center">
<div class="rounded-md bg-bg-muted p-2"><dt class="text-[11px] text-fg-muted">Bedrooms</dt><dd class="font-mono">{{ listing.beds || 'studio' }}</dd></div>
<div class="rounded-md bg-bg-muted p-2"><dt class="text-[11px] text-fg-muted">Bathrooms</dt><dd class="font-mono">{{ listing.baths }}</dd></div>
<div class="rounded-md bg-bg-muted p-2"><dt class="text-[11px] text-fg-muted">Area</dt><dd class="font-mono">{{ listing.areaM2 }} m²</dd></div>
</dl>
<p class="text-xs text-fg-muted">{{ perM2 }} per m²{{ listing.deal === 'rent' ? ' per month' : '' }} · <span class="capitalize">{{ listing.type }}</span></p>
<ul class="flex flex-wrap gap-1.5" aria-label="Features">
<li v-for="f in listing.features" :key="f" class="rounded-full border border-border px-2 py-0.5 text-xs">{{ f }}</li>
<li v-if="!listing.features.length" class="text-xs text-fg-muted">No extra features listed.</li>
</ul>
<button type="button" class="w-full rounded-md border px-3 py-2 text-sm"
:class="fav ? 'border-accent-3 text-accent-3' : 'border-border hover:border-accent-3 hover:text-accent-3'"
:aria-pressed="fav" @click="store.toggleFavourite(listing.id)">
{{ fav ? '♥ Saved to favourites' : '♡ Save to favourites' }}
</button>
</section>
</div>
<div class="grid gap-4 border-t border-border p-4 md:grid-cols-2">
<section v-if="listing.deal === 'sale'" aria-labelledby="pl-mortgage">
<h3 id="pl-mortgage" class="font-mono text-sm font-semibold">Mortgage estimate</h3>
<div class="mt-2 grid grid-cols-3 gap-2">
<label class="text-xs text-fg-muted">Deposit %
<input v-model.number="deposit" type="number" min="0" max="90" class="mt-1 w-full rounded border border-border bg-bg-elev px-2 py-1 font-mono text-sm text-fg" />
</label>
<label class="text-xs text-fg-muted">Rate %
<input v-model.number="rate" type="number" min="0" max="15" step="0.1" class="mt-1 w-full rounded border border-border bg-bg-elev px-2 py-1 font-mono text-sm text-fg" />
</label>
<label class="text-xs text-fg-muted">Years
<input v-model.number="years" type="number" min="5" max="40" class="mt-1 w-full rounded border border-border bg-bg-elev px-2 py-1 font-mono text-sm text-fg" />
</label>
</div>
<p class="mt-2 text-sm" aria-live="polite">About <span class="font-mono font-semibold text-accent">{{ formatPrice(monthly, 'sale') }}</span> per month</p>
</section>
<section v-else aria-labelledby="pl-movein">
<h3 id="pl-movein" class="font-mono text-sm font-semibold">Move-in cost</h3>
<p class="mt-2 text-sm">First month plus a two-month deposit:
<span class="font-mono font-semibold text-accent">{{ formatPrice(moveIn, 'sale') }}</span></p>
</section>
<section aria-labelledby="pl-location">
<h3 id="pl-location" class="mb-2 font-mono text-sm font-semibold">Location</h3>
<MiniMap :listing="listing" />
</section>
</div>
<section class="border-t border-border p-4" aria-labelledby="pl-nearby">
<h3 id="pl-nearby" class="font-mono text-sm font-semibold">Similar nearby</h3>
<ul class="mt-2 grid gap-2 sm:grid-cols-3">
<li v-for="n in close" :key="n.listing.id">
<button type="button" class="w-full rounded-md border border-border p-2 text-left text-sm hover:border-accent" @click="store.openDetail(n.listing.id)">
<span class="block font-mono font-semibold">{{ formatPrice(n.listing.price, n.listing.deal) }}</span>
<span class="block truncate">{{ n.listing.title }}</span>
<span class="block text-xs text-fg-muted">{{ formatDistance(n.metres) }} away</span>
</button>
</li>
</ul>
</section>
</div>
</div>
`,
});
function clamp(n: number, min: number, max: number) {
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : min;
}app/lab/vue-property-listing/app/leaflet.ts13 lines
import type * as Leaflet from "leaflet";
export type LeafletNS = typeof Leaflet;
/** Leaflet is a UMD bundle: depending on the bundler the API is on the namespace or on `default`. */
export async function loadLeaflet(): Promise<LeafletNS> {
const mod = await import("leaflet");
return ((mod as unknown as { default?: LeafletNS }).default ?? mod) as LeafletNS;
}
export const OSM_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
export const OSM_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';app/lab/vue-property-listing/Demo.tsx12 lines
"use client";
import "leaflet/dist/leaflet.css";
import { VueIsland } from "@/components/lab/vue/VueIsland";
// Module scope keeps the loader stable, so the Vue app mounts once.
const load = () => import("./app/App");
export default function Demo() {
return <VueIsland load={load} label="Property search demo, built with Vue 3 and Pinia" minHeight="52rem" />;
}components/lab/vue/VueIsland.tsx82 lines
"use client";
import { useEffect, useRef, useState } from "react";
import type { App, Component } from "vue";
type Props = {
/** Stable loader for the Vue root component, e.g. `const load = () => import("./app/App")` at module scope. */
load: () => Promise<{ default: Component }>;
/** Accessible name for the region the Vue app renders into. */
label: string;
/** Reserve height while Vue loads so the page does not jump. */
minHeight?: string;
};
/**
* Mounts a Vue 3 + Pinia app inside a React (Next.js) page.
*
* React owns the outer <div>; Vue owns everything inside it. The full Vue build is imported
* (not the runtime-only one) because the demo components use `template` strings, which need
* the in-browser template compiler. Importing it once registers the compiler for every
* component, so the components themselves import from plain "vue".
*/
export function VueIsland({ load, label, minHeight = "32rem" }: Props) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
let app: App | null = null;
let cancelled = false;
(async () => {
try {
// Vue's esm-bundler build expects these compile-time flags. next.config.ts does not define
// them (no DefinePlugin), so set them as globals before Vue loads to keep the console clean.
Object.assign(globalThis, {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
});
const [{ createApp }, { createPinia }, root] = await Promise.all([
import("vue/dist/vue.esm-bundler.js"),
import("pinia"),
load(),
]);
if (cancelled || !host.current) return;
app = createApp(root.default);
app.use(createPinia());
app.config.errorHandler = (err) => {
console.error("[vue]", err);
};
app.mount(host.current);
setState("ready");
} catch (err) {
console.error("[VueIsland]", err);
if (!cancelled) setState("error");
}
})();
return () => {
cancelled = true;
app?.unmount();
app = null;
};
}, [load]);
return (
<div className="relative" style={{ minHeight }} role="region" aria-label={label} aria-busy={state === "loading"}>
<div ref={host} />
{state === "loading" && (
<p className="absolute inset-0 grid place-items-center font-mono text-sm text-fg-muted">
<span>
<span className="text-accent">$</span> mounting Vue app…
</span>
</p>
)}
{state === "error" && (
<p role="alert" className="absolute inset-0 grid place-items-center p-6 text-center text-sm text-danger">
The Vue app failed to load. Reload the page to try again.
</p>
)}
</div>
);
}