Developer Demo

Search & Filtering

Full-text and semantic search using Optimizely Graph's _fulltext operator - with live results, ranking modes, and cursor pagination.

Live demo#

The input below calls /api/search with a 300ms debounce. Try searching for content from the Mosey Bank site - “savings”, “mortgage”, “team”, or “FAQ”. Switch to Semantic mode to see how it finds related content that doesn't contain the exact phrase. Leave Fuzzy on and try a typo like “morgage” or “savngs”, then toggle it off to see the misspelling return nothing.

Calls /api/search with cache: “no-store” - kept out of the Next.js Data Cache; Graph's own response cache still applies.

The _fulltext operator#

_fulltext searches across all text fields that were indexed for a content type - including displayName, string properties, and richText body. Fields declared with indexingType: "disabled" are excluded from the index and are never searched. You can search on _Content (all types) or any specific type such as _Page, ArticlePage, etc. SDK docs ↗

Basic relevance search across all content
# _fulltext searches across ALL text fields that were indexed for a type -
# displayName, string properties, richText body, and any field with
# indexingType: "fulltext" (the default for string-type properties).
# Fields with indexingType: "disabled" are NOT searched.
# fuzzy: true adds typo tolerance (see the Fuzzy matching section below).

query SearchRelevance($query: String!, $fuzzy: Boolean) {
  _Content(
    where: { _fulltext: { match: $query, fuzzy: $fuzzy } }
    orderBy: { _ranking: RELEVANCE }
    limit: 10
  ) {
    total
    items {
      _score                          # relevance float (0–1)
      _metadata {
        displayName
        url { default }
      }
    }
  }
}

Relevance vs. Semantic ranking#

Graph supports two ranking modes. RELEVANCE is classic BM25 - term frequency, inverse document frequency, and field weighting. It works well for exact-phrase search but misses synonyms and typos unless you add synonyms and fuzzy: true (below). SEMANTIC uses vector embeddings and finds conceptually related content even when the words don't match. _semanticWeight blends the two signals.

RELEVANCE

Fast, no GPU required. Best for: exact product names, error codes, known terms. Add fuzzy: true for typo tolerance.

SEMANTIC (weight: 0.5)

Blended. "home loan" matches "mortgage" content. Best for: general search bars.

SEMANTIC (weight: 1.0)

Pure semantic. Best for: discovery / recommendation use cases. Can surprise users.

Semantic search with configurable weight
# SEMANTIC ranking uses vector embeddings - it understands meaning,
# not just keyword presence. "home loan" matches content about "mortgage"
# even if that exact phrase doesn't appear.
#
# _semanticWeight controls the blend between keyword and semantic signals:
#   0.0 → pure keyword (same as RELEVANCE)
#   1.0 → pure semantic (ignores term frequency entirely)
#   0.5 → balanced (default in this project)

query SearchSemantic($query: String!, $weight: Float!, $fuzzy: Boolean) {
  _Content(
    where: { _fulltext: { match: $query, fuzzy: $fuzzy } }
    orderBy: { _ranking: SEMANTIC, _semanticWeight: $weight }
    limit: 10
  ) {
    total
    items {
      _score
      _metadata { displayName url { default } }
    }
  }
}

Try these in the demo above. Toggle between Relevance and Semantic (and push the weight toward 1.0) to see the ranking model change the result set. The starkest differences show up when the query is a phrase or a concept rather than a single known term.

worried about fraud

RELEVANCE

About Mosey and About Us rank 1-2: "about" is a strong term match. Security & Fraud sits at #3. 13 hits.

SEMANTIC

Security & Fraud jumps to #1 - the model reads the intent, not the stop-word. 33 hits.

Semantic wins when common words in the query create noisy term matches.

retirement

RELEVANCE

2 hits: Pensions and Investments - only pages carrying the term or its synonym (pension).

SEMANTIC

11 hits, adding 5 Savings Tips for 2025, Fixed Rate Savings, and the Family finance journey case study - none of which contain "retirement".

Semantic widens recall to conceptually-related content that shares no keywords.

mortgage

RELEVANCE

Mortgage page first, then Homepage, Personal Banking, and the mortgage-rates article. 15 hits.

SEMANTIC

Same top hits, same order - 44 hits, but the extra 29 are all lower down.

For a specific known term, BM25 already nails the top - semantic only pads the tail. Don't reach for it by default.

Note: on this content set the BM25 scores dwarf the semantic scores in magnitude, so raising _semanticWeight mostly broadens recall and reorders the tail rather than upending the top hits. A larger corpus with more near-duplicate phrasing shows a stronger reshuffle.

Fuzzy matching#

fuzzy: true turns on approximate, typo-tolerant matching. It is a Boolean option on _fulltext (and on the contains, eq, and in field operators), independent of synonyms and boost. Graph applies Damerau-Levenshtein edit distance to each term, scaled by its length. The search on this site sends fuzzy: true by default; the Fuzzy toggle in the search overlay flips it. Fuzzy search docs ↗

1-2 characters

Exact match required. Short terms are never fuzzed - too many false positives.

3-5 characters

Edit distance 1. "savngs" matches "savings", "lian" matches "loan".

6+ characters

Edit distance 2. "morgage" matches "mortgage", "finanical" matches "financial".

Typo-tolerant relevance search
# fuzzy: true enables approximate (typo-tolerant) matching. It works on
# _fulltext { match } as well as the field operators contains, eq, and in.
# Edit distance scales with term length (Damerau-Levenshtein):
#   1-2 chars  ->  exact match required
#   3-5 chars  ->  edit distance 1   ("savngs"  matches "savings")
#   6+  chars  ->  edit distance 2   ("morgage" matches "mortgage")
#
# fuzzy is independent of synonyms and boost - combine them freely. It does
# NOT fuzzy-match synonym terms (those stay exact, case-insensitive).

query SearchRelevance($query: String!, $fuzzy: Boolean) {
  _Content(
    where: { _fulltext: { match: $query, fuzzy: $fuzzy } }
    orderBy: { _ranking: RELEVANCE }
    limit: 10
  ) {
    total
    items {
      _score
      _metadata { displayName url { default } }
    }
  }
}

Filtering and cursor pagination#

_fulltext can be combined with any where clause. Common combinations: filter by base type ( _Page vs. _Content), published date range, or a specific content type. Results are paginated using an opaque cursor string - more stable than offset pagination because new publishes don't shift pages.

Filtered query with cursor

