Skip to content

Commit f27c9c0

Browse files
committed
feat(herbatika): add Meilisearch search autocomplete
1 parent 93253cf commit f27c9c0

10 files changed

Lines changed: 923 additions & 34 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextResponse } from "next/server";
2+
import { fetchSearchAutocomplete } from "@/lib/search-autocomplete/search-autocomplete.server";
3+
import { createEmptySearchAutocompleteResponse } from "@/lib/search-autocomplete/search-autocomplete-types";
4+
5+
export async function GET(request: Request) {
6+
const { searchParams } = new URL(request.url);
7+
const query = searchParams.get("q") ?? "";
8+
const currencyCode = searchParams.get("currency");
9+
10+
try {
11+
const response = await fetchSearchAutocomplete({ query, currencyCode });
12+
return NextResponse.json(response);
13+
} catch (error) {
14+
console.error("Search autocomplete failed", error);
15+
16+
return NextResponse.json(createEmptySearchAutocompleteResponse(query), {
17+
status: 502,
18+
});
19+
}
20+
}

apps/herbatika/src/components/herbatika-header.tsx

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { Badge } from "@techsio/ui-kit/atoms/badge";
55
import { Button } from "@techsio/ui-kit/atoms/button";
66
import { Icon } from "@techsio/ui-kit/atoms/icon";
77
import { LinkButton } from "@techsio/ui-kit/atoms/link-button";
8-
import { SearchForm } from "@techsio/ui-kit/molecules/search-form";
98
import { Header } from "@techsio/ui-kit/organisms/header";
109
import NextImage from "next/image";
1110
import NextLink from "next/link";
@@ -25,6 +24,7 @@ import {
2524
import { HERBATIKA_HEADER_SUBMENU_ROOT_CONFIGS } from "./header/herbatika-header.submenu-data";
2625
import { HerbatikaMobileMenuDialog } from "./header/herbatika-mobile-menu-dialog";
2726
import { HerbatikaLogo } from "./herbatika-logo";
27+
import { SearchAutocomplete } from "./search/search-autocomplete";
2828
import { resolveSearchHref } from "./search/search-query-config";
2929

3030
const REGION_TO_CURRENCY: Record<string, "EUR" | "CZK"> = {
@@ -45,36 +45,6 @@ const resolveRootHandleFromHref = (href: string) => {
4545
return href.slice(3);
4646
};
4747

48-
type HerbatikaHeaderSearchFormProps = {
49-
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
50-
variant: "desktop" | "mobile";
51-
};
52-
53-
function HerbatikaHeaderSearchForm({
54-
onSubmit,
55-
variant,
56-
}: HerbatikaHeaderSearchFormProps) {
57-
const isMobile = variant === "mobile";
58-
59-
return (
60-
<SearchForm className="w-full" onSubmit={onSubmit}>
61-
<SearchForm.Control className="h-search-form border border-border-search bg-fill-secondary">
62-
<SearchForm.Input
63-
className={`${isMobile ? "px-350 text-sm" : "px-400"} h-full font-verdana`}
64-
name="q"
65-
placeholder="Napíšte, čo hľadáte..."
66-
/>
67-
<SearchForm.Button
68-
aria-label="Hľadať"
69-
className="rounded-none"
70-
iconSize={isMobile ? "lg" : "xl"}
71-
showSearchIcon
72-
/>
73-
</SearchForm.Control>
74-
</SearchForm>
75-
);
76-
}
77-
7848
export function HerbatikaHeader() {
7949
const router = useRouter();
8050
const region = useRegionContext();
@@ -134,7 +104,8 @@ export function HerbatikaHeader() {
134104
<HerbatikaLogo className="min-w-0 shrink" size="lg" />
135105

136106
<div className="hidden w-full max-w-search-form flex-1 @header-desktop:block">
137-
<HerbatikaHeaderSearchForm
107+
<SearchAutocomplete
108+
currencyCode={currency}
138109
onSubmit={handleSearchSubmit}
139110
variant="desktop"
140111
/>
@@ -150,7 +121,7 @@ export function HerbatikaHeader() {
150121
<span className="block text-md font-semibold leading-snug text-fg-primary">
151122
+421 2/321 123 45
152123
</span>
153-
<span className="block text-xs ml-0.5 font-normal leading-snug text-fg-secondary">
124+
<span className="block text-xs ml-50 font-normal leading-snug text-fg-secondary">
154125
(Po-Pia: 09:00 - 16:00)
155126
</span>
156127
</span>
@@ -200,7 +171,8 @@ export function HerbatikaHeader() {
200171
</Header.Container>
201172

202173
<div className="mx-auto w-full max-w-max-w px-header-lg pb-300 2xl:px-header-2xl @header-desktop:hidden">
203-
<HerbatikaHeaderSearchForm
174+
<SearchAutocomplete
175+
currencyCode={currency}
204176
onSubmit={handleSearchSubmit}
205177
variant="mobile"
206178
/>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import { Icon, type IconType } from "@techsio/ui-kit/atoms/icon";
4+
import { FallbackImage } from "@/components/fallback-image";
5+
import type {
6+
SearchAutocompleteSuggestion,
7+
SearchAutocompleteSuggestionType,
8+
} from "@/lib/search-autocomplete/search-autocomplete-types";
9+
10+
const TYPE_ICON: Record<
11+
Exclude<SearchAutocompleteSuggestionType, "product">,
12+
IconType
13+
> = {
14+
brand: "token-icon-label",
15+
category: "token-icon-box",
16+
};
17+
18+
export function SearchAutocompleteMedia({
19+
item,
20+
}: {
21+
item: SearchAutocompleteSuggestion;
22+
}) {
23+
if (item.type === "product") {
24+
return (
25+
<span className="flex h-800 w-800 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border-secondary bg-base">
26+
<FallbackImage
27+
alt=""
28+
aria-hidden="true"
29+
className="h-full w-full object-contain"
30+
height={56}
31+
src={item.imageUrl}
32+
width={56}
33+
/>
34+
</span>
35+
);
36+
}
37+
38+
return (
39+
<span className="flex h-700 w-700 shrink-0 items-center justify-center rounded-md bg-fill-secondary text-primary">
40+
<Icon icon={TYPE_ICON[item.type]} size="lg" />
41+
</span>
42+
);
43+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"use client";
2+
3+
import { Link } from "@techsio/ui-kit/atoms/link";
4+
import NextLink from "next/link";
5+
import type { MouseEvent } from "react";
6+
import type {
7+
SearchAutocompleteSuggestion,
8+
SearchAutocompleteSuggestionType,
9+
} from "@/lib/search-autocomplete/search-autocomplete-types";
10+
import { SearchAutocompleteMedia } from "./search-autocomplete-media";
11+
12+
export type SearchAutocompletePanelSection = {
13+
key: SearchAutocompleteSuggestionType;
14+
title: string;
15+
items: SearchAutocompleteSuggestion[];
16+
};
17+
18+
type SearchAutocompletePanelProps = {
19+
activeItemId?: string;
20+
id: string;
21+
onItemClick: () => void;
22+
onItemMouseEnter: (item: SearchAutocompleteSuggestion) => void;
23+
onMouseDown: (event: MouseEvent<HTMLDivElement>) => void;
24+
query: string;
25+
sections: SearchAutocompletePanelSection[];
26+
status: "idle" | "loading" | "success" | "error";
27+
};
28+
29+
const joinClassNames = (...classNames: Array<string | false | undefined>) =>
30+
classNames.filter(Boolean).join(" ");
31+
32+
export const getSearchAutocompleteOptionId = (
33+
panelId: string,
34+
item: SearchAutocompleteSuggestion,
35+
) => `${panelId}-${item.type}-${item.id}`;
36+
37+
function SearchAutocompleteRow({
38+
activeItemId,
39+
item,
40+
onItemClick,
41+
onItemMouseEnter,
42+
panelId,
43+
}: Pick<
44+
SearchAutocompletePanelProps,
45+
"activeItemId" | "onItemClick" | "onItemMouseEnter"
46+
> & {
47+
item: SearchAutocompleteSuggestion;
48+
panelId: string;
49+
}) {
50+
const optionId = getSearchAutocompleteOptionId(panelId, item);
51+
const isActive = activeItemId === optionId;
52+
53+
return (
54+
<li role="presentation">
55+
<Link
56+
aria-selected={isActive}
57+
as={NextLink}
58+
className={joinClassNames(
59+
"flex min-w-0 items-center gap-300 px-300 py-200 text-fg-primary transition-colors",
60+
isActive ? "bg-fill-secondary" : "hover:bg-fill-secondary",
61+
)}
62+
href={item.href}
63+
id={optionId}
64+
onClick={onItemClick}
65+
onMouseEnter={() => onItemMouseEnter(item)}
66+
role="option"
67+
>
68+
<SearchAutocompleteMedia item={item} />
69+
<span className="min-w-0 flex-1">
70+
<span className="block truncate text-sm font-semibold leading-snug">
71+
{item.title}
72+
</span>
73+
{item.subtitle ? (
74+
<span className="block truncate text-xs leading-snug text-fg-secondary">
75+
{item.subtitle}
76+
</span>
77+
) : null}
78+
</span>
79+
{item.priceLabel || typeof item.inStock === "boolean" ? (
80+
<span className="shrink-0 text-right text-xs leading-snug">
81+
{item.priceLabel ? (
82+
<span className="block font-bold text-primary">
83+
{item.priceLabel}
84+
</span>
85+
) : null}
86+
{typeof item.inStock === "boolean" ? (
87+
<span
88+
className={item.inStock ? "text-success" : "text-fg-secondary"}
89+
>
90+
{item.inStock ? "Skladom" : "Vypredané"}
91+
</span>
92+
) : null}
93+
</span>
94+
) : null}
95+
</Link>
96+
</li>
97+
);
98+
}
99+
100+
export function SearchAutocompletePanel({
101+
activeItemId,
102+
id,
103+
onItemClick,
104+
onItemMouseEnter,
105+
onMouseDown,
106+
query,
107+
sections,
108+
status,
109+
}: SearchAutocompletePanelProps) {
110+
const hasItems = sections.some((section) => section.items.length > 0);
111+
112+
return (
113+
<div
114+
className="absolute left-0 right-0 top-full z-50 mt-100 max-h-screen overflow-y-auto rounded-xs border border-border-secondary bg-surface py-200 shadow-md"
115+
id={id}
116+
onMouseDown={onMouseDown}
117+
role="listbox"
118+
>
119+
{status === "loading" && !hasItems ? (
120+
<p className="px-300 py-250 text-sm text-fg-secondary">
121+
Hľadáme návrhy...
122+
</p>
123+
) : null}
124+
125+
{status === "error" ? (
126+
<p className="px-300 py-250 text-sm text-danger">
127+
Návrhy sa nepodarilo načítať.
128+
</p>
129+
) : null}
130+
131+
{status === "success" && !hasItems ? (
132+
<p className="px-300 py-250 text-sm text-fg-secondary">
133+
Pre výraz "{query}" nemáme rýchle návrhy.
134+
</p>
135+
) : null}
136+
137+
{sections.map((section) =>
138+
section.items.length > 0 ? (
139+
<div className="border-b border-border-secondary py-100 last:border-b-0" key={section.key}>
140+
<div className="px-300 py-100 text-xs font-semibold uppercase tracking-normal text-fg-secondary">
141+
{section.title}
142+
</div>
143+
<ul aria-label={section.title} role="group">
144+
{section.items.map((item) => (
145+
<SearchAutocompleteRow
146+
activeItemId={activeItemId}
147+
item={item}
148+
key={`${item.type}-${item.id}`}
149+
onItemClick={onItemClick}
150+
onItemMouseEnter={onItemMouseEnter}
151+
panelId={id}
152+
/>
153+
))}
154+
</ul>
155+
</div>
156+
) : null,
157+
)}
158+
</div>
159+
);
160+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { SearchAutocompleteResponse } from "@/lib/search-autocomplete/search-autocomplete-types";
2+
import type { SearchAutocompletePanelSection } from "./search-autocomplete-panel";
3+
4+
const SECTION_TITLES = {
5+
products: "Produkty",
6+
categories: "Kategórie",
7+
brands: "Značky",
8+
} as const;
9+
10+
export const createSearchAutocompleteSections = (
11+
data: SearchAutocompleteResponse,
12+
): SearchAutocompletePanelSection[] => [
13+
{ key: "product", title: SECTION_TITLES.products, items: data.products },
14+
{ key: "category", title: SECTION_TITLES.categories, items: data.categories },
15+
{ key: "brand", title: SECTION_TITLES.brands, items: data.brands },
16+
];
17+
18+
export const clampSearchAutocompleteIndex = (
19+
index: number,
20+
itemCount: number,
21+
) => {
22+
if (itemCount === 0) {
23+
return -1;
24+
}
25+
26+
if (index < 0) {
27+
return itemCount - 1;
28+
}
29+
30+
if (index >= itemCount) {
31+
return 0;
32+
}
33+
34+
return index;
35+
};

0 commit comments

Comments
 (0)