Skip to content
Huy Vo
Work

Pi Tek · Pi Group · Real-estate marketplace · public site and admin

PIEX

A real-estate marketplace: a map-first listing portal and an admin for agents, valuation, matching, bidding and contracts.

Screen recording

Role
Software engineer, front-end
Year
2026
Platforms
Web · Admin
Stack
  • Next.js 16
  • React 19
  • MapLibre GL
  • AWS Location
  • Tailwind v4
  • Playwright

PIEX is Pi Group's real-estate marketplace: owners list homes, buyers browse them on a map, and agents value, match and close deals in the admin. It is not public yet; the screens come from a local build with invented listings.

My part. A core front-end developer on the portal and admin since February 2026. The Firebase backend and business logic are the team's.

Outcomes

  • ~400

    commits on the portal and admin, Feb–Nov 2026

    Source: GitHub

  • 12

    admin areas I worked on

    Source: git

What I built

Map-first browsing

Clustered markers with a split list view, controller-driven map fitting, price filtering inside the map, lazy-loaded map bundles and area-range filters.

Listing flow

A multi-step listing request with address geocoding and a picker, owner listing management, and an appointment calendar with booking.

PIEX listing request flow, address step: the address picker dialog with a map preview and pin on Huynh Van Banh street, new or old address format, and province and ward selects.

Admin modules

Listing detail with valuation, contracts and transactions, agents, matching with a dynamic config form, bidding, surveys, points, referrals and member ranking, plus an admin end-to-end suite.

PIEX admin listing detail on the valuation history tab: six price statistic cards (accepted range, lowest, average, highest, valid count 13 of 16) and a three-step processing log that removes outliers with IQR.

Architecture

PORTAL · NEXT.JS 16ADMIN · NEXT.JS 16FIREBASE FUNCTIONS · TEAMDATA & SERVICESMap browsingMapLibre · clustersListing flowaddress · calendarRest of the portalthe teamAdmin modulesvaluation · contractsAdmin e2e suitePlaywright · 87 casesCallable actionsabout 60 modulesloadForMapclusters by box or areaBusiness rulesvaluation · matching · biddingLocation modulenearby places searchFirestorelistings and moreAWS LocationGeoPlaces APICALLABLECALLABLE
The public portal and the agent admin are two Next.js 16 apps that call the team's Firebase callable functions, which keep listings in Firestore and search nearby places through AWS Location.
  • Components:
    • Portal · Next.js 16: Map browsing (MapLibre · clusters), where I worked
    • Portal · Next.js 16: Listing flow (address · calendar), where I worked
    • Portal · Next.js 16: Rest of the portal (the team)
    • Admin · Next.js 16: Admin modules (valuation · contracts), where I worked
    • Admin · Next.js 16: Admin e2e suite (Playwright · 87 cases), where I worked
    • Firebase functions · team: Callable actions (about 60 modules)
    • Firebase functions · team: loadForMap (clusters by box or area)
    • Firebase functions · team: Business rules (valuation · matching · bidding)
    • Firebase functions · team: Location module (nearby places search)
    • Data & services: Firestore (listings and more)
    • Data & services: AWS Location (GeoPlaces API)
  • Connections:
    • Portal · Next.js 16 to Firebase functions · team: callable
    • Admin · Next.js 16 to Firebase functions · team: callable
    • loadForMap to Firestore
    • Location module to AWS Location

Key decisions

  1. Two channels for map URL state

    Filters go through the router and map bounds through a debounced history.replaceState, so panning stays smooth and filters still render on the server.

    Problem, choice and trade-off: Two channels for map URL state
    Problem
    On the map listing, filters and the viewport both live in the URL, but the bounds change many times a second while panning and every router update re-runs the server components.
    Choice
    Filters, page and view go through router.replace; bounds go through history.replaceState, debounced about 300 ms. A filter key that leaves out bounds decides when to re-fit, and one controller owns re-framing while the maps only report their bounds.
    Why
    Panning stays smooth and never resets the camera, while a filter change still re-renders the list on the server. useSearchParams is the single source of truth, with no state copy to drift.
    Trade-off
    Mixing the channels caused a bug: a filter change built from the stale useSearchParams snapshot overwrote a pending bounds write and snapped the map back. Review caught it, and URL writes now start from window.location.
  2. Bounding box made optional

    One backend change made the bbox optional while still requiring a bbox, province or ward, which stopped markers vanishing at province borders.

    Problem, choice and trade-off: Bounding box made optional
    Problem
    The map query required a viewport bbox even when the user only picked a province, and the backend intersected bbox and province, so markers vanished when the viewport crossed a province border.
    Choice
    In one of my few backend changes, bbox became optional in the map query schema, with a refine that still demands at least one of bbox, provinceId or wardId. Cluster mode skips the lat/lng range without a bbox; raw mode still requires it.
    Why
    It removed the double filtering and the vanishing markers without a client-side hack, and the refine means the endpoint never accepts an unbounded scan. Clients that send bbox behave exactly as before.
    Trade-off
    It touched the backend team's contract, so it shipped backward-compatible and separately from the front-end change that uses it.
  3. Typed routes without casts

    I fixed all 55 typedRoutes violations in 38 files without a cast, and added an admin Playwright suite of 87 cases.

    Problem, choice and trade-off: Typed routes without casts
    Problem
    Turning on typedRoutes broke the admin build with 55 violations in 38 files, and the quick ways out were switching it off or casting with 'as Route'.
    Choice
    I fixed every call site without a cast: literal routes for filter sheets and tab bars, UrlObject hrefs for sidebar links, and the history API plus router.refresh for the generic paging and search hooks. I added an admin Playwright suite of 87 cases for paging, search, filters, tabs and navigation.
    Why
    With literal routes, renaming a route folder fails the build instead of shipping a dead link.
    Trade-off
    The route-agnostic hooks go around the router, so the type check does not cover them and the e2e suite has to.

Credit. Firebase backend, business rules and the monorepo setup belong to the Pi Tek team. No real customer data appears in the screens.

Next case

Career GPT

AI recruitment · marketing site, candidate app, back office