Search pages only, published within 90 days
# Combine _fulltext with where clauses to narrow results by type,
# date range, or any indexed field.
#
# This query returns only _Page items (pages, not blocks or assets)
# published in the last 90 days, ranked by relevance.

query SearchPages($query: String!, $since: DateTime!, $fuzzy: Boolean) {
  _Page(
    where: {
      _fulltext: { match: $query, fuzzy: $fuzzy }
      _metadata: { published: { gte: $since } }
    }
    orderBy: { _ranking: RELEVANCE }
    limit: 10
  ) {
    total
    cursor               # opaque string - pass back as $cursor for the next page
    items {
      _score
      _metadata {
        displayName
        published
        url { default }
        __typename         # lets the UI show the content type (ArticlePage, LandingPage…)
      }
    }
  }
}

Consuming the cursor in code

Cursor-based pagination
# Cursor pagination: pass the cursor from the previous response
# to fetch the next page. Cursors are stable even if new content
# is published between requests - offset-based pagination isn't.

# Page 1 - no cursor
const page1 = await graphqlFetch(SEARCH_PAGES_QUERY, {
  query: "savings account",
  since: new Date(Date.now() - 90 * 86400_000).toISOString(),
});

const { items, cursor, total } = page1.data._Page;
// cursor = "eyJhbGciOiJub25lIn0.eyJza2lwIjoxMH0."  (opaque, don't parse)

// Page 2 - pass cursor back
const page2 = await graphqlFetch(SEARCH_PAGES_QUERY_WITH_CURSOR, {
  query: "savings account",
  since: ...,
  cursor: cursor,   // ← the cursor from page 1 response
});

Cache strategy - skip the app cache, keep the edge cache#

Two cache layers sit under a search request, and you want opposite things from them.

  • Next.js Data Cache - skip it. Each visitor types a slightly different phrase. Under ISR every unique { query, variables } pair becomes its own persistent entry that is almost never read again, and on a bounded cache that junk evicts entries you do reuse (pages, navigation). The /api/search route passes cache: "no-store" to keep search out of it.
  • Optimizely Graph's response cache - keep it. no-store is a Next.js directive; it does not reach Graph, which keeps its own TTL-based response cache, purged within seconds of a publish by the webhook. Repeated queries still return from Graph's edge - and that is the layer you want for search. A handful of popular terms carry most of the volume, and the synonyms expansion funnels “home loan”, “house loan”, and “mortgage” onto the same match set, so the hot query set stays small and stays warm.

Net: no per-visitor entries in your app, a shared cache at Graph, freshness from the publish webhook. (A GET transport and stored queries push Graph's hit rate higher still; graphqlFetch uses POST here for simplicity.)

App cache vs. Graph cache for search
// Search should skip the Next.js Data Cache but still ride Graph's own cache.

// ✅ no-store keeps search OUT of the Next.js Data Cache. Otherwise every
// unique query string becomes a persistent entry that is never read again,
// evicting entries you do reuse (pages, navigation).
const results = await graphqlFetch(SEARCH_QUERY, { query: q }, { cache: "no-store" });
// (the /api/search route in this project already passes this)

// no-store is a Next.js directive - it does NOT disable the Optimizely Graph
// response cache. Repeated queries still return from Graph's edge, purged
// within seconds of a publish by the webhook. Synonyms help here: "home loan"
// and "mortgage" resolve to the same match set, so the hot query set stays
// small and stays warm.

// ❌ Wrong - ISR on search: one Data Cache entry per unique phrase, indefinitely.
const results = await graphqlFetch(SEARCH_QUERY, { query: q });

// Navigation is the opposite - one query for every visitor, no variables,
// a perfect ISR candidate:
graphqlFetch(GET_NAV_QUERY, {}, { next: { revalidate: 300, tags: ["navigation"] } });

Search hit tracking#

Graph can record two kinds of search data. Phrase tracking - which queries users run - is automatic: add a tracking argument to the _Content call and every query execution is logged. Click tracking - which result the user selected - requires a client-side step: each result item returns a _track URL, and you fire a GET request to that URL when the user clicks. Together they give you the full picture - what was searched and what was chosen.

Query with tracking enabled

Add tracking param and _track field
# Add tracking: { phrase, source } to the _Content arguments.
# Graph records the search phrase automatically on each query execution.
# Each result item returns a _track URL containing the session ID,
# content ID, content type, and zero-based position.

query SearchRelevance($query: String!, $fuzzy: Boolean) {
  _Content(
    where: { _fulltext: { match: $query, fuzzy: $fuzzy } }
    orderBy: { _ranking: RELEVANCE }
    limit: 10
    tracking: { phrase: $query, source: "/search" }
  ) {
    total
    items {
      _track                        # tracking URL returned per result
      _score
      _metadata {
        displayName
        url { default }
      }
    }
  }
}

Client-side click handler

Fire _track URL on result click
// Graph does not embed auth in _track URLs - append the single key server-side.
// In your API route:
const trackUrl = item._track
  ? `${item._track}&auth=${process.env.OPTIMIZELY_GRAPH_SINGLE_KEY}`
  : null;

// On result click, fire a fire-and-forget GET to the tracking URL.
// Never await it or block navigation on it.
if (result.trackUrl) {
  fetch(result.trackUrl, { method: "GET", mode: "no-cors" }).catch(() => {});
}

Pinned results#

Pinned results let editors guarantee that specific content always appears at the top of search results when a user's query matches a configured phrase - regardless of ranking score. Common uses: promoted products, campaign pages, curated editorial picks. Pins are managed via REST and applied in GraphQL with a single pinned argument.

Create a collection and add pinned items

REST API - collection setup
// 1. Create a collection (once, via REST - store the returned ID)
const collRes = await fetch(
  `${process.env.OPTIMIZELY_GRAPH_GATEWAY}/api/pinned/collections`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: "..." },
    body: JSON.stringify({ title: "Featured Results", isActive: true }),
  }
);
const { id: collectionId } = await collRes.json();

// 2. Add a pinned item - targetKey is the content GUID (from ContentLink.GuidValue)
await fetch(
  `${process.env.OPTIMIZELY_GRAPH_GATEWAY}/api/pinned/collections/${collectionId}/items`,
  {
    method: "PUT",
    headers: { "Content-Type": "application/json", Authorization: "..." },
    body: JSON.stringify({
      phrases: ["savings account", "save money"],
      targetKey: "abc123...",   // content GUID
      language: "en",
      priority: 1,              // 1 = highest; top 5 pins are shown per query
      isActive: true,
    }),
  }
);

