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.
Calls /api/search with cache: “no-store” - results are never cached.
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 ↗
# _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.
query SearchRelevance($query: String!) {
_Content(
where: { _fulltext: { match: $query } }
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. 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.
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 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!) {
_Content(
where: { _fulltext: { match: $query } }
orderBy: { _ranking: SEMANTIC, _semanticWeight: $weight }
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
# 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!) {
_Page(
where: {
_fulltext: { match: $query }
_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 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 - always no-store#
Search queries must never be cached. Each user types a different string, so ISR would create a cache entry per unique query - polluting the Next.js data cache with entries that are never reused. The /api/search route in this project passes cache: "no-store" to graphqlFetch explicitly - bypassing both Next.js ISR and Graph CDN.
// Search results must NEVER be cached - every query string is unique.
// Using the default ISR behaviour would create a separate cache entry
// per search term and balloon the Next.js data cache with useless entries.
// ✅ Correct - graphqlFetch with explicit cache: "no-store"
const results = await graphqlFetch(SEARCH_QUERY, { query: q }, { cache: "no-store" });
// ✅ Also correct - API route passes cache: "no-store" to graphqlFetch
// (see src/app/api/search/route.ts)
// ❌ Wrong - using the default 60s ISR for search queries
const results = await graphqlFetch(SEARCH_QUERY, { query: q });
// ^ stores every unique query string in the data cache forever (until server restart)
// Compare with navigation, which IS cacheable because
// it's always the same query with no user-provided variables:
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: { 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!) {
_Content(
where: { _fulltext: { match: $query } }
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
// 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
// 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
# 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) {
_Content(
where: { _fulltext: { match: $query } }
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)
// 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
# 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] } }
# Note: _fulltext { match } uses BM25 without synonym expansion.
# Use field-level contains with synonyms, or switch to SEMANTIC ranking
# which handles meaning-based matching implicitly.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 - 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
}# 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 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.
- →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 queries must use cache: "no-store". Every user query is unique - ISR would fill the data cache with one-time entries. Navigation queries are the opposite - same query for every visitor, perfect for ISR.
- →_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
trackingto record search phrases. Each result item returns a_trackURL - call it with a GET request when the user clicks a result. Tracking should never block or interrupt navigation. - →Use
pinnedto guarantee editorial picks appear first. Create a collection, add items with trigger phrases, then passpinned: { 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: ONEoncontains,in, oreqoperators. - →Geo search needs a single
GeoPointfield, not two floats. Filter withdistanceand sort withorderByby origin. Theradiusargument is typedInt, not Float. Graph returns coordinates only - compute distance labels yourself with Haversine.
Source files8 files
export const SEARCH_RELEVANCE_QUERY = /* GraphQL */ `
query SearchRelevance($query: String!, $locale: [Locales]) {
_Content(
locale: $locale
where: { _fulltext: { match: $query } }
orderBy: { _ranking: RELEVANCE }
limit: 10
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]) {
ArticlePage(
locale: $locale
where: {
_fulltext: { match: $query }
category: { in: $categories }
tags: { in: $tags }
}
orderBy: { _ranking: RELEVANCE }
limit: 10
tracking: { phrase: $query, source: "/demo/faceted-search" }
) {
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)
}
}
_Content {
autocomplete {
_metadata {
url { default(limit: 6, value: $value) }
}
}
}
}
`;
export const SEARCH_SEMANTIC_QUERY = /* GraphQL */ `
query SearchSemantic($query: String!, $weight: Float!, $locale: [Locales]) {
_Content(
locale: $locale
where: { _fulltext: { match: $query } }
orderBy: { _ranking: SEMANTIC, _semanticWeight: $weight }
limit: 10
tracking: { phrase: $query, source: "/search" }
) {
total
items {
_track
_score
_metadata {
displayName
url { default }
}
}
}
}
`;
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 ?? "";
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"];
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);
}
try {
const result = await graphqlFetch<any>(
mode === "semantic" ? SEARCH_SEMANTIC_QUERY : SEARCH_RELEVANCE_QUERY,
mode === "semantic" ? { query: q, weight, locale } : { query: q, locale },
{ cache: "no-store" }
);
const raw = result.data?._Content ?? { total: 0, items: [] };
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,
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"]) {
try {
const result = await graphqlFetch<any>(
SEARCH_FACETED_QUERY,
{ query: q, categories, tags, locale },
{ 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 });
}
}
"use client";
import { useState, useCallback, useRef } from "react";
type SearchResult = { title: string; url: string; score: number; trackUrl: string | null };
export default function SearchDemo() {
const [query, setQuery] = useState("");
const [mode, setMode] = useState<"relevance" | "semantic">("relevance");
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) => {
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}`);
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);
}}
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);
}}
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>
{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">“{query}”</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">
<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>
<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: “no-store”</code>
{" "}- results are never cached.
</p>
</div>
);
}
"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;
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 [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) => {
if (q.length < 2) { setResults([]); setTotal(0); return; }
setLoading(true);
try {
const url = m === "semantic"
? `/api/search?q=${encodeURIComponent(q)}&mode=semantic&weight=${w}`
: `/api/search?q=${encodeURIComponent(q)}&mode=relevance`;
const res = await fetch(url);
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), 300);
};
const handleModeChange = (m: SearchMode) => {
setMode(m);
if (query.length >= 2) runSearch(query, m, weight);
};
const handleWeightChange = (w: number) => {
setWeight(w);
if (query.length >= 2) runSearch(query, mode, w);
};
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 text-on-brand"
: "text-on-surface-variant hover:text-brand"
}`}
>
{m}
</button>
))}
<span className="ml-auto 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 hover:bg-surface-low transition-colors group"
>
<div>
<div className="text-sm font-medium text-on-surface group-hover:text-brand transition-colors">
{r.title}
</div>
<div className="text-xs text-on-surface-variant mt-0.5 truncate max-w-md">
{r.url}
</div>
</div>
<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>
);
}
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 };
}
}
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 });
}
}
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;
}
}
"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>
);
}