HETIBANK SDK Read the docs

Websites guide

Build a brokerage-branded search experience on the HETIBANK API.

Install

pnpm add @hetibank/websites @hetibank/ui @hetibank/theme @hetibank/contracts @hetibank/client

Import styles once per page:

import "@hetibank/theme/styles.css";
import "@hetibank/ui/styles.css";
import "@hetibank/websites/styles.css";

The shape of the work

Your app owns data fetching, routing and auth. The SDK owns presentation:

API ──► buyer-ui envelope (property_card, financing_comparison, …) ──► <PropertyCard />

Components never fetch and never compute. That is deliberate: the same envelope renders on the website, in the chat kit and inside a widget with identical figures.

Minimal page

import { AppShell, ShellHeader, ShellFooter, BrandMark, ThemeProvider, EmptyState } from "@hetibank/ui";
import { PropertyCard, SearchResultsGrid, BuyerUiComponentView } from "@hetibank/websites";
import { createBrandTheme } from "@hetibank/theme";
import type { BuyerUiComponent } from "@hetibank/contracts";

const theme = createBrandTheme({
  brand: {
    name: "Desert Bloom Realty",
    primary: "#5b3a29",
    accent: "#c2703d",
    logoUrl: "/brand/desert-bloom.svg",
  },
});

export function ResultsPage({ cards }: { cards: BuyerUiComponent[] }) {
  return (
    <ThemeProvider theme={theme} as="section">
      <AppShell
        header={
          <ShellHeader
            brand={<BrandMark name={theme.brand.name} logoUrl={theme.brand.logoUrl} href="/" />}
            actions={<a className="hb-btn hb-btn--primary hb-btn--sm" href="/search">Search</a>}
          />
        }
        footer={
          <ShellFooter
            attribution="Listing data © Las Vegas REALTORS®. Figures estimated by HETIBANK."
            links={[{ label: "Privacy", href: "/privacy" }, { label: "Terms", href: "/terms" }]}
          />
        }
      >
        {cards.length === 0 ? (
          <EmptyState title="No homes match yet" description="Widen the budget or add a nearby ZIP code." />
        ) : (
          <SearchResultsGrid
            items={cards.map((card) => ({ key: card.component + (card.data as { listingKey?: string }).listingKey, card }))}
            renderItem={({ card }) => <PropertyCard component={card} onAction={(key, payload) => console.log(key, payload)} />}
          />
        )}
      </AppShell>
    </ThemeProvider>
  );
}

Property card

PropertyCard renders the canonical property_card envelope payment-first:

  • estimated monthly payment and cash needed lead;
  • loan program (FHA/VA), balance, rate and second mortgage follow, each with CertaintyBadge;
  • list price and HEstimate are secondary;
  • a missing figure renders Unavailable, never 0;
  • nextAction becomes the primary action; onAction receives its key.

Do not pass a hand-built object: validate with propertyCardSchema.parse(payload) (or use the client, which returns validated BuyerUiComponents) so a contract drift fails loudly instead of rendering a half-empty card.

Search filters and scenario

const [criteria, setCriteria] = useState({ availableCash: 40000, maxMonthlyPayment: 3200, minBeds: 3 });
const [maxRateBps, setMaxRateBps] = useState<number | null>(500);

<BuyerScenarioInputs value={criteria} onChange={setCriteria} />
<MaximumRateSelector value={maxRateBps} onChange={setMaxRateBps} options={[3, 4, 5, 6]} noLimitLabel="ANY" />

MaximumRateSelector value contract is number | null where null means no cap; the request sends assumptionFilters.maxRateBps only when set. Show the API's applied/relaxed echo — if the server dropped a criterion, display it (AppliedFilters) instead of implying it was honored.

Map

MapSurface takes an injected adapter so the SDK never ships a maps key:

const adapter: MapAdapter = {
  mount(element, { markers, palette, onSelect, onStatus }) { /* your Google Maps/Mapbox code */ },
  update(map) { /* … */ },
  destroy(map) { /* … */ },
};

<MapSurface adapter={adapter} markers={markers} selectedKey={selected} onSelect={setSelected} palette={theme.colors.chart} />

The adapter owns browser-key loading, vector map IDs and vendor licensing. onStatus reports load errors so the host can show StaleState instead of a silently empty map.

Financing and reports

<FinancingComparisonTable component={financing} />   // assumption vs new loan, 6 canonical rows
<PaymentBreakdownTable component={payment} />        // 5 canonical lines + total.complete/excludes
<CashToCloseTable component={cash} />                // down payment leads, equity gap last
<MarketSnapshotPanel component={snapshot} />
<MarketReportChart spec={chartSpec} renderer={nivoAdapter} />
<MarketReportTable spec={chartSpec} rows={tableRows} source={source} freshness={freshness} />

MarketReportChart does not depend on a chart library. Implement ChartRenderer with Nivo in your app (the example does) so the SDK stays installable without it.

States you must render

LoadingState, EmptyState, ErrorState (pass the typed HetibankApiError so request_id shows), StaleState, DeniedState, ExpiredState. A search that returns nothing is an empty state, not an error; an expired session is not an empty result.

Accessibility checklist

  • The kit's contrast gate covers brand colors; you still own alt text, headings and focus order.
  • Test 360px width with long addresses and long labels.
  • Do not override --hb-danger/--hb-success/--hb-warning meanings.
  • Keep data-certainty styling visible: colour alone is not the signal.

Animated backgrounds (optional)

@hetibank/backgrounds ships 42 canvas/SVG background profiles from the shadcn.io background library. It is decoration and nothing else:

import { CanvasBackgroundLayer } from "@hetibank/backgrounds";
import "@hetibank/backgrounds/styles.css";

<div style={{ position: "relative", minHeight: 440 }}>
  <CanvasBackgroundLayer backgroundId="aurora" />
  <section style={{ position: "relative" }}>…content…</section>
</div>
  • The layer is position:absolute; inset:0, pointer-events:none, aria-hidden="true" — it never

receives focus or clicks, and it is not content.

  • The parent must be positioned; content needs position: relative to sit above it.
  • usePrefersReducedMotion() and a CSS prefers-reduced-motion block stop every animation.
  • Use the lazy entry (@hetibank/backgrounds/lazy) when a route may not render a background, so a

data route never downloads the ~73 KB canvas renderer.

  • Brand the tint with the chrome prop (CanvasChromeSettings); effect profiles carry their own

colors because they are canvas paint, so the contrast obligation stays with your content layer.

What the package deliberately does not do

  • No data fetching, caching or auth.
  • No Google Maps loader or key handling.
  • No financial calculations.
  • No Tailwind requirement. Everything reads --hb-* variables.