A real estate listing portal
Search, map, listing detail and enquiry capture — with the agent-facing side built in.
The problem
Property search is a map and a grid showing the same result set, and they have to agree. Filters change both. Clicking a pin scrolls the list; hovering a card highlights the pin. Every portal rebuilds that from scratch, usually around a map library that owns its own state and disagrees with React about who is in charge.
Then the agent side arrives: your sales team wants to hand a buyer a link and walk them through listings live, and an AI assistant should be able to shortlist properties against a brief without anyone screen-scraping the site.
What you are building
Live surfaces, not screenshots — every one composed from the same components you would install. React + Inertia on Laravel. The map is engine-agnostic, so OpenStreetMap in development and Google in production is a one-line swap.
Listing grid
Cards, badges and typography straight from react-fancy — no bespoke CSS.
Map search
fancy-map over OpenStreetMap; the same component swaps to Google without touching your code.
Viewing request
Controlled fields with stable handles, so an agent can fill this without scraping the DOM.
Agent replies within 1 business hour
The code
// `view`, `markers` and `selectedId` are all CONTROLLED, so the map never holds
// state the list disagrees with. Selecting in either updates the same value.
const [selectedId, setSelectedId] = useState<string | null>(null);
const [view, setView] = useState({ center: { lat: 40.015, lng: -105.27 }, zoom: 12 });
<Map
provider={leafletProvider()} // googleProvider({ apiKey }) in production
view={view}
onViewChange={setView}
markers={listings.map((l) => ({
id: l.id,
position: { lat: l.lat, lng: l.lng },
label: formatPrice(l.price),
}))}
selectedId={selectedId}
onSelect={setSelectedId}
/>// Inertia hands the filtered set to the page; the map and the grid render from
// the same array, so they cannot drift apart.
public function index(Request $request): Response
{
$filters = $request->validate([
'min_price' => ['nullable', 'integer'],
'beds' => ['nullable', 'integer', 'min:0'],
'bounds' => ['nullable', 'array'],
]);
return Inertia::render('Listings/Index', [
'filters' => $filters,
'listings' => Listing::query()
->when($filters['min_price'] ?? null, fn ($q, $v) => $q->where('price', '>=', $v))
->when($filters['beds'] ?? null, fn ($q, $v) => $q->where('beds', '>=', $v))
->withinBounds($filters['bounds'] ?? null)
->limit(200)
->get(),
]);
}// The map bridge exposes pan / fit / select as MCP tools, so an assistant works
// the same controlled state a human does -- no DOM scraping, no Playwright.
import { registerMapBridge } from "@particle-academy/agent-integrations";
registerMapBridge(server, {
adapter: {
getView: () => view,
setView,
listMarkers: () => markers,
select: (id) => setSelectedId(id),
},
});How to solve it
Install the map and the kit
The map core carries no engine, so you choose Leaflet or Google per environment without changing component code.
Run thisbash npm i @particle-academy/react-fancy @particle-academy/fancy-mapRender the grid and the map from ONE array
Both read the same server-provided listings. This is the decision that stops the two views disagreeing — everything else follows from it.
Make selection controlled
Hold
selectedIdin the page, not in the map. A pin click and a card click become the same state change, which is what makes hover-to-highlight fall out for free.Capture the enquiry
Controlled fields with stable handles. That is the Human+ requirement, and it is also what makes the form testable without selectors.
Make it findable
Listings are the crawlable part of the business.
fancy-seoserver-renders the head and emits per-listing JSON-LD, so a property page arrives complete in the first byte.
