diff --git a/apps/strapi/.env.example b/apps/strapi/.env.example index e68aeaf4..dfe5b572 100644 --- a/apps/strapi/.env.example +++ b/apps/strapi/.env.example @@ -69,3 +69,15 @@ STRAPI_PREVIEW_SECRET=your-secret-key # Shared secret with the Next.js app for triggering on-demand revalidation. # Must match `STRAPI_REVALIDATE_SECRET` in apps/ui/.env.local. STRAPI_REVALIDATE_SECRET=your-revalidate-secret + +# ------- Meilisearch ------- + +# Meilisearch host URL +MEILISEARCH_HOST= + +# Master/admin API key used by the Strapi plugin to write indexes. +MEILISEARCH_API_KEY= + +# DANGER: Should be false while developing, to not populate production with test data. +# DANGER: If you are not sure always leave false +MEILISEARCH_PRODUCTION=false \ No newline at end of file diff --git a/apps/strapi/config/plugins.ts b/apps/strapi/config/plugins.ts index 37f9d70b..f55325d7 100644 --- a/apps/strapi/config/plugins.ts +++ b/apps/strapi/config/plugins.ts @@ -63,6 +63,54 @@ export default ({ env }) => { }, }, + meilisearch: { + config: { + host: env("MEILISEARCH_HOST", "http://localhost:7700"), + apiKey: env("MEILISEARCH_API_KEY"), + "case-study": { + indexName: env.bool("MEILISEARCH_PRODUCTION", false) + ? "case-studies-production" + : "case-studies-testing", + entriesQuery: { + populate: { + coverImage: { + populate: { image: { populate: { media: true } } }, + }, + categories: { fields: ["name", "slug"] }, + }, + }, + settings: { + filterableAttributes: ["categories.slug"], + searchableAttributes: ["title", "companyName", "description"], + sortableAttributes: ["originalPublishedAt"], + }, + }, + page: { + indexName: env.bool("MEILISEARCH_PRODUCTION", false) + ? "pages-production" + : "pages-testing", + transformEntry({ entry }) { + return { + ...entry, + pageType: + typeof entry.fullPath === "string" && + entry.fullPath.startsWith("/features") + ? "feature" + : "page", + } + }, + settings: { + filterableAttributes: ["pageType", "locale"], + }, + }, + "blog-post": { + indexName: env.bool("MEILISEARCH_PRODUCTION", false) + ? "blog-posts-production" + : "blog-posts-testing", + }, + }, + }, + // Must be last — registers after custom field plugins (color-picker) to avoid #119 // https://github.com/strapi-community/plugin-rest-cache/issues/119 "rest-cache": { diff --git a/apps/strapi/package.json b/apps/strapi/package.json index ae9abf76..29405faf 100644 --- a/apps/strapi/package.json +++ b/apps/strapi/package.json @@ -49,6 +49,7 @@ "react-dom": "^18.0.0", "react-router-dom": "^6.0.0", "strapi-plugin-config-sync": "2.1.0", + "strapi-plugin-meilisearch": "^0.16.2", "styled-components": "^6.0.0", "vitest": "catalog:", "zod": "^4.3.6" diff --git a/apps/strapi/src/api/page/content-types/page/schema.json b/apps/strapi/src/api/page/content-types/page/schema.json index 721446b8..ee3e4f55 100644 --- a/apps/strapi/src/api/page/content-types/page/schema.json +++ b/apps/strapi/src/api/page/content-types/page/schema.json @@ -92,7 +92,9 @@ "sections.community-banner", "sections.comparator-grid", "sections.disclaimer", - "sections.richtext" + "sections.richtext", + "sections.dynamic-case-studies-grid", + "sections.dynamic-features-grid" ] }, "children": { @@ -109,12 +111,12 @@ }, "minimalLayout": { "type": "boolean", - "default": false, "pluginOptions": { "i18n": { "localized": false } - } + }, + "default": false }, "seo": { "type": "component", diff --git a/apps/strapi/src/components/sections/dynamic-case-studies-grid.json b/apps/strapi/src/components/sections/dynamic-case-studies-grid.json new file mode 100644 index 00000000..055ef2c5 --- /dev/null +++ b/apps/strapi/src/components/sections/dynamic-case-studies-grid.json @@ -0,0 +1,14 @@ +{ + "collectionName": "components_sections_dynamic_case_studies_grids", + "info": { + "displayName": "DynamicCaseStudiesGrid" + }, + "options": {}, + "attributes": { + "isHidden": { + "type": "boolean", + "default": false + } + }, + "config": {} +} diff --git a/apps/strapi/src/components/sections/dynamic-features-grid.json b/apps/strapi/src/components/sections/dynamic-features-grid.json new file mode 100644 index 00000000..f842453a --- /dev/null +++ b/apps/strapi/src/components/sections/dynamic-features-grid.json @@ -0,0 +1,14 @@ +{ + "collectionName": "components_sections_dynamic_features_grids", + "info": { + "displayName": "DynamicFeaturesGrid" + }, + "options": {}, + "attributes": { + "isHidden": { + "type": "boolean", + "default": false + } + }, + "config": {} +} diff --git a/apps/strapi/src/populateDynamicZone/sections/dynamic-case-studies-grid.ts b/apps/strapi/src/populateDynamicZone/sections/dynamic-case-studies-grid.ts new file mode 100644 index 00000000..186b1207 --- /dev/null +++ b/apps/strapi/src/populateDynamicZone/sections/dynamic-case-studies-grid.ts @@ -0,0 +1 @@ +export default true diff --git a/apps/strapi/src/populateDynamicZone/sections/dynamic-features-grid.ts b/apps/strapi/src/populateDynamicZone/sections/dynamic-features-grid.ts new file mode 100644 index 00000000..186b1207 --- /dev/null +++ b/apps/strapi/src/populateDynamicZone/sections/dynamic-features-grid.ts @@ -0,0 +1 @@ +export default true diff --git a/apps/strapi/types/generated/components.d.ts b/apps/strapi/types/generated/components.d.ts index 9570a439..ef9e994c 100644 --- a/apps/strapi/types/generated/components.d.ts +++ b/apps/strapi/types/generated/components.d.ts @@ -1009,6 +1009,26 @@ export interface SectionsDisclaimer extends Struct.ComponentSchema { } } +export interface SectionsDynamicCaseStudiesGrid extends Struct.ComponentSchema { + collectionName: "components_sections_dynamic_case_studies_grids" + info: { + displayName: "DynamicCaseStudiesGrid" + } + attributes: { + isHidden: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo + } +} + +export interface SectionsDynamicFeaturesGrid extends Struct.ComponentSchema { + collectionName: "components_sections_dynamic_features_grids" + info: { + displayName: "DynamicFeaturesGrid" + } + attributes: { + isHidden: Schema.Attribute.Boolean & Schema.Attribute.DefaultTo + } +} + export interface SectionsFaqSection extends Struct.ComponentSchema { collectionName: "components_sections_faq_section" info: { @@ -1557,6 +1577,8 @@ declare module "@strapi/strapi" { "sections.comparator-grid": SectionsComparatorGrid "sections.cta-banner": SectionsCtaBanner "sections.disclaimer": SectionsDisclaimer + "sections.dynamic-case-studies-grid": SectionsDynamicCaseStudiesGrid + "sections.dynamic-features-grid": SectionsDynamicFeaturesGrid "sections.faq-section": SectionsFaqSection "sections.feature-card-grid": SectionsFeatureCardGrid "sections.hero": SectionsHero diff --git a/apps/strapi/types/generated/contentTypes.d.ts b/apps/strapi/types/generated/contentTypes.d.ts index 719bc05e..d5740d46 100644 --- a/apps/strapi/types/generated/contentTypes.d.ts +++ b/apps/strapi/types/generated/contentTypes.d.ts @@ -1271,6 +1271,8 @@ export interface ApiPagePage extends Struct.CollectionTypeSchema { "sections.comparator-grid", "sections.disclaimer", "sections.richtext", + "sections.dynamic-case-studies-grid", + "sections.dynamic-features-grid", ] > & Schema.Attribute.SetPluginOptions<{ diff --git a/apps/ui/.env.local.example b/apps/ui/.env.local.example index 4017dbd0..a2ab55f9 100644 --- a/apps/ui/.env.local.example +++ b/apps/ui/.env.local.example @@ -89,6 +89,23 @@ HUBSPOT_API_TOKEN= # HUBSPOT_PORTAL_ID=6893032 # HOTJAR_ID=1095300 +# -------------------------- Meilisearch --------------------------------- + +# Meilisearch host URL (server-side only — never exposed to the browser). +MEILISEARCH_HOST=http://localhost:7700 +# Host URL for docs +MEILISEARCH_DOCS_HOST= + +# API key with read access on the indexes consumed by the UI. +MEILISEARCH_API_KEY= +# API key with read access to the Strapi documentation +MEILISEARCH_DOCS_API_KEY= + +# Toggles which env's indexes the UI reads from. +# true -> *-production indexes +# false -> *-testing indexes +MEILISEARCH_PRODUCTION=false + # -------------------------- Demo provisioning --------------------------------- # Demo operator service URL and auth token (for /demo page form provisioning) diff --git a/apps/ui/locales/en.json b/apps/ui/locales/en.json index a64a9341..7c6c775c 100644 --- a/apps/ui/locales/en.json +++ b/apps/ui/locales/en.json @@ -102,6 +102,11 @@ "filterCategoriesLabel": "Categories", "loadMore": "See more" }, + "features": { + "searchPlaceholder": "Search features...", + "loadMore": "See more", + "viewFeature": "Learn more" + }, "blog": { "title": "Blog", "description": "Tutorials, product updates, and engineering insights.", diff --git a/apps/ui/package.json b/apps/ui/package.json index 34b16a62..bae67a69 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -40,6 +40,7 @@ "dayjs": "^1.11.19", "embla-carousel-react": "^8.6.0", "lodash": "catalog:", + "meilisearch": "^0.58.0", "motion": "^12.35.2", "next": "16.1.6", "next-intl": "4.8.3", diff --git a/apps/ui/src/app/[locale]/user-stories/page.tsx b/apps/ui/src/app/[locale]/user-stories/page.tsx deleted file mode 100644 index 9fe3c0dd..00000000 --- a/apps/ui/src/app/[locale]/user-stories/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { Metadata } from "next" -import type { Locale } from "next-intl" -import { getTranslations, setRequestLocale } from "next-intl/server" - -import { CaseStudiesGrid } from "@/components/case-study/CaseStudiesGrid" -import { Container } from "@/components/elementary/Container" -import { - HeroContainer, - HeroContainerBorder, - HeroContainerContent, -} from "@/components/elementary/HeroContainer" -import { - SectionDescription, - SectionHeader, - SectionHeaderContainer, - SectionLabel, - SectionTitle, -} from "@/components/elementary/section-header" -import { fetchCaseStudiesList } from "@/lib/strapi-api/content/server" - -export const dynamic = "force-static" - -interface RouteProps { - params: Promise<{ locale: string }> -} - -export async function generateMetadata(props: RouteProps): Promise { - const { locale } = await props.params - const t = await getTranslations({ - locale: locale as Locale, - namespace: "caseStudies", - }) - - return { - title: t("title"), - description: t("description"), - } -} - -export default async function UserStoriesIndexPage(props: RouteProps) { - const { locale: localeParam } = await props.params - const locale = localeParam as Locale - - setRequestLocale(locale) - - const [t, listRes] = await Promise.all([ - getTranslations({ locale, namespace: "caseStudies" }), - fetchCaseStudiesList(locale), - ]) - - const items = listRes?.data ?? [] - - return ( - <> - - - - - - - - {t("sectionLabel")} - - - {t("title")} - - - {t("intro")} - - - - - - - - - - - - - ) -} diff --git a/apps/ui/src/components/case-study/CaseStudiesGrid.tsx b/apps/ui/src/components/case-study/CaseStudiesGrid.tsx index 3475171f..40ef6a16 100644 --- a/apps/ui/src/components/case-study/CaseStudiesGrid.tsx +++ b/apps/ui/src/components/case-study/CaseStudiesGrid.tsx @@ -3,7 +3,7 @@ import { MagnifyingGlassIcon } from "@phosphor-icons/react/ssr" import type { Data } from "@repo/strapi-types" import { useTranslations } from "next-intl" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState, useTransition } from "react" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -11,84 +11,108 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { cn } from "@/lib/styles" +import type { + CaseStudyHit, + SearchCaseStudiesArgs, +} from "./case-studies-search-types" import { CaseStudyCard } from "./CaseStudyCard" type CaseStudy = Data.ContentType<"api::case-study.case-study"> -interface CaseStudyCategory { - readonly name?: string | null - readonly slug?: string | null -} - interface CaseStudiesGridProps { - readonly items: readonly CaseStudy[] + readonly locale: string + readonly initialHits: readonly CaseStudyHit[] + readonly initialTotal: number + readonly pageSize?: number + readonly searchAction: ( + args: SearchCaseStudiesArgs + ) => Promise<{ hits: readonly CaseStudyHit[]; total: number }> readonly className?: string } -const PAGE_SIZE = 12 - -function deriveCategoryOptions( - items: readonly CaseStudy[] -): readonly { label: string; value: string }[] { - const seen = new Map() +const DEFAULT_PAGE_SIZE = 12 +function collectCategories( + into: Map, + items: readonly CaseStudyHit[] +): Map { for (const item of items) { - const categories = (item as { categories?: readonly CaseStudyCategory[] }) - .categories + if (!item.categories) continue - if (!categories) continue - - for (const cat of categories) { - if (cat?.slug && cat.name && !seen.has(cat.slug)) { - seen.set(cat.slug, cat.name) + for (const cat of item.categories) { + if (cat?.slug && cat.name && !into.has(cat.slug)) { + into.set(cat.slug, cat.name) } } } - return [...seen.entries()].map(([value, label]) => ({ label, value })) -} - -function itemMatchesCategories( - item: CaseStudy, - selected: ReadonlySet -): boolean { - if (selected.size === 0) return true - - const categories = (item as { categories?: readonly CaseStudyCategory[] }) - .categories - - if (!categories || categories.length === 0) return false - - return categories.some((c) => c?.slug != null && selected.has(c.slug)) + return into } -export function CaseStudiesGrid({ items, className }: CaseStudiesGridProps) { +export function CaseStudiesGrid({ + locale, + initialHits, + initialTotal, + pageSize = DEFAULT_PAGE_SIZE, + searchAction, + className, +}: CaseStudiesGridProps) { const t = useTranslations("caseStudies") + const [hits, setHits] = useState(initialHits) + const [total, setTotal] = useState(initialTotal) const [query, setQuery] = useState("") const [selectedCategories, setSelectedCategories] = useState< ReadonlySet >(new Set()) - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) - - const categoryOptions = useMemo(() => deriveCategoryOptions(items), [items]) + const [knownCategories, setKnownCategories] = useState< + ReadonlyMap + >(() => collectCategories(new Map(), initialHits)) + const [isPending, startTransition] = useTransition() + + const categoryOptions = useMemo( + () => + [...knownCategories.entries()].map(([value, label]) => ({ + label, + value, + })), + [knownCategories] + ) - const filtered = useMemo(() => { - const q = query.trim().toLowerCase() + function mergeCategories(items: readonly CaseStudyHit[]) { + setKnownCategories((prev) => { + const next = collectCategories(new Map(prev), items) - return items.filter((item) => { - if (q.length > 0) { - const title = item.title?.toLowerCase() ?? "" + return next.size === prev.size ? prev : next + }) + } - if (!title.includes(q)) return false - } + const isFirstRender = useRef(true) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false - return itemMatchesCategories(item, selectedCategories) - }) - }, [items, query, selectedCategories]) + return + } - const visible = filtered.slice(0, visibleCount) - const hasMore = filtered.length > visibleCount + const handle = setTimeout(() => { + startTransition(async () => { + const res = await searchAction({ + locale, + query, + categorySlugs: [...selectedCategories], + offset: 0, + limit: pageSize, + }) + + setHits(res.hits) + setTotal(res.total) + mergeCategories(res.hits) + }) + }, 200) + + return () => clearTimeout(handle) + }, [query, selectedCategories, locale, pageSize, searchAction]) function toggleCategory(slug: string) { setSelectedCategories((prev) => { @@ -102,14 +126,26 @@ export function CaseStudiesGrid({ items, className }: CaseStudiesGridProps) { return next }) - setVisibleCount(PAGE_SIZE) } - function handleSearchChange(e: React.ChangeEvent) { - setQuery(e.target.value) - setVisibleCount(PAGE_SIZE) + function loadMore() { + startTransition(async () => { + const res = await searchAction({ + locale, + query, + categorySlugs: [...selectedCategories], + offset: hits.length, + limit: pageSize, + }) + + setHits((prev) => [...prev, ...res.hits]) + setTotal(res.total) + mergeCategories(res.hits) + }) } + const hasMore = hits.length < total + return (
@@ -156,18 +192,18 @@ export function CaseStudiesGrid({ items, className }: CaseStudiesGridProps) {
- {visible.map((item) => ( - + {hits.map((item) => ( + ))}
{hasMore && (
-
)} diff --git a/apps/ui/src/components/case-study/case-studies-search-types.ts b/apps/ui/src/components/case-study/case-studies-search-types.ts new file mode 100644 index 00000000..1d2fc678 --- /dev/null +++ b/apps/ui/src/components/case-study/case-studies-search-types.ts @@ -0,0 +1,31 @@ +export interface CaseStudyHit { + readonly id?: number + readonly documentId?: string + readonly slug: string + readonly title: string + readonly companyName?: string | null + readonly description?: string | null + readonly originalPublishedAt?: string | null + readonly locale?: string + readonly coverImage?: { readonly image?: unknown } | null + readonly categories?: + | readonly { + readonly name?: string + readonly slug?: string + }[] + | null + readonly [key: string]: unknown +} + +export interface CaseStudiesSearchResult { + readonly hits: readonly CaseStudyHit[] + readonly total: number +} + +export interface SearchCaseStudiesArgs { + readonly locale: string + readonly query: string + readonly categorySlugs: readonly string[] + readonly offset: number + readonly limit: number +} diff --git a/apps/ui/src/components/case-study/case-studies-search.ts b/apps/ui/src/components/case-study/case-studies-search.ts new file mode 100644 index 00000000..642679cd --- /dev/null +++ b/apps/ui/src/components/case-study/case-studies-search.ts @@ -0,0 +1,47 @@ +"use server" + +import { + getCaseStudiesIndexName, + getMeilisearchClient, +} from "@/lib/meilisearch" + +import type { + CaseStudiesSearchResult, + CaseStudyHit, + SearchCaseStudiesArgs, +} from "./case-studies-search-types" + +function escape(value: string): string { + // eslint-disable-next-line unicorn/prefer-string-raw -- escaping a single backslash in a template literal is awkward + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"') +} + +export async function searchCaseStudies({ + query, + categorySlugs, + offset, + limit, +}: SearchCaseStudiesArgs): Promise { + const index = getMeilisearchClient().index( + getCaseStudiesIndexName() + ) + + const filter: string[] = [] + + if (categorySlugs.length > 0) { + const list = categorySlugs.map((s) => `"${escape(s)}"`).join(", ") + filter.push(`categories.slug IN [${list}]`) + } + + const res = await index.search(query.trim(), { + offset, + limit, + filter, + sort: ["originalPublishedAt:desc"], + }) + + return { + hits: res.hits, + total: res.estimatedTotalHits ?? res.hits.length, + } +} diff --git a/apps/ui/src/components/elementary/GlobalSearch.tsx b/apps/ui/src/components/elementary/GlobalSearch.tsx new file mode 100644 index 00000000..2267fa97 --- /dev/null +++ b/apps/ui/src/components/elementary/GlobalSearch.tsx @@ -0,0 +1,37 @@ +"use client" + +import { MagnifyingGlassIcon } from "@phosphor-icons/react/ssr" +import { useEffect, useState } from "react" + +import { GlobalSearchModal } from "@/components/elementary/GlobalSearchModal" + +export function GlobalSearch() { + const [open, setOpen] = useState(false) + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + setOpen((prev) => !prev) + } + } + + document.addEventListener("keydown", onKeyDown) + + return () => document.removeEventListener("keydown", onKeyDown) + }, []) + + return ( + <> + + + + ) +} diff --git a/apps/ui/src/components/elementary/GlobalSearchModal.tsx b/apps/ui/src/components/elementary/GlobalSearchModal.tsx new file mode 100644 index 00000000..6986a1c4 --- /dev/null +++ b/apps/ui/src/components/elementary/GlobalSearchModal.tsx @@ -0,0 +1,268 @@ +"use client" + +import { MagnifyingGlassIcon } from "@phosphor-icons/react/ssr" +import { Command } from "cmdk" +import { useLocale } from "next-intl" +import { useEffect, useState, useTransition } from "react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { useRouter } from "@/lib/navigation" +import { cn } from "@/lib/styles" + +import { globalSearch } from "./global-search-action" +import type { GlobalSearchResult } from "./global-search-types" + +interface GlobalSearchModalProps { + readonly open: boolean + readonly onOpenChange: (open: boolean) => void +} + +const DEBOUNCE_MS = 350 + +const EMPTY_RESULT: GlobalSearchResult = { + caseStudies: [], + pages: [], + blogPosts: [], + docs: [], +} + +function docsTitle(item: { + readonly hierarchy_lvl0?: string | null + readonly hierarchy_lvl1?: string | null + readonly hierarchy_lvl2?: string | null + readonly hierarchy_lvl3?: string | null +}): string { + return ( + item.hierarchy_lvl3 || + item.hierarchy_lvl2 || + item.hierarchy_lvl1 || + item.hierarchy_lvl0 || + "Untitled" + ) +} + +function docsBreadcrumb(item: { + readonly hierarchy_lvl0?: string | null + readonly hierarchy_lvl1?: string | null + readonly hierarchy_lvl2?: string | null +}): string { + return [item.hierarchy_lvl0, item.hierarchy_lvl1, item.hierarchy_lvl2] + .filter(Boolean) + .join(" › ") +} + +const itemClass = cn( + "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground", + "flex cursor-pointer flex-col gap-0.5 rounded-md px-3 py-2 text-sm outline-none" +) + +const groupClass = cn( + "[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider" +) + +export function GlobalSearchModal({ + open, + onOpenChange, +}: GlobalSearchModalProps) { + const locale = useLocale() + const router = useRouter() + + const [query, setQuery] = useState("") + const [results, setResults] = useState(EMPTY_RESULT) + const [isPending, startTransition] = useTransition() + + useEffect(() => { + const trimmed = query.trim() + + if (trimmed.length === 0) return + + const handle = setTimeout(() => { + startTransition(async () => { + const res = await globalSearch({ query: trimmed, locale }) + + setResults(res) + }) + }, DEBOUNCE_MS) + + return () => clearTimeout(handle) + }, [query, locale]) + + function handleQueryChange(next: string) { + setQuery(next) + + if (next.trim().length === 0) { + setResults(EMPTY_RESULT) + } + } + + function handleOpenChange(next: boolean) { + if (!next) { + setQuery("") + setResults(EMPTY_RESULT) + } + + onOpenChange(next) + } + + function navigateAndClose(href: string) { + handleOpenChange(false) + router.push(href) + } + + const hasAny = + results.pages.length > 0 || + results.docs.length > 0 || + results.blogPosts.length > 0 || + results.caseStudies.length > 0 + + return ( + + + + Search + Search across the site + + +
+ + +
+ + {query.trim().length === 0 ? ( +

+ Start typing to search. +

+ ) : isPending && !hasAny ? ( +

+ Searching… +

+ ) : !hasAny ? ( + + No results found. + + ) : null} + + {results.pages.length > 0 && ( + + {results.pages.map((item) => ( + navigateAndClose(item.fullPath)} + className={itemClass} + > + + {item.title} + + + {`/${item.slug}`} + + + ))} + + )} + + {results.docs.length > 0 && ( + + {results.docs.map((item) => { + const breadcrumb = docsBreadcrumb(item) + + return ( + { + handleOpenChange(false) + window.open(item.url, "_blank", "noopener,noreferrer") + }} + className={itemClass} + > + + {docsTitle(item)} + + {breadcrumb && ( + + {breadcrumb} + + )} + + ) + })} + + )} + + {results.blogPosts.length > 0 && ( + + {results.blogPosts.map((item) => { + const href = `/blog/${item.slug}` + + return ( + navigateAndClose(href)} + className={itemClass} + > + + {item.title} + + {item.description && ( + + {item.description} + + )} + + ) + })} + + )} + + {results.caseStudies.length > 0 && ( + + {results.caseStudies.map((item) => { + const href = `/user-stories/${item.slug}` + + return ( + navigateAndClose(href)} + className={itemClass} + > + + {item.title} + + {item.companyName && ( + + {item.companyName} + + )} + + ) + })} + + )} +
+
+
+
+ ) +} diff --git a/apps/ui/src/components/elementary/global-search-action.ts b/apps/ui/src/components/elementary/global-search-action.ts new file mode 100644 index 00000000..e9b16793 --- /dev/null +++ b/apps/ui/src/components/elementary/global-search-action.ts @@ -0,0 +1,91 @@ +"use server" + +import { + DOCS_INDEX_NAME, + getBlogPostsIndexName, + getCaseStudiesIndexName, + getMeilisearchClient, + getMeilisearchDocsClient, + getPagesIndexName, +} from "@/lib/meilisearch" + +import type { + BlogPostGlobalHit, + CaseStudyGlobalHit, + DocsGlobalHit, + GlobalSearchResult, + PageGlobalHit, +} from "./global-search-types" + +const PER_INDEX_LIMIT = 5 +const PAGES_LIMIT = 3 + +function escape(value: string): string { + // eslint-disable-next-line unicorn/prefer-string-raw -- escaping a single backslash in a template literal is awkward + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"') +} + +export async function globalSearch({ + query, + locale, +}: { + readonly query: string + readonly locale: string +}): Promise { + const trimmed = query.trim() + + if (trimmed.length === 0) { + return { caseStudies: [], pages: [], blogPosts: [], docs: [] } + } + + const [siteRes, docsRes] = await Promise.all([ + getMeilisearchClient().multiSearch({ + queries: [ + { + indexUid: getCaseStudiesIndexName(), + q: trimmed, + limit: PER_INDEX_LIMIT, + attributesToRetrieve: ["slug", "title", "companyName"], + }, + { + indexUid: getPagesIndexName(), + q: trimmed, + limit: PAGES_LIMIT, + filter: [`locale = "${escape(locale)}"`], + attributesToRetrieve: ["slug", "title", "fullPath", "pageType"], + }, + { + indexUid: getBlogPostsIndexName(), + q: trimmed, + limit: PER_INDEX_LIMIT, + attributesToRetrieve: ["slug", "title", "description"], + }, + ], + }), + getMeilisearchDocsClient() + .index(DOCS_INDEX_NAME) + .search(trimmed, { + limit: PER_INDEX_LIMIT, + attributesToRetrieve: [ + "url", + "hierarchy_lvl0", + "hierarchy_lvl1", + "hierarchy_lvl2", + "hierarchy_lvl3", + "anchor", + ], + }) + .catch(() => ({ hits: [] })), + ]) + + const [caseStudies, pages, blogPosts] = siteRes.results + + return { + caseStudies: (caseStudies?.hits ?? + []) as unknown as readonly CaseStudyGlobalHit[], + pages: (pages?.hits ?? []) as unknown as readonly PageGlobalHit[], + blogPosts: (blogPosts?.hits ?? + []) as unknown as readonly BlogPostGlobalHit[], + docs: (docsRes.hits ?? []) as unknown as readonly DocsGlobalHit[], + } +} diff --git a/apps/ui/src/components/elementary/global-search-types.ts b/apps/ui/src/components/elementary/global-search-types.ts new file mode 100644 index 00000000..da1d054c --- /dev/null +++ b/apps/ui/src/components/elementary/global-search-types.ts @@ -0,0 +1,34 @@ +export interface CaseStudyGlobalHit { + readonly slug: string + readonly title: string + readonly companyName?: string | null +} + +export interface PageGlobalHit { + readonly slug: string + readonly title: string + readonly fullPath: string + readonly pageType?: string +} + +export interface BlogPostGlobalHit { + readonly slug: string + readonly title: string + readonly description?: string | null +} + +export interface DocsGlobalHit { + readonly url: string + readonly hierarchy_lvl0?: string | null + readonly hierarchy_lvl1?: string | null + readonly hierarchy_lvl2?: string | null + readonly hierarchy_lvl3?: string | null + readonly anchor?: string | null +} + +export interface GlobalSearchResult { + readonly caseStudies: readonly CaseStudyGlobalHit[] + readonly pages: readonly PageGlobalHit[] + readonly blogPosts: readonly BlogPostGlobalHit[] + readonly docs: readonly DocsGlobalHit[] +} diff --git a/apps/ui/src/components/feature-page/FeaturePagesGrid.tsx b/apps/ui/src/components/feature-page/FeaturePagesGrid.tsx new file mode 100644 index 00000000..e29c0232 --- /dev/null +++ b/apps/ui/src/components/feature-page/FeaturePagesGrid.tsx @@ -0,0 +1,136 @@ +"use client" + +import { ArrowRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react/ssr" +import { useTranslations } from "next-intl" +import { useEffect, useRef, useState, useTransition } from "react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardFooter } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Link } from "@/lib/navigation" +import { cn } from "@/lib/styles" + +import type { + FeaturePageHit, + SearchFeaturePagesArgs, +} from "./feature-pages-search-types" + +interface FeaturePagesGridProps { + readonly locale: string + readonly initialHits: readonly FeaturePageHit[] + readonly initialTotal: number + readonly pageSize?: number + readonly searchAction: ( + args: SearchFeaturePagesArgs + ) => Promise<{ hits: readonly FeaturePageHit[]; total: number }> + readonly className?: string +} + +const DEFAULT_PAGE_SIZE = 12 + +export function FeaturePagesGrid({ + locale, + initialHits, + initialTotal, + pageSize = DEFAULT_PAGE_SIZE, + searchAction, + className, +}: FeaturePagesGridProps) { + const t = useTranslations("features") + + const [hits, setHits] = useState(initialHits) + const [total, setTotal] = useState(initialTotal) + const [query, setQuery] = useState("") + const [isPending, startTransition] = useTransition() + + const isFirstRender = useRef(true) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + + return + } + + const handle = setTimeout(() => { + startTransition(async () => { + const res = await searchAction({ + locale, + query, + offset: 0, + limit: pageSize, + }) + + setHits(res.hits) + setTotal(res.total) + }) + }, 200) + + return () => clearTimeout(handle) + }, [query, locale, pageSize, searchAction]) + + function loadMore() { + startTransition(async () => { + const res = await searchAction({ + locale, + query, + offset: hits.length, + limit: pageSize, + }) + + setHits((prev) => [...prev, ...res.hits]) + setTotal(res.total) + }) + } + + const hasMore = hits.length < total + + return ( +
+ + +
+
+ {hits.map((item) => ( + + + +

+ {item.title} +

+
+ + + + {t("viewFeature")} + + + + +
+ ))} +
+ + {hasMore && ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/ui/src/components/feature-page/feature-pages-search-types.ts b/apps/ui/src/components/feature-page/feature-pages-search-types.ts new file mode 100644 index 00000000..d0c80c36 --- /dev/null +++ b/apps/ui/src/components/feature-page/feature-pages-search-types.ts @@ -0,0 +1,22 @@ +export interface FeaturePageHit { + readonly id?: number + readonly documentId?: string + readonly slug: string + readonly title: string + readonly fullPath: string + readonly locale?: string + readonly pageType?: string + readonly [key: string]: unknown +} + +export interface FeaturePagesSearchResult { + readonly hits: readonly FeaturePageHit[] + readonly total: number +} + +export interface SearchFeaturePagesArgs { + readonly locale: string + readonly query: string + readonly offset: number + readonly limit: number +} diff --git a/apps/ui/src/components/feature-page/feature-pages-search.ts b/apps/ui/src/components/feature-page/feature-pages-search.ts new file mode 100644 index 00000000..7a30ec31 --- /dev/null +++ b/apps/ui/src/components/feature-page/feature-pages-search.ts @@ -0,0 +1,40 @@ +"use server" + +import { getMeilisearchClient, getPagesIndexName } from "@/lib/meilisearch" + +import type { + FeaturePageHit, + FeaturePagesSearchResult, + SearchFeaturePagesArgs, +} from "./feature-pages-search-types" + +function escape(value: string): string { + // eslint-disable-next-line unicorn/prefer-string-raw -- escaping a single backslash in a template literal is awkward + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"') +} + +export async function searchFeaturePages({ + locale, + query, + offset, + limit, +}: SearchFeaturePagesArgs): Promise { + const index = + getMeilisearchClient().index(getPagesIndexName()) + + const filter: string[] = [ + `pageType = "feature"`, + `locale = "${escape(locale)}"`, + ] + + const res = await index.search(query.trim(), { + offset, + limit, + filter, + }) + + return { + hits: res.hits, + total: res.estimatedTotalHits ?? res.hits.length, + } +} diff --git a/apps/ui/src/components/layouts/StrapiPageView.tsx b/apps/ui/src/components/layouts/StrapiPageView.tsx index 43387b15..82fd2aad 100644 --- a/apps/ui/src/components/layouts/StrapiPageView.tsx +++ b/apps/ui/src/components/layouts/StrapiPageView.tsx @@ -60,9 +60,7 @@ export function StrapiPageView({ params, searchParams }: Props) {
{minimalLayout &&