All blocks

Postcode Lookup

Self-contained postcode lookup card. Resolves a postal code to its dominant placeId and centroid via the Mapbase /v1/postcodes endpoint.

Install

$bunx shadcn@latest add https://mapbase.dev/r/postcode-lookup.json

The shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).

npm dependencies

mapbase

Registry dependencies

cardinputlabelbuttonselect

Source

components/mapbase/postcode-lookup.tsx
"use client"

import * as React from "react"
import { Mapbase, type CountryCode, type Postcode } from "mapbase"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"

export type PostcodeLookupProps = {
  /**
   * Public Mapbase API key (`mb_live_*`). Mint one at
   * https://dashboard.mapbase.dev/api-keys.
   */
  apiKey: string
  /** Optional: pin the engine URL. Defaults to https://api.mapbase.dev. */
  baseUrl?: string
  /**
   * Default country shown in the dropdown. Pass `undefined` to leave the
   * dropdown set to "Any" so the user picks the country themselves.
   */
  defaultCountry?: CountryCode
  /** Initial code to populate the input with. */
  defaultCode?: string
  /** Fired whenever a successful lookup resolves. */
  onResolve?: (postcode: Postcode) => void
  className?: string
}

const COUNTRY_OPTIONS: Array<{ value: CountryCode | "ANY"; label: string }> = [
  { value: "ANY", label: "Any country" },
  { value: "PT", label: "Portugal" },
  { value: "ES", label: "Spain" },
  { value: "IT", label: "Italy" },
]

/**
 * Self-contained postcode lookup card. User types a postal code, picks a
 * country (optional), hits Lookup, and gets back the dominant `placeId`
 * + centroid for that postcode.
 *
 * Backed by the Mapbase `/v1/postcodes/{code}` endpoint.
 */
export function PostcodeLookup({
  apiKey,
  baseUrl,
  defaultCountry,
  defaultCode = "",
  onResolve,
  className,
}: PostcodeLookupProps) {
  const client = React.useMemo(
    () => new Mapbase(apiKey, baseUrl ? { baseUrl } : undefined),
    [apiKey, baseUrl],
  )

  const [code, setCode] = React.useState(defaultCode)
  const [country, setCountry] = React.useState<CountryCode | "ANY">(
    defaultCountry ?? "ANY",
  )
  const [loading, setLoading] = React.useState(false)
  const [postcode, setPostcode] = React.useState<Postcode | null>(null)
  const [errorMessage, setErrorMessage] = React.useState<string | null>(null)

  const submit = async (event?: React.FormEvent<HTMLFormElement>) => {
    event?.preventDefault()
    const trimmed = code.trim()
    if (trimmed.length === 0) {
      setErrorMessage("Enter a postal code.")
      return
    }
    setLoading(true)
    setErrorMessage(null)
    const params = country === "ANY" ? {} : { country }
    const result = await client.postcodes.byCode(trimmed, params)
    setLoading(false)

    if (result.error) {
      setPostcode(null)
      setErrorMessage(humanizeError(result.error.code))
      return
    }
    setPostcode(result.data)
    onResolve?.(result.data)
  }

  return (
    <Card className={cn("max-w-md", className)}>
      <CardHeader>
        <CardTitle>Postcode lookup</CardTitle>
        <CardDescription>
          Resolve a postal code to its dominant place id and centroid.
        </CardDescription>
      </CardHeader>
      <form onSubmit={submit}>
        <CardContent className="flex flex-col gap-4">
          <div className="flex flex-col gap-1.5">
            <Label htmlFor="postcode-lookup-code">Postal code</Label>
            <Input
              id="postcode-lookup-code"
              value={code}
              onChange={(e) => setCode(e.target.value)}
              placeholder="1100-062"
              autoComplete="postal-code"
            />
          </div>
          <div className="flex flex-col gap-1.5">
            <Label htmlFor="postcode-lookup-country">Country</Label>
            <Select
              value={country}
              onValueChange={(v) => setCountry(v as CountryCode | "ANY")}
            >
              <SelectTrigger id="postcode-lookup-country">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {COUNTRY_OPTIONS.map((opt) => (
                  <SelectItem key={opt.value} value={opt.value}>
                    {opt.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>

          {errorMessage ? (
            <p className="text-sm text-destructive">{errorMessage}</p>
          ) : null}

          {postcode ? (
            <div className="rounded-md border bg-muted/40 p-3 text-sm">
              <div className="font-medium">{postcode.code}</div>
              <div className="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs text-muted-foreground">
                <span>country</span>
                <span>{postcode.country}</span>
                <span>placeId</span>
                <span className="font-mono">{postcode.placeId ?? "—"}</span>
                <span>centroid</span>
                <span className="font-mono">
                  {postcode.centroid
                    ? `${postcode.centroid[0].toFixed(4)}, ${postcode.centroid[1].toFixed(4)}`
                    : "—"}
                </span>
              </div>
            </div>
          ) : null}
        </CardContent>
        <CardFooter className="justify-end">
          <Button type="submit" disabled={loading || code.trim().length === 0}>
            {loading ? "Looking up…" : "Lookup"}
          </Button>
        </CardFooter>
      </form>
    </Card>
  )
}

function humanizeError(code: string): string {
  switch (code) {
    case "not_found":
      return "No postcode matches that code."
    case "unauthorized":
      return "Invalid API key."
    case "rate_limited":
      return "Rate limited — try again shortly."
    case "invalid_request":
      return "Invalid postcode format."
    case "network_error":
      return "Network error — check your connection."
    default:
      return "Something went wrong."
  }
}