Apply pinned results in a GraphQL query

pinned argument in _Content query
# Add pinned: { phrase, collections } alongside where/orderBy.
# Graph prepends matching pinned items before organic results (up to 5).
# Omit collections to evaluate ALL active collections automatically.

query SearchWithPinned($query: String!, $collectionId: String, $fuzzy: Boolean) {
  _Content(
    where: { _fulltext: { match: $query, fuzzy: $fuzzy } }
    orderBy: { _ranking: RELEVANCE }
    limit: 10
    pinned: { phrase: $query, collections: $collectionId }
  ) {
    total
    items {
      _score
      _metadata { displayName url { default } }
    }
  }
}

Pinned items are prepended, not ranked

Graph prepends matching pinned items before the organic result set - they don't compete for ranking position. Up to 5 pinned items appear per query; organic results fill the remainder of the limit. Omit collections to evaluate all active collections automatically.

Synonyms#

Synonyms expand what Graph considers a match, reducing zero-result searches and search abandonment. Graph supports two synonym slots per language (ONE and TWO) so you can manage independent synonym groups - for example product terminology in ONE and financial acronyms in TWO. Synonyms are uploaded as CSV via REST and applied at query time via the synonyms parameter on field operators.

Bi-directional

Comma-separated terms. All are interchangeable in both directions. mortgage, home loan, house loan

Uni-directional

Arrow notation. Left-side terms expand to right-side only. H2O => water

Slot ONE / TWO

Two independent synonym sets per language. Apply one or both per field: synonyms: [ONE, TWO]

Upload synonyms via REST (CSV format)

PUT /resources/synonyms
// Synonyms are uploaded via REST as a CSV string.
// PUT replaces the entire slot - always upload the full set.

const csv = [
  // Bi-directional: all terms are interchangeable in both directions
  "mortgage, home loan, house loan",
  "ISA, individual savings account",
  // Uni-directional: left-side terms expand to right-side terms only
  "H2O => water",
  "current account => checking account",
].join("\n");

await fetch(`${process.env.OPTIMIZELY_GRAPH_GATEWAY}/resources/synonyms`, {
  method: "PUT",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization: `Basic ${btoa(`${process.env.OPTIMIZELY_GRAPH_APP_KEY}:`)}`,
  },
  body: new URLSearchParams({
    synonym_slot: "ONE",
    language_routing: "en",
    synonyms: csv,
  }),
});

Apply synonym expansion in a query

synonyms parameter on field operators
# Apply synonym expansion on field-level operators via the synonyms parameter.
# Supported operators: contains, in, notIn, eq, notEq.
# ONE and TWO are independent slots per language - use both if you manage
# separate synonym groups (e.g. product terms in ONE, acronyms in TWO).

{
  _Content(
    where: {
      Title: { contains: "home loan", synonyms: ONE }
    }
    orderBy: { _ranking: RELEVANCE }
    limit: 10
  ) {
    total
    items {
      _score
      _metadata { displayName url { default } }
    }
  }
}

# Multiple slots:
# where: { Name: { eq: "ISA", synonyms: [ONE, TWO] } }

# synonyms also works on _fulltext { match } - the production search in this
# project uses _fulltext: { match: $query, synonyms: [ONE], fuzzy: $fuzzy }.
# synonyms and fuzzy are independent options and combine freely. Fuzzy applies
# to the typed query terms only, never to synonym expansion (synonym terms are
# matched exactly, case-insensitively).

Geo search with GeoPoint#

Graph supports geospatial search. The key is the data model: geo operators (distance, withIn, orderBy by distance) only attach to a single field of type GeoPoint - never to two separate lat/lon floats. The Mosey Bank branches are pushed as an external content source with a GeoPoint field. Geo search docs ↗

The input below calls /api/locations/nearby, which geocodes your text to coordinates (OpenStreetMap Nominatim), runs the distance query, and attaches a Haversine distanceKm to each result (Graph ranks by distance but does not return the value). Try “Berlin”, “London”, or “Stockholm” and expand the response panel to see the raw JSON.

Content Source schema (GeoPoint field)
# Content Source schema - a single GeoPoint field, NOT two floats.
# Geo operators only attach to a field of type "GeoPoint".

PUT https://cg.optimizely.com/api/content/v3/types?id=locs
{
  "label": "Bank Locations",
  "languages": ["en"],
  "contentTypes": {
    "BankLocation": {
      "contentType": ["_Item"],
      "properties": {
        "branchName": { "type": "String" },
        "address":    { "type": "String" },
        "city":       { "type": "String" },
        "country":    { "type": "String" },
        "phone":      { "type": "String" },
        "services":   { "type": "String" },
        "location":   { "type": "GeoPoint" }
      }
    }
  },
  "preset": "next",
  "useTypedFieldNames": true
}
Full NdJSON data push payload
# Data push (NdJSON) - two lines per record: an action line, then the data line.
# POST https://cg.optimizely.com/api/content/v2/data?id=locs
# Content-Type: text/plain   (NdJSON, not application/json)

{"index":{"_id":1,"language_routing":"en"}}
{
  "_rbac": "r:Everyone:Read",
  "_itemMetadata": {
    "key": "loc-1",
    "displayName___searchable": "Mosey Bank Amsterdam Central - Amsterdam",
    "lastModified": "2026-07-03T00:00:00.000Z",
    "type": "BankLocation"
  },
  "_metadata": { "types": ["BankLocation", "_Item"], "locale": "en", "key": "loc-1", "status": "Published" },
  "branchName$$String": "Mosey Bank Amsterdam Central",
  "address$$String": "Damrak 101",
  "city$$String": "Amsterdam",
  "country$$String": "Netherlands",
  "phone$$String": "+31 20 555 0100",
  "services$$String": "Current accounts, mortgages, investments",
  "location$$GeoPoint": { "lat": 52.3676, "lon": 4.9041 },
  "ContentType": ["BankLocation"],
  "Status": "Published",
  "Language": { "DisplayName": "English", "Name": "en" }
}
distance filter + nearest-first orderBy
# distance filters by radius from an origin; orderBy sorts nearest-first.
# NOTE: the radius argument is typed Int, not Float - a $radius: Float
# variable is rejected with "used in position expecting type Int".

query GetNearbyBankLocations($lat: Float!, $lon: Float!, $radius: Int) {
  BankLocation(
    where: {
      location: {
        distance: { origin: { lat: $lat, lon: $lon }, radius: $radius, unit: KM }
      }
    }
    orderBy: { location: { origin: { lat: $lat, lon: $lon } } }
  ) {
    items {
      branchName
      city
      location { lat lon }   # Graph returns coords only - no computed distance
    }
  }
}

