Self-contained postcode lookup card. Resolves a postal code to its dominant placeId and centroid via the Mapbase /v1/postcodes endpoint.
bunx shadcn@latest add https://mapbase.dev/r/postcode-lookup.jsonThe shadcn CLI copies the file(s) below into your tree, installs npm dependencies, and recursively pulls registry dependencies (including from cross-registry URLs).
"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."
}
}