From 5537e106e7554ff25606b98c7d2c3a798966397a Mon Sep 17 00:00:00 2001 From: Anna Date: Sun, 18 Jan 2026 12:37:54 +0000 Subject: [PATCH 1/2] feature(blog): Adding blog header and category pages --- .../app/[locale]/blog/[slug]/PostDetails.tsx | 12 ++- .../blog/category/[category]/page.tsx | 101 ++++++++++++++++++ frontend/app/[locale]/blog/page.tsx | 6 +- frontend/app/[locale]/layout.tsx | 18 +++- frontend/components/blog/BlogCard.tsx | 58 +++++----- frontend/components/blog/BlogCategoryGrid.tsx | 10 ++ .../components/blog/BlogCategoryLinks.tsx | 81 ++++++++++++++ frontend/components/blog/BlogFilters.tsx | 11 +- frontend/components/blog/BlogNavLinks.tsx | 91 ++++++++++++++++ frontend/components/header/AppChrome.tsx | 16 ++- frontend/components/header/AppMobileMenu.tsx | 37 ++++--- frontend/components/header/UnifiedHeader.tsx | 21 ++-- studio/schemaTypes/post.ts | 6 -- 13 files changed, 407 insertions(+), 61 deletions(-) create mode 100644 frontend/app/[locale]/blog/category/[category]/page.tsx create mode 100644 frontend/components/blog/BlogCategoryGrid.tsx create mode 100644 frontend/components/blog/BlogCategoryLinks.tsx create mode 100644 frontend/components/blog/BlogNavLinks.tsx diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index fdf5efb8..34169551 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -5,6 +5,8 @@ import { getTranslations } from 'next-intl/server'; import { client } from '@/client'; import { Link } from '@/i18n/routing'; +export const revalidate = 0; + type SocialLink = { _key?: string; platform?: string; @@ -116,11 +118,15 @@ export default async function PostDetails({ const slugParam = String(slug || '').trim(); if (!slugParam) return notFound(); - const post: Post | null = await client.fetch(query, { + const post: Post | null = await client + .withConfig({ useCdn: false }) + .fetch(query, { slug: slugParam, locale, }); - const recommendedAll: Post[] = await client.fetch(recommendedQuery, { + const recommendedAll: Post[] = await client + .withConfig({ useCdn: false }) + .fetch(recommendedQuery, { slug: slugParam, locale, }); @@ -156,7 +162,7 @@ export default async function PostDetails({ href={`/blog?category=${encodeURIComponent(post.categories[0])}`} className="inline-flex items-center gap-1 hover:text-[#ff00ff] transition" > - {post.categories[0]} + {post.categories[0] === 'Growth' ? 'Career' : post.categories[0]} )} diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx new file mode 100644 index 00000000..5de76c23 --- /dev/null +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -0,0 +1,101 @@ +import groq from 'groq'; +import { notFound } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; +import { client } from '@/client'; +import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid'; + +export const revalidate = 0; + +type Author = { + name?: string; + image?: string; +}; + +type Post = { + _id: string; + title: string; + slug: { current: string }; + publishedAt?: string; + categories?: string[]; + mainImage?: string; + body?: any[]; + author?: Author; +}; + +type Category = { + _id: string; + title: string; +}; + +const categoriesQuery = groq` + *[_type == "category"] | order(orderRank asc) { + _id, + title + } +`; + +export default async function BlogCategoryPage({ + params, +}: { + params: Promise<{ locale: string; category: string }>; +}) { + const { locale, category } = await params; + const t = await getTranslations({ locale, namespace: 'blog' }); + const categoryKey = String(category || '').toLowerCase(); + const categories: Category[] = await client + .withConfig({ useCdn: false }) + .fetch(categoriesQuery); + const matchedCategory = categories.find( + item => slugify(item.title) === categoryKey + ); + + if (!matchedCategory) return notFound(); + const categoryTitle = matchedCategory.title; + const displayTitle = + categoryTitle === 'Growth' ? 'Career' : categoryTitle; + + const posts: Post[] = await client.withConfig({ useCdn: false }).fetch( + groq` + *[_type == "post" && defined(slug.current) && $category in categories[]->title] + | order(publishedAt desc) { + _id, + "title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title), + slug, + publishedAt, + "categories": categories[]->title, + "body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{ + ..., + children[]{ text } + }, + "mainImage": mainImage.asset->url, + "author": author->{ + "name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name), + "image": image.asset->url + } + } + `, + { locale, category: categoryTitle } + ); + + return ( +
+

+ {displayTitle} +

+
+ +
+ {!posts.length && ( +

{t('noPosts')}

+ )} +
+ ); +} + +function slugify(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-'); +} diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index ecc85341..bd751d04 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server'; import { client } from '@/client'; import BlogFilters from '@/components/blog/BlogFilters'; +export const revalidate = 0; + export async function generateMetadata({ params, }: { @@ -25,7 +27,7 @@ export default async function BlogPage({ const { locale } = await params; const t = await getTranslations({ locale, namespace: 'blog' }); - const posts = await client.fetch( + const posts = await client.withConfig({ useCdn: false }).fetch( groq` *[_type == "post" && defined(slug.current)] | order(publishedAt desc) { @@ -62,7 +64,7 @@ export default async function BlogPage({ `, { locale } ); - const categories = await client.fetch( + const categories = await client.withConfig({ useCdn: false }).fetch( groq` *[_type == "category"] | order(orderRank asc) { _id, diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index 96f569b3..5cc51227 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -3,11 +3,13 @@ import { Toaster } from 'sonner'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; import { notFound } from 'next/navigation'; +import groq from 'groq'; import { locales } from '@/i18n/config'; import Footer from '@/components/shared/Footer'; import { ThemeProvider } from '@/components/theme/ThemeProvider'; import { getCurrentUser } from '@/lib/auth'; +import { client } from '@/client'; import { MainSwitcher } from '@/components/header/MainSwitcher'; import { AppChrome } from '@/components/header/AppChrome'; @@ -29,6 +31,16 @@ export default async function LocaleLayout({ const messages = await getMessages({ locale }); const user = await getCurrentUser(); + const blogCategories: Array<{ _id: string; title: string }> = await client + .withConfig({ useCdn: false }) + .fetch( + groq` + *[_type == "category"] | order(orderRank asc) { + _id, + title + } + ` + ); const userExists = Boolean(user); const showAdminNavLink = process.env.NEXT_PUBLIC_ENABLE_ADMIN === 'true'; @@ -41,7 +53,11 @@ export default async function LocaleLayout({ enableSystem disableTransitionOnChange > - + {children} diff --git a/frontend/components/blog/BlogCard.tsx b/frontend/components/blog/BlogCard.tsx index 41a2b8ce..7599939d 100644 --- a/frontend/components/blog/BlogCard.tsx +++ b/frontend/components/blog/BlogCard.tsx @@ -38,6 +38,8 @@ export default function BlogCard({ year: 'numeric', }).format(date); }, [post.publishedAt, locale]); + const categoryLabel = + post.categories?.[0] === 'Growth' ? 'Career' : post.categories?.[0]; return (
)} -
+
+

{excerpt}

)} -
- {post.author?.name && ( -
- - {formattedDate && ·} +
+ {(post.author?.name || formattedDate || categoryLabel) && ( +
+ {post.author?.name && ( + + )} + {post.author?.name && formattedDate && ·} {formattedDate && {formattedDate}} + {(formattedDate || post.author?.name) && categoryLabel && ( + · + )} + {categoryLabel && {categoryLabel}}
)} diff --git a/frontend/components/blog/BlogCategoryGrid.tsx b/frontend/components/blog/BlogCategoryGrid.tsx new file mode 100644 index 00000000..349ef1ed --- /dev/null +++ b/frontend/components/blog/BlogCategoryGrid.tsx @@ -0,0 +1,10 @@ +'use client'; + +import BlogGrid from '@/components/blog/BlogGrid'; +import type { Post } from '@/components/blog/BlogFilters'; + +export function BlogCategoryGrid({ posts }: { posts: Post[] }) { + if (!posts.length) return null; + + return {}} />; +} diff --git a/frontend/components/blog/BlogCategoryLinks.tsx b/frontend/components/blog/BlogCategoryLinks.tsx new file mode 100644 index 00000000..01b3f458 --- /dev/null +++ b/frontend/components/blog/BlogCategoryLinks.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Link, usePathname } from '@/i18n/routing'; +import { cn } from '@/lib/utils'; + +type Category = { + _id: string; + title: string; +}; + +type BlogCategoryLinksProps = { + categories: Category[]; + className?: string; + linkClassName?: string; + onNavigate?: () => void; +}; + +export function BlogCategoryLinks({ + categories, + className, + linkClassName, + onNavigate, +}: BlogCategoryLinksProps) { + const pathname = usePathname(); + const baseLink = + linkClassName || + 'rounded-md px-3 py-2 text-sm font-medium transition-colors ' + + 'hover:bg-secondary hover:text-foreground ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ' + + 'focus-visible:ring-offset-2 focus-visible:ring-offset-background'; + + const items = categories + .map(category => ({ + ...category, + slug: slugify(category.title || ''), + displayTitle: category.title === 'Growth' ? 'Career' : category.title, + })) + .filter(category => category.slug); + + return ( + + ); +} + +function slugify(value: string) { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-'); +} diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx index 8b794294..601615d7 100644 --- a/frontend/components/blog/BlogFilters.tsx +++ b/frontend/components/blog/BlogFilters.tsx @@ -131,7 +131,7 @@ export default function BlogFilters({ return categories .map(category => ({ norm: normalizeTag(category.title), - name: category.title, + name: category.title === 'Growth' ? 'Career' : category.title, })) .filter(category => category.norm); } @@ -145,7 +145,10 @@ export default function BlogFilters({ } } return Array.from(map.entries()) - .map(([norm, name]) => ({ norm, name })) + .map(([norm, name]) => ({ + norm, + name: name === 'Growth' ? 'Career' : name, + })) .sort((a, b) => a.name.localeCompare(b.name)); }, [posts, categories]); const categoryParam = useMemo(() => { @@ -195,11 +198,11 @@ export default function BlogFilters({
{!selectedAuthor && featuredPost && (
-
+
{featuredPost.mainImage && ( void; +}; + +export function BlogNavLinks({ + className, + linkClassName, + onNavigate, +}: BlogNavLinksProps) { + const [categories, setCategories] = useState([]); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const currentCategory = searchParams.get('category'); + + useEffect(() => { + let active = true; + client + .fetch(categoriesQuery) + .then(result => { + if (!active) return; + setCategories(result || []); + }) + .catch(() => { + if (!active) return; + setCategories([]); + }); + return () => { + active = false; + }; + }, []); + + const baseLink = + linkClassName || + 'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-secondary hover:text-foreground'; + + const containerClassName = cn('flex items-center gap-1', className); + const isBlogPath = pathname.startsWith('/blog'); + + const items = useMemo(() => { + return categories + .filter(category => category.title) + .map(category => ({ + ...category, + isActive: isBlogPath && currentCategory === category.title, + })); + }, [categories, currentCategory, isBlogPath]); + + if (!items.length) return null; + + return ( + + ); +} diff --git a/frontend/components/header/AppChrome.tsx b/frontend/components/header/AppChrome.tsx index 4eca744e..eedf29be 100644 --- a/frontend/components/header/AppChrome.tsx +++ b/frontend/components/header/AppChrome.tsx @@ -9,12 +9,19 @@ import { CartProvider } from '@/components/shop/cart-provider'; type AppChromeProps = { userExists: boolean; showAdminLink?: boolean; + blogCategories?: Array<{ _id: string; title: string }>; children: React.ReactNode; }; -export function AppChrome({ userExists, showAdminLink = false, children }: AppChromeProps) { +export function AppChrome({ + userExists, + showAdminLink = false, + blogCategories = [], + children, +}: AppChromeProps) { const segments = useSelectedLayoutSegments(); const isShop = segments.includes('shop'); + const isBlog = segments.includes('blog'); if (isShop) { return ( @@ -24,6 +31,7 @@ export function AppChrome({ userExists, showAdminLink = false, children }: AppCh variant="shop" userExists={userExists} showAdminLink={showAdminLink} + blogCategories={blogCategories} /> {children}
@@ -33,7 +41,11 @@ export function AppChrome({ userExists, showAdminLink = false, children }: AppCh return ( <> - + {children} ); diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index 239cb3c9..af2b820f 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -6,20 +6,23 @@ import { Link } from '@/i18n/routing'; import { SITE_LINKS } from '@/lib/navigation'; import { NAV_LINKS } from '@/components/shop/header/nav-links'; +import { BlogCategoryLinks } from '@/components/blog/BlogCategoryLinks'; import { LogoutButton } from '@/components/auth/logoutButton'; -export type AppMobileMenuVariant = 'platform' | 'shop'; +export type AppMobileMenuVariant = 'platform' | 'shop' | 'blog'; type Props = { variant: AppMobileMenuVariant; userExists: boolean; showAdminLink?: boolean; + blogCategories?: Array<{ _id: string; title: string }>; }; export function AppMobileMenu({ variant, userExists, showAdminLink = false, + blogCategories = [], }: Props) { const [open, setOpen] = useState(false); @@ -39,7 +42,8 @@ export function AppMobileMenu({ const links = useMemo(() => { if (variant === 'shop') return NAV_LINKS; - return SITE_LINKS; + if (variant === 'platform') return SITE_LINKS; + return []; }, [variant]); return ( @@ -79,16 +83,25 @@ export function AppMobileMenu({ ) : null} - {links.map(link => ( - - {link.label} - - ))} + {variant === 'blog' ? ( + + ) : ( + links.map(link => ( + + {link.label} + + )) + )} {variant === 'shop' && showAdminLink ? ( ; }; export function UnifiedHeader({ @@ -25,15 +27,19 @@ export function UnifiedHeader({ userExists, showAdminLink = false, enableSearch = true, + blogCategories = [], }: UnifiedHeaderProps) { const isShop = variant === 'shop'; + const isBlog = variant === 'blog'; + const brandHref = isShop ? '/shop' : isBlog ? '/blog' : '/'; + const brandBadge = isShop ? 'Shop' : isBlog ? 'Blog' : ''; return (
@@ -42,11 +48,11 @@ export function UnifiedHeader({ - Shop + {brandBadge}
@@ -61,6 +67,8 @@ export function UnifiedHeader({ showAdminLink={showAdminLink} includeHomeLink /> + ) : isBlog ? ( + ) : (
{SITE_LINKS.map(link => ( @@ -125,9 +133,10 @@ export function UnifiedHeader({ {isShop && }
diff --git a/studio/schemaTypes/post.ts b/studio/schemaTypes/post.ts index dae4ed92..160b0190 100644 --- a/studio/schemaTypes/post.ts +++ b/studio/schemaTypes/post.ts @@ -14,12 +14,6 @@ export default defineType({ defineField({name: 'pl', title: 'Polish', type: 'string'}), defineField({name: 'uk', title: 'Ukrainian', type: 'string'}), ], - type: 'object', - fields: [ - defineField({name: 'en', title: 'English', type: 'string'}), - defineField({name: 'pl', title: 'Polish', type: 'string'}), - defineField({name: 'uk', title: 'Ukrainian', type: 'string'}), - ], }), defineField({ name: 'slug', From 25fc1dafba120b1bdf06f242d67f200635cad8b5 Mon Sep 17 00:00:00 2001 From: Anna Date: Sun, 18 Jan 2026 21:01:47 +0000 Subject: [PATCH 2/2] feature(blog): adding search, fix deploy issue --- frontend/app/api/blog-search/route.ts | 20 ++ frontend/app/globals.css | 2 + frontend/components/blog/BlogFilters.tsx | 50 ++++- frontend/components/blog/BlogHeaderSearch.tsx | 210 ++++++++++++++++++ frontend/components/header/UnifiedHeader.tsx | 3 + frontend/drizzle.config.ts | 5 +- 6 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 frontend/app/api/blog-search/route.ts create mode 100644 frontend/components/blog/BlogHeaderSearch.tsx diff --git a/frontend/app/api/blog-search/route.ts b/frontend/app/api/blog-search/route.ts new file mode 100644 index 00000000..d051c369 --- /dev/null +++ b/frontend/app/api/blog-search/route.ts @@ -0,0 +1,20 @@ +import groq from 'groq'; +import { NextResponse } from 'next/server'; +import { client } from '@/client'; + +const searchQuery = groq` + *[_type == "post" && defined(slug.current)] | order(publishedAt desc) { + _id, + "title": coalesce(title.en, title.uk, title.pl, title), + "body": coalesce(body.en, body.uk, body.pl, body)[]{ + ..., + children[]{ text } + }, + slug + } +`; + +export async function GET() { + const items = await client.withConfig({ useCdn: false }).fetch(searchQuery); + return NextResponse.json(items || []); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 0fc4821d..e88c9cb0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,3 +1,5 @@ +@import url(https://fonts.googleapis.com/css?family=Lato:100,300,400,700); +@import url(https://raw.github.com/FortAwesome/Font-Awesome/master/docs/assets/css/font-awesome.min.css); @import 'tailwindcss'; @custom-variant dark (&:is(.dark *)); diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx index 601615d7..09c5582e 100644 --- a/frontend/components/blog/BlogFilters.tsx +++ b/frontend/components/blog/BlogFilters.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import Image from 'next/image'; import { useLocale, useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; +import { usePathname, useRouter } from '@/i18n/routing'; import BlogGrid from '@/components/blog/BlogGrid'; import { Link } from '@/i18n/routing'; @@ -76,12 +77,16 @@ function plainTextFromPortableText(value?: PortableText): string { return value .filter(block => block?._type === 'block') .map(block => - (block.children || []).map(child => child.text || '').join('') + (block.children || []).map(child => child.text || '').join(' ') ) - .join('\n') + .join(' ') .trim(); } +function normalizeSearchText(value: string) { + return value.toLowerCase().replace(/\s+/g, ' ').trim(); +} + function plainTextExcerpt(value?: PortableText): string { return plainTextFromPortableText(value); } @@ -103,6 +108,8 @@ export default function BlogFilters({ const tNav = useTranslations('navigation'); const locale = useLocale(); const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); const [selectedAuthor, setSelectedAuthor] = useState<{ name: string; norm: string; @@ -154,6 +161,28 @@ export default function BlogFilters({ const categoryParam = useMemo(() => { return searchParams?.get('category') || ''; }, [searchParams]); + const searchQuery = useMemo(() => { + return (searchParams?.get('search') || '').trim(); + }, [searchParams]); + const searchQueryLower = searchQuery.toLowerCase(); + const didClearSearchRef = useRef(false); + + useEffect(() => { + if (didClearSearchRef.current) return; + if (!searchQuery) { + didClearSearchRef.current = true; + return; + } + if (typeof performance === 'undefined') return; + const [navEntry] = performance.getEntriesByType('navigation'); + const navType = (navEntry as PerformanceNavigationTiming | undefined)?.type; + if (navType !== 'reload') return; + const params = new URLSearchParams(searchParams?.toString() || ''); + params.delete('search'); + const nextPath = params.toString() ? `${pathname}?${params}` : pathname; + router.replace(nextPath); + didClearSearchRef.current = true; + }, [pathname, router, searchParams, searchQuery]); useEffect(() => { const normParam = normalizeTag(categoryParam); @@ -185,9 +214,22 @@ export default function BlogFilters({ if (!postCategories.includes(selectedCategory.norm)) return false; } + if (searchQueryLower) { + const titleText = normalizeSearchText(post.title); + const bodyText = normalizeSearchText( + plainTextFromPortableText(post.body) + ); + if ( + !titleText.includes(searchQueryLower) && + !bodyText.includes(searchQueryLower) + ) { + return false; + } + } + return true; }); - }, [posts, selectedAuthor, selectedCategory]); + }, [posts, selectedAuthor, selectedCategory, searchQueryLower]); const selectedAuthorData = selectedAuthor?.data || null; const authorBioText = useMemo(() => { diff --git a/frontend/components/blog/BlogHeaderSearch.tsx b/frontend/components/blog/BlogHeaderSearch.tsx new file mode 100644 index 00000000..002c3329 --- /dev/null +++ b/frontend/components/blog/BlogHeaderSearch.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Search } from 'lucide-react'; +import { useRouter } from '@/i18n/routing'; + +type PostSearchItem = { + _id: string; + title?: string; + body?: Array<{ _type: string; children?: Array<{ text?: string }> }>; + slug?: { current?: string }; +}; + +type SearchResult = PostSearchItem & { snippet?: string }; + +function extractSnippet(body: PostSearchItem['body'], query: string) { + const text = (body || []) + .filter(block => block?._type === 'block') + .map(block => + (block.children || []).map(child => child.text || '').join(' ') + ) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + if (!text) return ''; + const lower = text.toLowerCase(); + const idx = lower.indexOf(query.toLowerCase()); + if (idx === -1) return text.slice(0, 90); + const start = Math.max(0, idx - 36); + const end = Math.min(text.length, idx + 54); + const prefix = start > 0 ? '...' : ''; + const suffix = end < text.length ? '...' : ''; + return `${prefix}${text.slice(start, end)}${suffix}`; +} + +const SEARCH_ENDPOINT = '/api/blog-search'; + +export function BlogHeaderSearch() { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + const router = useRouter(); + const debounceRef = useRef(null); + + useEffect(() => { + if (!open) return; + inputRef.current?.focus(); + }, [open]); + + useEffect(() => { + if (!open || items.length || isLoading) return; + let active = true; + setIsLoading(true); + fetch(SEARCH_ENDPOINT, { cache: 'no-store' }) + .then(response => (response.ok ? response.json() : [])) + .then((result: PostSearchItem[]) => { + if (!active) return; + setItems(Array.isArray(result) ? result : []); + }) + .catch(() => { + if (!active) return; + setItems([]); + }) + .finally(() => { + if (!active) return; + setIsLoading(false); + }); + return () => { + active = false; + }; + }, [open, items.length, isLoading]); + + useEffect(() => { + if (!open) return; + if (debounceRef.current) window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => { + const query = value.trim(); + router.replace(query ? `/blog?search=${encodeURIComponent(query)}` : '/blog'); + }, 300); + return () => { + if (debounceRef.current) window.clearTimeout(debounceRef.current); + }; + }, [open, router, value]); + + const results = useMemo(() => { + const query = value.trim().toLowerCase(); + if (!query) return []; + const words = query.split(/\s+/).filter(Boolean); + return items + .filter(item => { + const title = (item.title || '').toLowerCase(); + const bodyText = (item.body || []) + .filter(block => block?._type === 'block') + .map(block => + (block.children || []).map(child => child.text || '').join(' ') + ) + .join(' ') + .toLowerCase(); + return words.every( + word => title.includes(word) || bodyText.includes(word) + ); + }) + .slice(0, 6) + .map(item => ({ + ...item, + snippet: extractSnippet(item.body, query), + })); + }, [items, value]); + + const submit = (event?: React.FormEvent) => { + if (event) event.preventDefault(); + const query = value.trim(); + router.push(query ? `/blog?search=${encodeURIComponent(query)}` : '/blog'); + setOpen(false); + }; + + const clear = () => { + setValue(''); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+
+ { + setValue(event.target.value); + if (!open) setOpen(true); + }} + onKeyDown={event => { + if (event.key === 'Escape') setOpen(false); + }} + placeholder="What're we looking for ?" + className="w-full bg-transparent text-sm text-foreground outline-none" + style={{ fontFamily: 'Lato, system-ui, -apple-system, sans-serif' }} + /> + +
+ {value && results.length > 0 && ( +
+ {results.map(result => ( + + ))} +
+ )} + {value && !results.length && !isLoading && ( +
+ No matches +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/components/header/UnifiedHeader.tsx b/frontend/components/header/UnifiedHeader.tsx index 19698d8e..d69970d8 100644 --- a/frontend/components/header/UnifiedHeader.tsx +++ b/frontend/components/header/UnifiedHeader.tsx @@ -9,6 +9,7 @@ import { LogoutButton } from '@/components/auth/logoutButton'; import { CartButton } from '@/components/shop/header/cart-button'; import { NavLinks } from '@/components/shop/header/nav-links'; import { BlogCategoryLinks } from '@/components/blog/BlogCategoryLinks'; +import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch'; import { AppMobileMenu } from '@/components/header/AppMobileMenu'; export type UnifiedHeaderVariant = 'platform' | 'shop' | 'blog'; @@ -98,6 +99,7 @@ export function UnifiedHeader({ ) : null} + {isBlog && } {isShop && } @@ -114,6 +116,7 @@ export function UnifiedHeader({ )}
+ {isBlog && } {isShop && }