Key Things to Know#

  • _fulltext searches indexed text fields automatically. String properties are indexed by default. Opt out per-field with indexingType: "disabled".
  • RELEVANCE = BM25 keyword matching. Fast, deterministic. SEMANTIC = vector similarity - slower but understands meaning and synonyms.
  • fuzzy: true tolerates typos. Approximate matching on _fulltext / contains / eq / in; edit distance scales with term length (exact under 3 chars, 1 for 3-5, 2 for 6+). Independent of synonyms and boost. This site's search sends it by default.
  • Combine _fulltext with any where clause to restrict by type, date, or tag. Filtering narrows the candidate set before ranking is applied.
  • Cursor pagination is stable; offset pagination shifts when new content is published. Always use the cursor returned by Graph, not a computed skip value.
  • Search uses cache: "no-store" to skip the Next.js Data Cache, not to bypass Graph. Per-visitor phrases would fill a bounded persistent cache with one-time entries; Graph's own response cache (purged on publish) still serves repeated and synonym-funneled queries from the edge. Navigation is the opposite - one query for every visitor, a perfect ISR candidate.
  • _score is a float between 0 and 1. Use it to show relevance indicators in the UI or to filter out low-confidence results below a threshold.
  • Add tracking to record search phrases. Each result item returns a _track URL - call it with a GET request when the user clicks a result. Tracking should never block or interrupt navigation.
  • Use pinned to guarantee editorial picks appear first. Create a collection, add items with trigger phrases, then pass pinned: { phrase, collections } in the GraphQL query. Up to 5 pinned items are prepended before organic results.
  • Synonyms reduce zero-result searches by expanding query terms. Upload a CSV to slot ONE or TWO via REST, then enable synonym expansion per field using synonyms: ONE on contains, in, or eq operators.
  • Geo search needs a single GeoPoint field, not two floats. Filter with distance and sort with orderBy by origin. The radius argument is typed Int, not Float. Graph returns coordinates only - compute distance labels yourself with Haversine.
Source files8 files
src/lib/graphql/queries/SearchContent.ts
export const SEARCH_RELEVANCE_QUERY = /* GraphQL */ `
  query SearchRelevance($query: String!, $locale: [Locales], $fuzzy: Boolean) {
    SEO(
      locale: $locale
      where: { _fulltext: { match: $query, synonyms: [ONE], fuzzy: $fuzzy } }
      orderBy: { _ranking: RELEVANCE }
      limit: 10
      pinned: { phrase: $query }
      tracking: { phrase: $query, source: "/search" }
    ) {
      total
      items {
        _track
        _score
        _metadata {
          displayName
          url { default }
        }
      }
    }
  }
`;

export const SEARCH_FACETED_QUERY = /* GraphQL */ `
  query SearchFaceted($query: String!, $categories: [String!], $tags: [String!], $locale: [Locales], $fuzzy: Boolean) {
    ArticlePage(
      locale: $locale
      where: {
        _fulltext: { match: $query, fuzzy: $fuzzy }
        category: { in: $categories }
        tags: { in: $tags }
      }
      orderBy: { _ranking: RELEVANCE }
      limit: 10
      tracking: { phrase: $query, source: "/demo/listing" }
    ) {
      total
      items {
        _score
        category
        tags
        _metadata {
          displayName
          url { default }
        }
      }
      facets {
        category(orderType: COUNT, orderBy: DESC, limit: 10) { name count }
        tags(orderType: COUNT, orderBy: DESC, limit: 12) { name count }
      }
    }
  }
`;

export const AUTOCOMPLETE_QUERY = /* GraphQL */ `
  query Autocomplete($value: String!) {
    ArticlePage {
      autocomplete {
        tags(limit: 5, value: $value)
      }
    }
    SEO {
      autocomplete {
        _metadata {
          url { default(limit: 6, value: $value) }
        }
      }
    }
  }
`;

export const SEARCH_SEMANTIC_QUERY = /* GraphQL */ `
  query SearchSemantic($query: String!, $weight: Float!, $locale: [Locales], $fuzzy: Boolean) {
    SEO(
      locale: $locale
      where: { _fulltext: { match: $query, synonyms: [ONE], fuzzy: $fuzzy } }
      orderBy: { _ranking: SEMANTIC, _semanticWeight: $weight }
      limit: 10
      pinned: { phrase: $query }
      tracking: { phrase: $query, source: "/search" }
    ) {
      total
      items {
        _track
        _score
        _metadata {
          displayName
          url { default }
        }
      }
    }
  }
`;
src/app/api/search/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { graphqlFetch } from "@/lib/optimizely/client";
import {
  SEARCH_FACETED_QUERY,
  SEARCH_RELEVANCE_QUERY,
  SEARCH_SEMANTIC_QUERY,
} from "@/lib/graphql/queries/SearchContent";

const SINGLE_KEY = process.env.OPTIMIZELY_GRAPH_SINGLE_KEY ?? "";

// Pinned results carry a _score boosted by 2^32-1 (~4.29e9); organic scores stay
// in the hundreds/thousands. 1e9 is an unambiguous cutoff for flagging a pinned hit.
const PINNED_SCORE_THRESHOLD = 1_000_000_000;

function listParam(value: string | null): string[] | null {
  const parsed = value?.split(",").map((v) => v.trim()).filter(Boolean) ?? [];
  return parsed.length > 0 ? parsed : null; // null = Graph ignores the filter
}

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const q      = searchParams.get("q")?.trim() ?? "";
  const mode   = searchParams.get("mode") === "semantic" ? "semantic" : "relevance";
  const weight = Math.min(1, Math.max(0, parseFloat(searchParams.get("weight") ?? "0.5")));
  const locale = [searchParams.get("locale") ?? "en"];
  // Fuzzy (typo-tolerant) matching is on by default; only an explicit fuzzy=0 disables it.
  const fuzzy  = searchParams.get("fuzzy") !== "0";

  if (!q || q.length < 2) {
    return NextResponse.json({ total: 0, items: [] });
  }

  if (searchParams.get("facets") === "1") {
    return facetedSearch(q, listParam(searchParams.get("category")), listParam(searchParams.get("tags")), locale, fuzzy);
  }

  try {
    const result = await graphqlFetch<any>(
      mode === "semantic" ? SEARCH_SEMANTIC_QUERY : SEARCH_RELEVANCE_QUERY,
      mode === "semantic" ? { query: q, weight, locale, fuzzy } : { query: q, locale, fuzzy },
      { cache: "no-store" }
    );

    const raw = result.data?.SEO ?? { total: 0, items: [] };

    const items = (raw.items ?? [])
      .filter((item: any) => item?._metadata?.displayName && item?._metadata?.url?.default)
      .map((item: any) => {
        const score = (item._score as number | null | undefined) ?? 0;
        return {
          title:    item._metadata.displayName as string,
          url:      item._metadata.url.default as string,
          score,
          pinned:   score >= PINNED_SCORE_THRESHOLD,
          trackUrl: (() => {
            const t = item._track as string | null | undefined;
            return t && SINGLE_KEY ? `${t}&auth=${SINGLE_KEY}` : (t ?? null);
          })(),
        };
      });

    return NextResponse.json({ total: raw.total ?? items.length, items });
  } catch (error) {
    console.error("[Search] Query failed:", error);
    return NextResponse.json({ error: "Search failed" }, { status: 500 });
  }
}

