mirror of
https://github.com/vercel/commerce.git
synced 2025-05-15 05:56:59 +00:00
Iterated search experience
This commit is contained in:
parent
86f2475aad
commit
88f3bd6531
@ -1,6 +1,7 @@
|
||||
import Text from 'components/ui/text/text';
|
||||
import { categoryQuery } from 'lib/sanity/queries';
|
||||
import { clientFetch } from 'lib/sanity/sanity.client';
|
||||
import Search from '@/components/search/search';
|
||||
import SearchResult from '@/components/search/search-result';
|
||||
import { categoryQuery } from '@/lib/sanity/queries';
|
||||
import { clientFetch } from '@/lib/sanity/sanity.client';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@ -34,8 +35,10 @@ export default async function ProductPage({ params }: CategoryPageParams) {
|
||||
const { title } = category;
|
||||
|
||||
return (
|
||||
<div className="my-8 flex w-full flex-col px-4 lg:my-16 lg:px-8 2xl:px-16">
|
||||
<Text variant={'pageHeading'}>{title}</Text>
|
||||
<div className="my-8 flex w-full flex-col px-4 lg:my-12 lg:px-8 2xl:px-16">
|
||||
<Search isCategory placeholder={title.toLowerCase()} title={title}>
|
||||
<SearchResult />
|
||||
</Search>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -77,7 +77,7 @@ body {
|
||||
|
||||
/* DYNAMIC CONTENT MANAGER */
|
||||
.dynamic-content > :not(.hero) {
|
||||
@apply my-16 lg:my-24;
|
||||
@apply my-12 md:my-16 lg:my-24;
|
||||
}
|
||||
|
||||
.dynamic-content > :first-child {
|
||||
@ -85,7 +85,7 @@ body {
|
||||
}
|
||||
|
||||
.dynamic-content > :last-child {
|
||||
@apply mb-16 lg:mb-24;
|
||||
@apply mb-12 md:mb-16 lg:mb-24;
|
||||
}
|
||||
|
||||
.dynamic-content .dynamic-content {
|
||||
|
17
app/[locale]/search/page.tsx
Normal file
17
app/[locale]/search/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import Search from '@/components/search/search';
|
||||
import SearchResult from '@/components/search/search-result';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function SearchPage() {
|
||||
const t = useTranslations('search');
|
||||
|
||||
return (
|
||||
<div className="my-8 flex w-full flex-col px-4 lg:my-12 lg:px-8 2xl:px-16">
|
||||
<Search title={t('search')}>
|
||||
<SearchResult />
|
||||
</Search>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -47,7 +47,7 @@ export default async function Header({ locale }: HeaderProps) {
|
||||
{mainMenu.map((item: { title: string; slug: string }, i: number) => {
|
||||
return (
|
||||
<li key={i}>
|
||||
<Link className="font-medium" href={`${locale}/category/${item.slug}`}>
|
||||
<Link className="font-medium" href={`/${locale}/category/${item.slug}`}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
|
@ -1,26 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import Search from '@/components/search/search';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import Text from '@/components/ui/text/text';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import OpenSearch from './open-search';
|
||||
|
||||
import { Highlight, Hits } from 'react-instantsearch';
|
||||
|
||||
import Search from '@/components/search/search';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function SearchModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations('search');
|
||||
|
||||
const Hit = (props: any) => {
|
||||
const { hit } = props;
|
||||
const { handle, price } = props.hit;
|
||||
|
||||
return (
|
||||
<Link
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
href={`/product/${handle}`}
|
||||
className="flex w-full gap-4 outline-offset-0"
|
||||
>
|
||||
<div className="relative aspect-square h-16 w-16 bg-neutral-300" />
|
||||
<div>
|
||||
<Text className="!text-sm text-low-contrast" variant="label">
|
||||
Brand
|
||||
</Text>
|
||||
<h3 className="flex text-sm font-normal text-high-contrast">
|
||||
<Highlight attribute="title" hit={hit} />
|
||||
</h3>
|
||||
<p className="text-sm font-bold ">{price} SEK</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
|
||||
<SheetTrigger aria-label="Open search">
|
||||
<SheetTrigger asChild>
|
||||
<button aria-label="Open search">
|
||||
<OpenSearch />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="bg-app">
|
||||
<SheetContent side="right" className="flex flex-col bg-app">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-lg font-semibold">{t('search')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Search />
|
||||
<Search>
|
||||
<Hits
|
||||
hitComponent={Hit}
|
||||
classNames={{
|
||||
list: 'mt-4 grid w-full grid-cols-1 overflow-auto gap-6'
|
||||
}}
|
||||
/>
|
||||
</Search>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
|
@ -29,8 +29,6 @@ const heroSize = {
|
||||
const Hero = ({ variant, title, text, label, image, link }: HeroProps) => {
|
||||
const heroClass = heroSize[variant as HeroSize] || heroSize.fullScreen;
|
||||
|
||||
console.log(image);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-screen ${heroClass} relative flex flex-col justify-end bg-neutral-300 text-high-contrast`}
|
||||
|
@ -26,7 +26,7 @@ export default function ProductView({ product, relatedProducts }: ProductViewPro
|
||||
const { name, description, price, images } = product;
|
||||
|
||||
return (
|
||||
<div className="mb-8 flex w-full flex-col lg:my-16">
|
||||
<div className="my-8 flex w-full flex-col lg:my-16">
|
||||
<div
|
||||
className={cn('relative grid grid-cols-1 items-start lg:grid-cols-12 lg:px-8 2xl:px-16')}
|
||||
>
|
||||
|
50
components/search/no-result.tsx
Normal file
50
components/search/no-result.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { ClearRefinements, useInstantSearch } from 'react-instantsearch';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface NoResultsProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
}
|
||||
|
||||
export function NoResultsBoundary({ children, fallback }: NoResultsProps) {
|
||||
const { results } = useInstantSearch();
|
||||
|
||||
// The `__isArtificial` flag makes sure not to display the No Results message
|
||||
// when no hits have been returned.
|
||||
if (!results.__isArtificial && results.nbHits === 0) {
|
||||
return (
|
||||
<>
|
||||
{fallback}
|
||||
<div hidden>{children}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function NoResults() {
|
||||
const t = useTranslations('search');
|
||||
const { indexUiState } = useInstantSearch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mt-4">
|
||||
{t('noResults')} <q>{indexUiState.query}</q>.
|
||||
<ClearRefinements
|
||||
translations={{
|
||||
resetButtonText: t('resetTitle')
|
||||
}}
|
||||
classNames={{
|
||||
button: 'border border-ui-border px-6 py-3 mt-4 inline-flex mx-auto w-auto'
|
||||
}}
|
||||
excludedAttributes={[]}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
52
components/search/search-result.tsx
Normal file
52
components/search/search-result.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import Text from '@/components/ui/text/text';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { Configure, Highlight, InfiniteHits } from 'react-instantsearch';
|
||||
|
||||
export default function SearchResult() {
|
||||
const t = useTranslations('search');
|
||||
|
||||
const Hit = (props: any) => {
|
||||
const { hit } = props;
|
||||
const { handle, price } = props.hit;
|
||||
|
||||
return (
|
||||
<Link href={`/product/${handle}`} className="flex w-full flex-col gap-4 outline-offset-2">
|
||||
<div className="relative aspect-square h-full w-full bg-neutral-300" />
|
||||
<div>
|
||||
<Text className="!text-sm text-low-contrast" variant="label">
|
||||
Brand
|
||||
</Text>
|
||||
<h3 className="flex text-sm font-normal text-high-contrast">
|
||||
<Highlight attribute="title" hit={hit} />
|
||||
</h3>
|
||||
<p className="text-sm font-bold ">{price} SEK</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Configure hitsPerPage={4} />
|
||||
<InfiniteHits
|
||||
translations={{
|
||||
showMoreButtonText: t('showMore')
|
||||
}}
|
||||
showPrevious={false}
|
||||
classNames={{
|
||||
root: cn('flex flex-col flex-1'),
|
||||
list: cn(
|
||||
'grid grid-cols-2 mt-4 gap-4 md:grid-cols-3 md:gap-8 lg:grid-cols-4 lg:gap-12 lg:mt-12'
|
||||
),
|
||||
loadMore:
|
||||
'border border-ui-border mt-4 px-6 py-3 inline-flex mx-auto w-auto disabled:opacity-50 disabled:cursor-not-allowed md:mt-8 lg:mt-12'
|
||||
}}
|
||||
hitComponent={Hit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
21
components/search/search-root.tsx
Normal file
21
components/search/search-root.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import algoliasearch from 'algoliasearch/lite';
|
||||
import { InstantSearch } from 'react-instantsearch';
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
`${process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID}`,
|
||||
`${process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_ONLY_API_KEY}`
|
||||
);
|
||||
|
||||
interface SearchRootProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
export default function SearchRoot({ children }: SearchRootProps) {
|
||||
return (
|
||||
<>
|
||||
<InstantSearch searchClient={searchClient} indexName="shopify_products">
|
||||
{children}
|
||||
</InstantSearch>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,43 +1,48 @@
|
||||
import algoliasearch from 'algoliasearch/lite';
|
||||
// import { useLocale } from 'next-intl';
|
||||
'use client';
|
||||
|
||||
import Text from '@/components/ui/text';
|
||||
import { Highlight, Hits, InstantSearch, SearchBox } from 'react-instantsearch';
|
||||
|
||||
const searchClient = algoliasearch(
|
||||
`${process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID}`,
|
||||
`${process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_ONLY_API_KEY}`
|
||||
);
|
||||
import { cn } from 'lib/utils';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import SearchRoot from './search-root';
|
||||
|
||||
export default function Search() {
|
||||
// const locale = useLocale();
|
||||
// Hit.
|
||||
function Hit(props: any) {
|
||||
return (
|
||||
<li>
|
||||
<a href={`/product/${props.hit.handle}`} className="flex gap-4">
|
||||
<div className="relative aspect-square h-16 w-16 bg-neutral-300" />
|
||||
<div>
|
||||
<Text className="!text-sm text-low-contrast" variant="label">
|
||||
Brand
|
||||
</Text>
|
||||
<h3 className="flex text-sm font-bold text-high-contrast">
|
||||
<Highlight attribute="title" hit={props.hit} />
|
||||
</h3>
|
||||
<p className="text-sm font-bold ">{props.hit.price} SEK</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
import { SearchBox } from 'react-instantsearch';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { NoResults, NoResultsBoundary } from './no-result';
|
||||
|
||||
interface SearchProps {
|
||||
title?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
isCategory?: boolean;
|
||||
}
|
||||
|
||||
export default function Search({ title, placeholder, children, isCategory = false }: SearchProps) {
|
||||
const t = useTranslations('search');
|
||||
|
||||
console.log(isCategory);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<InstantSearch searchClient={searchClient} indexName="shopify_products">
|
||||
{/* Widgets */}
|
||||
<SearchRoot>
|
||||
{/* Search top */}
|
||||
<div className="">
|
||||
{title && (
|
||||
<Text className="mb-8 lg:mb-12" variant="pageHeading">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<SearchBox
|
||||
placeholder="Vad letar du efter?"
|
||||
placeholder={
|
||||
placeholder
|
||||
? `${isCategory ? `${t('searchCategory')} ${placeholder}` : placeholder}`
|
||||
: `${t('globalPlaceholder')}`
|
||||
}
|
||||
classNames={{
|
||||
root: 'mt-4',
|
||||
form: 'relative',
|
||||
root: cn('flex max-w-lg'),
|
||||
form: 'relative w-full',
|
||||
input:
|
||||
'block w-full outline-offset-0 appearance-none rounded-none h-11 px-11 pr-3 py-2 bg-white border border-ui-border',
|
||||
submit: 'absolute flex items-center justify-center top-0 left-0 bottom-0 w-11 h-11',
|
||||
@ -46,17 +51,9 @@ export default function Search() {
|
||||
resetIcon: 'w-3 h-3 mx-auto bg-app'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <Configure filters={`locale:${locale}`} /> */}
|
||||
|
||||
<Hits
|
||||
classNames={{
|
||||
root: 'flex flex-col mt-4 overflow-auto max-h-full',
|
||||
list: 'grid grid-cols-1 gap-12 overflow-auto'
|
||||
}}
|
||||
hitComponent={Hit}
|
||||
/>
|
||||
</InstantSearch>
|
||||
</div>
|
||||
|
||||
<NoResultsBoundary fallback={<NoResults />}>{children}</NoResultsBoundary>
|
||||
</SearchRoot>
|
||||
);
|
||||
}
|
||||
|
@ -22,6 +22,9 @@
|
||||
"submitTitle": "Submit your search query",
|
||||
"clearTitle": "Clear your search query",
|
||||
"resetTitle": "Reset your search query",
|
||||
"noResults": "No results for",
|
||||
"showMore": "Show more results",
|
||||
"searchCategory": "Search in category",
|
||||
"seo": {
|
||||
"title": "Search",
|
||||
"description": "Search for product or category"
|
||||
|
@ -22,6 +22,9 @@
|
||||
"submitTitle": "Skicka din sökfråga",
|
||||
"clearTitle": "Rensa din sökfråga",
|
||||
"resetTitle": "Återställ din sökfråga",
|
||||
"noResults": "Inga resultat för",
|
||||
"showMore": "Visa fler resultat",
|
||||
"searchCategory": "Sök i kategori",
|
||||
"seo": {
|
||||
"title": "Sök",
|
||||
"description": "Sök efter produkt eller kategori"
|
||||
|
Loading…
x
Reference in New Issue
Block a user