async function facetedSearch(q: string, categories: string[] | null, tags: string[] | null, locale: string[] = ["en"], fuzzy = true) {
  try {
    const result = await graphqlFetch<any>(
      SEARCH_FACETED_QUERY,
      { query: q, categories, tags, locale, fuzzy },
      { cache: "no-store" }
    );

    const raw = result.data?.ArticlePage ?? { total: 0, items: [], facets: {} };

    const items = (raw.items ?? [])
      .filter((item: any) => item?._metadata?.displayName && item?._metadata?.url?.default)
      .map((item: any) => ({
        title:    item._metadata.displayName as string,
        url:      item._metadata.url.default as string,
        score:    (item._score as number | null | undefined) ?? 0,
        category: (item.category as string | null | undefined) ?? null,
        tags:     (item.tags as string[] | null | undefined) ?? [],
      }));

    return NextResponse.json({
      total: raw.total ?? items.length,
      items,
      facets: {
        category: raw.facets?.category ?? [],
        tags:     raw.facets?.tags ?? [],
      },
    });
  } catch (error) {
    console.error("[Search] Faceted query failed:", error);
    return NextResponse.json({ error: "Search failed" }, { status: 500 });
  }
}
src/app/demo/search/SearchDemo.tsx
"use client";
import { useState, useCallback, useRef } from "react";

type SearchResult = { title: string; url: string; score: number; pinned: boolean; trackUrl: string | null };

export default function SearchDemo() {
  const [query, setQuery] = useState("");
  const [mode, setMode] = useState<"relevance" | "semantic">("relevance");
  const [fuzzy, setFuzzy] = useState(true);
  const [results, setResults] = useState<SearchResult[]>([]);
  const [total, setTotal] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const search = useCallback((q: string, m: string, f: boolean) => {
    if (debounceRef.current) clearTimeout(debounceRef.current);
    if (!q || q.length < 2) {
      setResults([]);
      setTotal(null);
      return;
    }
    debounceRef.current = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&mode=${m}${f ? "" : "&fuzzy=0"}`);
        const data = await res.json();
        setResults(data.items ?? []);
        setTotal(data.total ?? 0);
      } catch {
        setResults([]);
        setTotal(0);
      } finally {
        setLoading(false);
      }
    }, 300);
  }, []);

  return (
    <div className="bg-surface-lowest border border-ghost-border rounded-2xl p-6 space-y-4">
      <div className="flex gap-3">
        <input
          type="text"
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            search(e.target.value, mode, fuzzy);
          }}
          placeholder="Type to search published content…"
          className="flex-1 px-4 py-2 text-sm bg-surface border border-ghost-border rounded-xl text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-brand/30"
        />
        <select
          value={mode}
          onChange={(e) => {
            const m = e.target.value as "relevance" | "semantic";
            setMode(m);
            search(query, m, fuzzy);
          }}
          className="px-3 py-2 text-sm bg-surface border border-ghost-border rounded-xl text-on-surface focus:outline-none"
        >
          <option value="relevance">Relevance</option>
          <option value="semantic">Semantic</option>
        </select>
      </div>

      <label className="flex items-center gap-2 text-xs text-on-surface-variant cursor-pointer">
        <input
          type="checkbox"
          checked={fuzzy}
          onChange={(e) => {
            setFuzzy(e.target.checked);
            search(query, mode, e.target.checked);
          }}
          className="accent-brand"
        />
        Fuzzy matching (typo tolerance) - sends{" "}
        <code className="bg-surface px-1 rounded font-mono">fuzzy: true</code>
      </label>

      {loading && (
        <p className="text-xs text-on-surface-variant">Searching…</p>
      )}

      {!loading && total !== null && (
        <p className="text-xs text-on-surface-variant">
          {total} result{total !== 1 ? "s" : ""} for{" "}
          <strong className="text-on-surface">&ldquo;{query}&rdquo;</strong>
          {" "}· <span className="font-mono">{mode}</span> ranking
        </p>
      )}

      {results.length > 0 && (
        <ul className="space-y-2 divide-y divide-ghost-border">
          {results.map((r) => (
            <li
              key={r.url}
              className={`flex items-center justify-between gap-4 pt-2 first:pt-0 text-sm${
                r.pinned
                  ? " -mx-3 px-3 py-2 rounded-lg bg-brand/10 dark:bg-brand-fill/10 ring-1 ring-emerald-300/40"
                  : ""
              }`}
            >
              <div className="flex items-center gap-2 min-w-0">
                <a
                  href={r.url}
                  className="text-brand hover:underline truncate"
                  onClick={(e) => {
                    e.preventDefault();
                    if (r.trackUrl) fetch(r.trackUrl, { method: "GET", mode: "no-cors", keepalive: true }).catch(() => {});
                    window.location.href = r.url;
                  }}
                >{r.title}</a>
                {r.pinned && (
                  <span className="shrink-0 rounded-full bg-brand/10 dark:bg-brand-fill/20 text-brand dark:text-brand text-[10px] font-medium uppercase tracking-wide px-2 py-0.5">
                    Pinned
                  </span>
                )}
              </div>
              {!r.pinned && (
                <span className="text-xs font-mono text-on-surface-variant shrink-0">
                  {r.score.toFixed(3)}
                </span>
              )}
            </li>
          ))}
        </ul>
      )}

      {!loading && query.length >= 2 && results.length === 0 && total !== null && (
        <p className="text-xs text-on-surface-variant italic">No results found.</p>
      )}

      <p className="text-xs text-on-surface-variant border-t border-ghost-border pt-3">
        Calls <code className="bg-surface px-1 rounded font-mono">/api/search</code> with{" "}
        <code className="bg-surface px-1 rounded font-mono">cache: &ldquo;no-store&rdquo;</code>
        {" "}- kept out of the Next.js Data Cache; Graph&apos;s own response cache still applies.
      </p>
    </div>
  );
}
src/components/layout/SearchOverlay/index.tsx
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { DEFAULT_SITE_SETTINGS, type SiteSettingsStrings } from "@/lib/siteSettings";

interface SearchResult {
  title:    string;
  url:      string;
  score:    number;
  pinned:   boolean;
  trackUrl: string | null;
}

type SearchMode = "relevance" | "semantic";

interface Props {
  onClose: () => void;
  /** UI strings from the SiteSettings block; defaults keep the overlay working without CMS data. */
  labels?: SiteSettingsStrings;
}

export default function SearchOverlay({ onClose, labels = DEFAULT_SITE_SETTINGS }: Props) {
  const [query,   setQuery]   = useState("");
  const [mode,    setMode]    = useState<SearchMode>("relevance");
  const [weight,  setWeight]  = useState(0.5);
  const [fuzzy,   setFuzzy]   = useState(true);
  const [results, setResults] = useState<SearchResult[]>([]);
  const [total,   setTotal]   = useState(0);
  const [loading, setLoading] = useState(false);
  const inputRef  = useRef<HTMLInputElement>(null);
  const timerRef  = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    inputRef.current?.focus();

    const handleKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", handleKey);
    return () => document.removeEventListener("keydown", handleKey);
  }, [onClose]);

  const runSearch = useCallback(async (q: string, m: SearchMode, w: number, f: boolean) => {
    if (q.length < 2) { setResults([]); setTotal(0); return; }
    setLoading(true);
    try {
      const base = m === "semantic"
        ? `/api/search?q=${encodeURIComponent(q)}&mode=semantic&weight=${w}`
        : `/api/search?q=${encodeURIComponent(q)}&mode=relevance`;
      const res  = await fetch(f ? base : `${base}&fuzzy=0`);
      const data = await res.json();
      setResults(data.items ?? []);
      setTotal(data.total ?? 0);
    } finally {
      setLoading(false);
    }
  }, []);

  const handleQueryChange = (value: string) => {
    setQuery(value);
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => runSearch(value, mode, weight, fuzzy), 300);
  };

  const handleModeChange = (m: SearchMode) => {
    setMode(m);
    if (query.length >= 2) runSearch(query, m, weight, fuzzy);
  };

  const handleWeightChange = (w: number) => {
    setWeight(w);
    if (query.length >= 2) runSearch(query, mode, w, fuzzy);
  };

  const handleFuzzyChange = (f: boolean) => {
    setFuzzy(f);
    if (query.length >= 2) runSearch(query, mode, weight, f);
  };

  return (
    <div
      data-component="SearchOverlay"
      className="fixed inset-0 z-[100] bg-black/50 flex items-start justify-center pt-24 px-4"
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
    >
      <div className="bg-surface-lowest rounded-2xl shadow-2xl w-full max-w-2xl border border-ghost-border overflow-hidden">
        {/* Input */}
        <div className="flex items-center gap-3 px-5 py-4 border-b border-ghost-border">
          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" className="shrink-0 text-on-surface-variant">
            <circle cx="8" cy="8" r="5.5" stroke="currentColor" strokeWidth="1.5" />
            <path d="M12.5 12.5L16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
          </svg>
          <input
            ref={inputRef}
            type="text"
            placeholder={labels.searchPlaceholder}
            value={query}
            onChange={(e) => handleQueryChange(e.target.value)}
            className="flex-1 bg-transparent text-sm text-on-surface placeholder:text-on-surface-variant outline-none"
          />
          <button
            onClick={onClose}
            className="text-xs text-on-surface-variant hover:text-on-surface transition-colors"
          >
            ESC
          </button>
        </div>

        {/* Mode toggle */}
        <div className="flex gap-1 px-5 py-3 border-b border-ghost-border">
          {(["relevance", "semantic"] as SearchMode[]).map((m) => (
            <button
              key={m}
              onClick={() => handleModeChange(m)}
              className={`px-3 py-1 rounded-lg text-xs font-medium capitalize transition-colors ${
                mode === m
                  ? "bg-brand-fill text-on-brand"
                  : "text-on-surface-variant hover:text-brand"
              }`}
            >
              {m}
            </button>
          ))}
          <button
            type="button"
            onClick={() => handleFuzzyChange(!fuzzy)}
            aria-pressed={fuzzy}
            title="Typo-tolerant matching (Damerau-Levenshtein edit distance)"
            className={`ml-auto px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
              fuzzy
                ? "bg-brand-fill text-on-brand"
                : "text-on-surface-variant hover:text-brand"
            }`}
          >
            Fuzzy
          </button>
          <span className="text-xs text-on-surface-variant self-center">
            {mode === "semantic" ? "Vector similarity" : "Keyword (BM25)"}
          </span>
        </div>

        {/* Semantic weight slider */}
        {mode === "semantic" && (
          <div className="flex items-center gap-3 px-5 py-3 border-b border-ghost-border">
            <span className="text-xs text-on-surface-variant shrink-0">BM25</span>
            <input
              type="range"
              min={0}
              max={1}
              step={0.05}
              value={weight}
              onChange={(e) => handleWeightChange(parseFloat(e.target.value))}
              className="flex-1 accent-brand h-1.5 cursor-pointer"
            />
            <span className="text-xs text-on-surface-variant shrink-0">Semantic</span>
            <span className="text-xs font-mono text-brand w-8 text-right shrink-0">
              {weight.toFixed(2)}
            </span>
          </div>
        )}

        {/* Results */}
        <div className="max-h-96 overflow-y-auto">
          {loading && (
            <div className="px-5 py-8 text-sm text-center text-on-surface-variant">
              {labels.searchLoadingText}
            </div>
          )}

          {!loading && query.length >= 2 && results.length === 0 && (
            <div className="px-5 py-8 text-sm text-center text-on-surface-variant">
              {labels.searchNoResultsText.replace("{query}", query)}
            </div>
          )}

          {!loading && results.length > 0 && (
            <ul>
              {results.map((r, i) => (
                <li key={i}>
                  <Link
                    href={r.url}
                    onClick={() => {
                      onClose();
                      if (r.trackUrl) fetch(r.trackUrl, { method: "GET", mode: "no-cors", keepalive: true }).catch(() => {});
                    }}
                    className={`flex items-center justify-between gap-4 px-5 py-3 transition-colors group ${
                      r.pinned
                        ? "bg-brand/10 hover:bg-brand/20"
                        : "hover:bg-surface-low"
                    }`}
                  >
                    <div className="min-w-0">
                      <div className="flex items-center gap-2">
                        <span className="text-sm font-medium text-on-surface group-hover:text-brand transition-colors truncate">
                          {r.title}
                        </span>
                        {r.pinned && (
                          <span className="shrink-0 rounded-full bg-brand/15 text-brand text-[10px] font-medium uppercase tracking-wide px-2 py-0.5">
                            Pinned
                          </span>
                        )}
                      </div>
                      <div className="text-xs text-on-surface-variant mt-0.5 truncate max-w-md">
                        {r.url}
                      </div>
                    </div>
                    {!r.pinned && (
                      <span className="shrink-0 text-xs tabular-nums text-on-surface-variant opacity-60 text-right">
                        <span className="block">{r.score.toFixed(2)}</span>
                        <span className="block text-[10px] uppercase tracking-wide">
                          {mode === "semantic" ? "similarity" : "BM25"}
                        </span>
                      </span>
                    )}
                  </Link>
                </li>
              ))}
            </ul>
          )}

          {!loading && query.length < 2 && (
            <div className="px-5 py-8 text-sm text-center text-on-surface-variant">
              {labels.searchMinCharsText}
            </div>
          )}
        </div>

        {/* Footer */}
        {!loading && results.length > 0 && (
          <div className="px-5 py-2 border-t border-ghost-border text-xs text-on-surface-variant text-right">
            {total} result{total !== 1 ? "s" : ""}
          </div>
        )}
      </div>
    </div>
  );
}
src/lib/graphql/queries/GetLocations.ts
import { graphqlFetch, CACHE_TTL } from "@/lib/optimizely/client";

export interface BankLocation {
  branchName: string;
  city: string;
  country: string;
  phone: string;
  services: string;
  lat: number | null;
  lon: number | null;
}

interface RawLocation {
  branchName?: string | null;
  city?:       string | null;
  country?:    string | null;
  phone?:      string | null;
  services?:   string | null;
  location?:   { lat?: number | null; lon?: number | null } | null;
}

interface GetLocationsResult {
  BankLocation?: {
    items?: Array<RawLocation | null> | null;
  } | null;
}

const LOCATION_FIELDS = /* GraphQL */ `
  branchName
  city
  country
  phone
  services
  location {
    lat
    lon
  }
`;

export const GET_LOCATIONS_QUERY = /* GraphQL */ `
  query GetBankLocations {
    BankLocation(limit: 20, orderBy: { city: ASC }) {
      items {
        ${LOCATION_FIELDS}
      }
    }
  }
`;

// Geo search: branches within $radius km of the origin, ranked nearest-first.
export const GET_NEARBY_LOCATIONS_QUERY = /* GraphQL */ `
  query GetNearbyBankLocations($lat: Float!, $lon: Float!, $radius: Int) {
    BankLocation(
      where: { location: { distance: { origin: { lat: $lat, lon: $lon }, radius: $radius, unit: KM } } }
      orderBy: { location: { origin: { lat: $lat, lon: $lon } } }
    ) {
      items {
        ${LOCATION_FIELDS}
      }
    }
  }
`;

function toLocation(raw: RawLocation): BankLocation {
  return {
    branchName: raw.branchName        ?? "",
    city:       raw.city              ?? "",
    country:    raw.country           ?? "",
    phone:      raw.phone             ?? "",
    services:   raw.services          ?? "",
    lat:        raw.location?.lat     ?? null,
    lon:        raw.location?.lon     ?? null,
  };
}

export async function getLocations(): Promise<{ items: BankLocation[]; fromGraph: boolean }> {
  try {
    const result = await graphqlFetch<GetLocationsResult>(
      GET_LOCATIONS_QUERY,
      {},
      { next: { revalidate: CACHE_TTL, tags: ["locations"] } }
    );

    const raw   = result.data?.BankLocation?.items ?? [];
    const items = raw
      .filter((l): l is RawLocation => l !== null)
      .map(toLocation)
      .filter((l) => l.branchName !== "");

    if (items.length === 0) return { items: [], fromGraph: false };
    return { items, fromGraph: true };
  } catch {
    return { items: [], fromGraph: false };
  }
}

export async function getNearbyLocations(
  lat: number,
  lon: number,
  radiusKm: number
): Promise<{ items: BankLocation[]; fromGraph: boolean }> {
  try {
    const result = await graphqlFetch<GetLocationsResult>(
      GET_NEARBY_LOCATIONS_QUERY,
      { lat, lon, radius: Math.round(radiusKm) },
      { next: { revalidate: CACHE_TTL, tags: ["locations"] } }
    );

    const raw   = result.data?.BankLocation?.items ?? [];
    const items = raw
      .filter((l): l is RawLocation => l !== null)
      .map(toLocation)
      .filter((l) => l.branchName !== "");

    return { items, fromGraph: items.length > 0 };
  } catch {
    return { items: [], fromGraph: false };
  }
}
src/app/api/locations/nearby/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { getNearbyLocations, type BankLocation } from "@/lib/graphql/queries/GetLocations";
import { geocodeQuery } from "@/lib/geocode";
import { haversineKm } from "@/lib/geo";

const DEFAULT_RADIUS_KM = 500;
const MAX_RADIUS_KM = 5000;

export interface NearbyLocation extends BankLocation {
  distanceKm: number | null;
}

export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const q = searchParams.get("q")?.trim() ?? "";
  const radius = Math.min(
    MAX_RADIUS_KM,
    Math.max(1, parseFloat(searchParams.get("radius") ?? String(DEFAULT_RADIUS_KM)) || DEFAULT_RADIUS_KM)
  );

  if (q.length < 2) {
    return NextResponse.json({ origin: null, items: [] });
  }

  try {
    const origin = await geocodeQuery(q);
    if (!origin) {
      return NextResponse.json({ origin: null, items: [], error: "Location not found" });
    }

    const { items } = await getNearbyLocations(origin.lat, origin.lon, radius);

    const withDistance: NearbyLocation[] = items.map((loc) => ({
      ...loc,
      distanceKm:
        loc.lat != null && loc.lon != null
          ? Math.round(haversineKm({ lat: origin.lat, lon: origin.lon }, { lat: loc.lat, lon: loc.lon }))
          : null,
    }));

    return NextResponse.json({
      origin: { lat: origin.lat, lon: origin.lon, label: origin.label },
      items: withDistance,
    });
  } catch (error) {
    console.error("[Locations/nearby] Query failed:", error);
    return NextResponse.json({ error: "Nearby search failed" }, { status: 500 });
  }
}
src/lib/geocode.ts
export interface GeocodeResult {
  lat: number;
  lon: number;
  label: string;
}

// Server-side geocoding via OpenStreetMap Nominatim (no API key required).
// Nominatim's usage policy requires a descriptive User-Agent and limits
// requests to ~1/sec - fine for this demo. Swap this out for a keyed
// geocoder (Google, Mapbox) if higher volume is ever needed.
const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
const USER_AGENT = "mosey-bank-demo/1.0 (nearest-branch finder)";

export async function geocodeQuery(q: string): Promise<GeocodeResult | null> {
  const query = q.trim();
  if (!query) return null;

  const url = `${NOMINATIM_URL}?format=json&limit=1&q=${encodeURIComponent(query)}`;

  try {
    const res = await fetch(url, {
      headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
      next: { revalidate: 86400 },
    });

    if (!res.ok) return null;

    const results = (await res.json()) as Array<{
      lat?: string;
      lon?: string;
      display_name?: string;
    }>;

    const hit = results[0];
    if (!hit?.lat || !hit?.lon) return null;

    const lat = parseFloat(hit.lat);
    const lon = parseFloat(hit.lon);
    if (Number.isNaN(lat) || Number.isNaN(lon)) return null;

    return { lat, lon, label: hit.display_name ?? query };
  } catch {
    return null;
  }
}
src/app/demo/search/BranchFinder.tsx
"use client";
import { useState, useCallback, type FormEvent } from "react";

interface NearbyLocation {
  branchName: string;
  city: string;
  country: string;
  phone: string;
  services: string;
  lat: number | null;
  lon: number | null;
  distanceKm: number | null;
}

interface NearbyResponse {
  origin: { lat: number; lon: number; label: string } | null;
  items: NearbyLocation[];
  error?: string;
}

const RADIUS_OPTIONS = [250, 500, 1000, 2500];

export default function BranchFinder() {
  const [query, setQuery] = useState("");
  const [radius, setRadius] = useState(500);
  const [data, setData] = useState<NearbyResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [message, setMessage] = useState<string | null>(null);

  const search = useCallback(
    async (e: FormEvent) => {
      e.preventDefault();
      const q = query.trim();
      if (q.length < 2) return;

      setLoading(true);
      setMessage(null);
      try {
        const res = await fetch(`/api/locations/nearby?q=${encodeURIComponent(q)}&radius=${radius}`);
        const json: NearbyResponse = await res.json();
        setData(json);

        if (json.error) setMessage(json.error);
        else if (!json.origin) setMessage("We couldn't find that location. Try a city name or full address.");
      } catch {
        setData(null);
        setMessage("Something went wrong. Please try again.");
      } finally {
        setLoading(false);
      }
    },
    [query, radius]
  );

  const results = data?.items ?? [];

  return (
    <div data-component="BranchFinder" className="space-y-6">
      <form
        onSubmit={search}
        className="flex flex-col sm:flex-row gap-3 bg-surface-lowest border border-ghost-border rounded-2xl p-5"
      >
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="City or address, e.g. Berlin"
          className="flex-1 px-4 py-2 text-sm bg-surface border border-ghost-border rounded-xl text-on-surface placeholder:text-on-surface-variant/50 focus:outline-none focus:ring-2 focus:ring-brand/30"
        />
        <select
          value={radius}
          onChange={(e) => setRadius(Number(e.target.value))}
          className="px-3 py-2 text-sm bg-surface border border-ghost-border rounded-xl text-on-surface focus:outline-none"
          aria-label="Search radius"
        >
          {RADIUS_OPTIONS.map((r) => (
            <option key={r} value={r}>
              Within {r} km
            </option>
          ))}
        </select>
        <button
          type="submit"
          disabled={loading || query.trim().length < 2}
          className="px-5 py-2 text-sm font-medium bg-brand text-on-brand rounded-xl disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-brand/30"
        >
          {loading ? "Searching…" : "Search"}
        </button>
      </form>

      {data?.origin && !message && (
        <p className="text-xs text-on-surface-variant">
          Nearest branches to <strong className="text-on-surface">{data.origin.label}</strong>
          {" "}({data.origin.lat.toFixed(4)}, {data.origin.lon.toFixed(4)})
        </p>
      )}

      {message && <p className="text-sm text-on-surface-variant">{message}</p>}

      {!loading && data && !message && results.length === 0 && (
        <p className="text-sm text-on-surface-variant">No branches within {radius} km. Try a larger radius.</p>
      )}

      {results.length > 0 && (
        <ul className="space-y-3">
          {results.map((loc) => (
            <li
              key={loc.branchName}
              className="flex items-start justify-between gap-4 bg-surface-lowest border border-ghost-border rounded-2xl p-5"
            >
              <div className="space-y-1">
                <p className="text-sm font-semibold text-on-surface">{loc.branchName}</p>
                <p className="text-xs text-on-surface-variant">{loc.city}, {loc.country}</p>
                <p className="text-xs text-on-surface-variant">{loc.services}</p>
                <p className="text-xs text-on-surface-variant">{loc.phone}</p>
              </div>
              {loc.distanceKm != null && (
                <span className="shrink-0 text-xs font-medium text-brand whitespace-nowrap">{loc.distanceKm} km away</span>
              )}
            </li>
          ))}
        </ul>
      )}

      {data && (
        <details className="group border border-ghost-border rounded-2xl overflow-hidden">
          <summary className="flex items-center justify-between px-5 py-3 cursor-pointer bg-surface-low hover:bg-surface-low/80 transition-colors list-none select-none">
            <span className="text-xs font-semibold text-on-surface">Underlying response - GET /api/locations/nearby</span>
            <span className="text-xs text-on-surface-variant">JSON</span>
          </summary>
          <pre className="bg-surface-lowest p-4 text-xs font-mono text-on-surface-variant overflow-auto leading-relaxed">
            <code>{JSON.stringify(data, null, 2)}</code>
          </pre>
        </details>
      )}
    </div>
  );
}