├── .env.sample ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .stylelintrc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cli ├── actions │ ├── delete.js │ └── import.js ├── data │ ├── products_configuration.json │ ├── products_dataset.json │ ├── products_query_suggestions_configuration.json │ └── products_query_suggestions_dataset.json ├── index.js ├── package-lock.json ├── package.json └── utils.js ├── components ├── @autocomplete │ ├── _default │ │ ├── autocomplete.css │ │ └── autocomplete.tsx │ ├── basic │ │ └── autocomplete-basic.tsx │ └── plugins │ │ ├── popular-searches │ │ ├── popular-searches.css │ │ └── popular-searches.tsx │ │ ├── recent-searches.tsx │ │ ├── search-button.tsx │ │ └── voice-camera-icons.tsx ├── @dev │ ├── debug-layer │ │ └── debug-layer.tsx │ ├── dev.tsx │ ├── grids │ │ ├── grid.tsx │ │ └── grids.tsx │ └── pane │ │ └── pane.tsx ├── @instantsearch │ ├── hooks │ │ ├── useCurrentRefinementCount.ts │ │ ├── useGetRefinementWidgets.tsx │ │ ├── useHasRefinements.ts │ │ └── useUrlSync.ts │ ├── search.tsx │ ├── utils │ │ ├── refinements.ts │ │ └── url.ts │ └── widgets │ │ ├── breadcrumb │ │ └── breadcrumb.tsx │ │ ├── clear-refinements │ │ └── clear-refinements.tsx │ │ ├── current-refinements │ │ ├── current-refinements.tsx │ │ └── getCurrentRefinement.ts │ │ ├── dynamic-widgets │ │ └── dynamic-widgets.tsx │ │ ├── expandable-panel │ │ └── expandable-panel.tsx │ │ ├── infinite-hits │ │ └── infinite-hits.tsx │ │ ├── load-less │ │ └── load-less.tsx │ │ ├── load-more │ │ ├── load-more.css │ │ └── load-more.tsx │ │ ├── no-results-handler │ │ ├── no-results-current-refinements.tsx │ │ ├── no-results-handler.tsx │ │ └── no-results-query-suggestions.tsx │ │ ├── query-rule-banners │ │ └── query-rule-banners.tsx │ │ ├── range-input │ │ ├── range-input-currency.tsx │ │ └── range-input.tsx │ │ ├── rating-selector │ │ └── rating-selector.tsx │ │ ├── refinements-dropdown │ │ └── dropdown-refinements.tsx │ │ ├── relevant-sort │ │ └── relevant-sort.tsx │ │ ├── see-results-button │ │ └── see-results-button.tsx │ │ ├── sort-by │ │ └── sort-by.tsx │ │ ├── virtual-search-box │ │ └── virtual-search-box.tsx │ │ ├── virtual-state-results │ │ └── virtual-state-results.tsx │ │ └── virtual-stats │ │ └── virtual-stats.tsx ├── @ui │ ├── button │ │ ├── button.css │ │ └── button.tsx │ ├── chip │ │ ├── chip.css │ │ └── chip.tsx │ ├── collapse │ │ └── collapse.tsx │ ├── count │ │ └── count.tsx │ ├── dropdown │ │ └── dropdown.tsx │ ├── icon-label │ │ └── icon-label.tsx │ ├── icon │ │ └── icon.tsx │ ├── input │ │ ├── input.css │ │ └── input.tsx │ ├── label │ │ └── label.tsx │ ├── link │ │ └── link.tsx │ ├── pill │ │ └── pill.tsx │ └── select │ │ └── select.tsx ├── banner │ └── banner.tsx ├── client-only │ └── client-only.tsx ├── container │ └── container.tsx ├── dummy-wrapper │ └── dummy-wrapper.tsx ├── footer │ └── footer.tsx ├── header │ └── header.tsx ├── loader │ └── loader.tsx ├── logo │ └── logo.tsx ├── nav │ ├── nav-autocomplete.tsx │ ├── nav-bottom.tsx │ ├── nav-item.tsx │ ├── nav-top.tsx │ └── nav.tsx ├── overlay │ └── overlay.tsx ├── product-card │ ├── product-card-hit.tsx │ └── product-card.tsx ├── product-detail │ ├── product-detail-hit.tsx │ └── product-detail.tsx ├── product │ ├── product-color-variation-item.tsx │ ├── product-color-variation-list.tsx │ ├── product-description.tsx │ ├── product-favorite.tsx │ ├── product-image.tsx │ ├── product-label.tsx │ ├── product-price.tsx │ ├── product-rating.tsx │ ├── product-sizes.tsx │ ├── product-tag.css │ ├── product-tag.tsx │ └── product-title.tsx ├── products-showcase │ └── products-showcase.tsx ├── refinements-bar │ ├── refinements-bar-dropdowns.tsx │ └── refinements-bar.tsx ├── refinements-panel │ ├── refinements-panel-body.tsx │ ├── refinements-panel-footer.tsx │ ├── refinements-panel-header.tsx │ ├── refinements-panel-widget.tsx │ ├── refinements-panel.css │ └── refinements-panel.tsx ├── toggle-filters │ └── toggle-filters.tsx └── view-modes │ └── view-modes.tsx ├── config ├── config.ts └── screens.js ├── hooks ├── useDebouncedCallback.ts ├── useDeepCompareCallback.ts ├── useDeepCompareEffect.ts ├── useDeepCompareMemo.ts ├── useDeepCompareMemoize.ts ├── useDeepCompareSetState.ts ├── useDeepUpdateAtom.ts ├── useEventListener.ts ├── useIntersectionObserver.ts ├── useInterval.ts ├── useIsMounted.ts ├── useIsVisible.ts ├── useIsomorphicLayoutEffect.ts ├── useKeyPress.ts ├── useLockedBody.ts ├── useMatchMedia.ts ├── useSearchClient.ts ├── useSearchInsights.ts ├── useTailwindScreens.ts └── useUserToken.ts ├── layouts ├── app-layout.tsx ├── basic-page-layout.tsx └── search-page-layout.tsx ├── lib ├── autocomplete │ └── plugins │ │ ├── createAnimatedPlaceholderPlugin.tsx │ │ ├── createClearLeftPlugin.ts │ │ ├── createFocusBlurPlugin.ts │ │ └── createTemplatePlugin.tsx ├── framer-motion-features.ts └── media.tsx ├── netlify.toml ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── _offline.tsx ├── catalog │ └── [[...slugs]].tsx ├── index.tsx ├── kit │ ├── banners.tsx │ ├── buttons.tsx │ └── chips.tsx └── product │ └── [objectID].tsx ├── postcss.config.js ├── public ├── favicon.ico ├── robots.txt └── static │ ├── icons │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-16x16.png │ ├── icon-192x192.png │ ├── icon-32x32.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ ├── icon-maskable-512x512.png │ ├── manifest.json │ ├── mstile-150x150.png │ └── safari-pinned-tab.svg │ └── images │ ├── banners │ ├── accessories-desktop.jpg │ ├── accessories-men-desktop.jpg │ ├── accessories-men-mobile.jpg │ ├── accessories-mobile.jpg │ ├── accessories-women-desktop.jpg │ ├── accessories-women-mobile.jpg │ ├── bags-desktop.jpg │ ├── bags-mobile.jpg │ ├── jeans-desktop.jpg │ ├── jeans-mobile.jpg │ ├── men-desktop.jpg │ ├── men-mobile.jpg │ ├── scarf-desktop.jpg │ ├── scarf-mobile.jpg │ ├── shoes-desktop.jpg │ ├── shoes-mobile.jpg │ ├── tops-desktop.jpg │ ├── tops-mobile.jpg │ ├── women-desktop.jpg │ └── women-mobile.jpg │ ├── home │ └── banner.jpg │ └── socials │ ├── og.png │ └── twitter.png ├── styles ├── _index.css └── themes │ └── default │ ├── _globals.css │ ├── base.css │ ├── components │ └── loader.css │ ├── utilities.css │ └── widgets │ ├── hierarchical-menu.css │ ├── refinement-list.css │ └── size-refinement-list.css ├── tailwind.config.js ├── tsconfig.json ├── typings ├── hits.d.ts ├── lib │ ├── react-instantsearch-core.d.ts │ └── react-instantsearch-dom.d.ts └── refinements.d.ts └── utils ├── browser.ts ├── capitalize.ts ├── createInitialValues.ts ├── env.ts ├── getMapping.ts ├── getRefElement.ts ├── getResultsState.ts ├── isObjectEmpty.ts ├── launchEditor.ts ├── math.ts ├── parseUrl.ts ├── scrollToTop.ts └── tailwindScreens.ts /.env.sample: -------------------------------------------------------------------------------- 1 | # Preview credentials (to be replaced with yours) 2 | NEXT_PUBLIC_INSTANTSEARCH_APP_ID=latency 3 | NEXT_PUBLIC_INSTANTSEARCH_SEARCH_API_KEY=a4a3ef0b25a75b6df040f4d963c6e655 4 | NEXT_PUBLIC_INSTANTSEARCH_INDEX_NAME=STAGING_pwa_ecom_ui_template_products 5 | NEXT_PUBLIC_INSTANTSEARCH_QUERY_SUGGESTIONS_INDEX_NAME=STAGING_pwa_ecom_ui_template_products_query_suggestions 6 | 7 | # CLI credentials (to be replaced with yours) 8 | CLI_APP_ID= 9 | CLI_ADMIN_API_KEY= 10 | 11 | # Google Analytics (to be replaced with yours) 12 | NEXT_PUBLIC_UA_ID=UA-32446386-47 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | screens.js 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "algolia", 4 | "algolia/react", 5 | "algolia/typescript", 6 | "next", 7 | "next/core-web-vitals", 8 | "prettier" 9 | ], 10 | "plugins": ["unused-imports"], 11 | "globals": { "React": true, "JSX": true }, 12 | "rules": { 13 | "no-undef": "off", 14 | "@typescript-eslint/no-unused-vars": "off", 15 | "unused-imports/no-unused-imports": "error", 16 | "unused-imports/no-unused-vars": "error", 17 | "react/function-component-definition": [ 18 | 2, 19 | { 20 | "namedComponents": "function-declaration" 21 | } 22 | ], 23 | "@typescript-eslint/explicit-function-return-type": "off", 24 | "spaced-comment": ["error", "always", { "markers": ["/"] }], 25 | "no-param-reassign": [ 26 | "error", 27 | { 28 | "props": true, 29 | "ignorePropertyModificationsFor": ["accu"] 30 | } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | **/public/workbox-*.js 15 | **/public/sw.js 16 | **/public/fallback-*.js 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env.local 32 | .env.development.local 33 | .env.test.local 34 | .env.production.local 35 | 36 | # vercel 37 | .vercel 38 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.10.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"], 3 | "rules": { 4 | "at-rule-no-unknown": [ 5 | true, 6 | { 7 | "ignoreAtRules": [ 8 | "tailwind", 9 | "apply", 10 | "variants", 11 | "responsive", 12 | "screen", 13 | "layer" 14 | ] 15 | } 16 | ], 17 | "declaration-block-trailing-semicolon": null, 18 | "no-descending-specificity": null, 19 | "selector-id-pattern": null, 20 | "selector-class-pattern": null 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | ## Dev 4 | 5 | ```bash 6 | npm run dev 7 | # or 8 | yarn dev 9 | ``` 10 | 11 | ## Lint 12 | 13 | ```bash 14 | npm run lint 15 | # or 16 | yarn lint 17 | ``` 18 | 19 | ## Build 20 | 21 | ```bash 22 | npm run build 23 | # or 24 | yarn build 25 | ``` 26 | 27 | ## Serve 28 | 29 | ```bash 30 | npm start 31 | # or 32 | yarn start 33 | ``` 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present Algolia, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cli/actions/delete.js: -------------------------------------------------------------------------------- 1 | const { bold, dim } = require('kleur') 2 | 3 | async function deleteAction(productsIndices) { 4 | const [productsIndex] = productsIndices 5 | 6 | console.log() 7 | 8 | // Products index - Unlink replicas 9 | console.info(dim('Unlink replicas...')) 10 | await productsIndex.setSettings({ replicas: [] }).wait() 11 | 12 | await Promise.all( 13 | productsIndices.map((productIndex) => productIndex.delete()) 14 | ) 15 | 16 | console.log() 17 | console.info(bold().green('✔ Datasets deleted')) 18 | } 19 | 20 | module.exports = deleteAction 21 | -------------------------------------------------------------------------------- /cli/data/products_query_suggestions_configuration.json: -------------------------------------------------------------------------------- 1 | {"settings":{"minWordSizefor1Typo":4,"minWordSizefor2Typos":8,"hitsPerPage":20,"maxValuesPerFacet":100,"searchableAttributes":["query"],"numericAttributesForFiltering":["nb_words"],"attributesToRetrieve":null,"unretrievableAttributes":null,"optionalWords":null,"attributesForFaceting":null,"attributesToSnippet":null,"attributesToHighlight":["query"],"paginationLimitedTo":1000,"attributeForDistinct":null,"exactOnSingleWordQuery":"attribute","ranking":["typo","geo","words","filters","proximity","attribute","exact","custom"],"customRanking":["desc(popularity)"],"separatorsToIndex":"","removeWordsIfNoResults":"lastWords","queryType":"prefixLast","highlightPreTag":"","highlightPostTag":"","snippetEllipsisText":"","alternativesAsExact":["ignorePlurals","singleWordSynonym"],"enablePersonalization":true},"rules":[],"synonyms":[]} 2 | -------------------------------------------------------------------------------- /cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwa-ecom-ui-template-cli", 3 | "version": "0.0.1", 4 | "description": "PWA Ecom UI Template command line interface.", 5 | "private": true, 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "node -r dotenv/config index dotenv_config_path=../.env.local", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "dotenv": "^10.0.0", 16 | "prompts": "^2.4.2", 17 | "stream-json": "^1.7.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cli/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const Readable = require('stream').Readable 3 | const StreamArray = require('stream-json/streamers/StreamArray') 4 | 5 | function readFile(fileName) { 6 | return new Promise(function (resolve, reject) { 7 | fs.readFile(fileName, 'utf8', function (err, data) { 8 | if (err) { 9 | throw { 10 | code: 'ERR_READ_ERROR', 11 | file: fileName, 12 | message: 'Read error (file "' + fileName + '"): ' + err.message, 13 | } 14 | } else { 15 | resolve({ name: fileName, content: data }) 16 | } 17 | }) 18 | }) 19 | } 20 | 21 | function readJsonFiles(fileList) { 22 | return Promise.all(fileList.map(readFile)) 23 | .then(function (fileContentList) { 24 | try { 25 | const returnArray = [] 26 | fileContentList.forEach(function (fileContent) { 27 | let parsedFileContent 28 | try { 29 | parsedFileContent = JSON.parse(fileContent.content) 30 | } catch (err) { 31 | throw { 32 | code: 'ERR_PARSE_ERROR', 33 | file: fileContent.name, 34 | message: 35 | 'Parse error (file "' + fileContent.name + '"): ' + err.message, 36 | } 37 | } 38 | returnArray.push(parsedFileContent) 39 | }) 40 | return returnArray 41 | } catch (err) { 42 | throw err 43 | } 44 | }) 45 | .catch(function (err) { 46 | throw err 47 | }) 48 | } 49 | 50 | function saveObjectsByChunks(index, records) { 51 | const stream = Readable.from(records).pipe(StreamArray.withParser()) 52 | let chunks = [] 53 | 54 | return new Promise((resolve, reject) => { 55 | stream 56 | .on('data', ({ value }) => { 57 | chunks.push(value) 58 | if (chunks.length === 10000) { 59 | stream.pause() 60 | index 61 | .saveObjects(chunks, { autoGenerateObjectIDIfNotExist: true }) 62 | .then(() => { 63 | chunks = [] 64 | stream.resume() 65 | }) 66 | .catch(reject) 67 | } 68 | }) 69 | .on('end', () => { 70 | if (chunks.length) { 71 | index 72 | .saveObjects(chunks, { 73 | autoGenerateObjectIDIfNotExist: true, 74 | }) 75 | .catch(reject) 76 | .then(resolve) 77 | } 78 | }) 79 | .on('error', reject) 80 | }) 81 | } 82 | 83 | function getIndices(client, indicesNames) { 84 | return Promise.all( 85 | indicesNames.map((indexName) => client.initIndex(indexName)) 86 | ) 87 | } 88 | 89 | module.exports = { readJsonFiles, saveObjectsByChunks, getIndices } 90 | -------------------------------------------------------------------------------- /components/@autocomplete/_default/autocomplete.css: -------------------------------------------------------------------------------- 1 | .aa-Autocomplete { 2 | @apply full-size flex items-center justify-center; 3 | } 4 | 5 | .aa-Form { 6 | /* Overrides */ 7 | @apply !shadow-none bg-neutral-lightest can-hover:hover:border-brand-nebula laptop:bg-white laptop:border laptop:border-solid laptop:border-neutral-dark laptop:rounded-sm laptop:transition; 8 | } 9 | 10 | .focused { 11 | .aa-Form { 12 | @apply border-transparent ring-2 ring-brand-nebula; 13 | } 14 | 15 | .aa-Label svg, 16 | .aa-LoadingIndicator svg { 17 | @apply text-brand-nebula; 18 | } 19 | } 20 | 21 | .aa-Input { 22 | @apply body-regular text-brand-black; 23 | } 24 | 25 | .aa-Input::placeholder { 26 | @apply text-neutral-darkest; 27 | } 28 | 29 | .aa-SubmitButton, 30 | .aa-LoadingIndicator { 31 | @apply w-auto; 32 | } 33 | 34 | .aa-Label svg, 35 | .aa-LoadingIndicator svg { 36 | @apply text-brand-black transition-colors; 37 | } 38 | 39 | .aa-Panel { 40 | @apply !top-0 !left-0 rounded-none shadow-medium mt-4; 41 | } 42 | 43 | .aa-PanelLayout { 44 | @apply px-4 py-6 flex flex-col gap-4; 45 | } 46 | 47 | .aa-DetachedSearchButton { 48 | @apply full-size flex flex-row-reverse items-center justify-between; 49 | 50 | /* Overrides */ 51 | @apply p-0 border-none; 52 | } 53 | 54 | .aa-DetachedSearchButtonIcon { 55 | @apply text-brand-black w-auto; 56 | } 57 | 58 | .aa-DetachedSearchButtonPlaceholder { 59 | @apply text-neutral-darkest; 60 | 61 | /* Overrides */ 62 | @apply body-regular; 63 | } 64 | 65 | .aa-DetachedFormContainer { 66 | @apply bg-neutral-lightest border-none; 67 | } 68 | 69 | .aa-SourceHeader { 70 | @apply mt-0; 71 | } 72 | 73 | .aa-SourceHeaderTitle { 74 | @apply body-regular text-neutral-dark font-normal; 75 | } 76 | 77 | .aa-Item[aria-selected=true] { 78 | @apply bg-neutral-lightest; 79 | } 80 | 81 | .aa-ItemContent { 82 | @apply gap-2; 83 | } 84 | 85 | .aa-ItemIcon { 86 | @apply w-auto; 87 | } 88 | 89 | .aa-ItemIcon svg { 90 | @apply w-6 h-6 text-brand-black; 91 | } 92 | 93 | .aa-ItemActionButton svg { 94 | @apply w-5 h-5; 95 | } 96 | 97 | .aa-ClearButton:hover .aa-ClearIcon { 98 | @apply text-nebula-dark transition-colors; 99 | } 100 | -------------------------------------------------------------------------------- /components/@autocomplete/_default/autocomplete.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | AutocompleteApi, 3 | AutocompleteOptions, 4 | } from '@algolia/autocomplete-js' 5 | import { autocomplete } from '@algolia/autocomplete-js' 6 | import classNames from 'classnames' 7 | import { atom } from 'jotai' 8 | import { useUpdateAtom } from 'jotai/utils' 9 | import type { ReactElement } from 'react' 10 | import { createElement, Fragment, useEffect, useRef } from 'react' 11 | import { render } from 'react-dom' 12 | 13 | export type AutocompleteProps = Partial> & { 14 | container?: HTMLElement | string 15 | panelContainer?: HTMLElement | string 16 | initialQuery?: string 17 | hidePanel?: boolean 18 | children?: React.ReactNode 19 | onFocus?: () => void 20 | onBlur?: () => void 21 | } 22 | 23 | export const autocompleteAtom = atom | null>(null) 24 | 25 | export function Autocomplete({ 26 | container: customContainer, 27 | panelContainer: customPanelContainer, 28 | plugins = [], 29 | initialQuery = '', 30 | hidePanel = false, 31 | children, 32 | ...props 33 | }: AutocompleteProps) { 34 | const containerRef = useRef(null) 35 | const panelContainerRef = useRef(null) 36 | 37 | const setAutocomplete = useUpdateAtom(autocompleteAtom) 38 | 39 | useEffect(() => { 40 | if (!containerRef.current || !panelContainerRef.current) { 41 | return undefined 42 | } 43 | 44 | const autocompleteInstance = autocomplete({ 45 | container: customContainer ? customContainer : containerRef.current, 46 | panelContainer: customPanelContainer 47 | ? customPanelContainer 48 | : panelContainerRef.current, 49 | panelPlacement: 'full-width', 50 | initialState: { 51 | query: initialQuery, 52 | }, 53 | renderer: { createElement, Fragment }, 54 | render({ children: acChildren }, root) { 55 | render(acChildren as ReactElement, root) 56 | }, 57 | plugins, 58 | ...props, 59 | }) 60 | 61 | setAutocomplete(autocompleteInstance) 62 | 63 | return () => { 64 | setAutocomplete(null) 65 | 66 | // Waiting for an 'unsubscribe' method on Autocomplete plugin API 67 | plugins.forEach((plugin: any) => { 68 | if (typeof plugin.unsubscribe === 'function') { 69 | plugin.unsubscribe() 70 | } 71 | }) 72 | 73 | autocompleteInstance?.destroy() 74 | } 75 | // eslint-disable-next-line react-hooks/exhaustive-deps 76 | }, [ 77 | customContainer, 78 | customPanelContainer, 79 | initialQuery, 80 | plugins, 81 | props.onSubmit, 82 | props.onStateChange, 83 | ]) 84 | 85 | const panelClassName = classNames('absolute w-full z-autocomplete-panel', { 86 | hidden: hidePanel, 87 | }) 88 | 89 | return ( 90 | <> 91 |
92 |
93 | 94 | {children} 95 | 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /components/@autocomplete/plugins/popular-searches/popular-searches.css: -------------------------------------------------------------------------------- 1 | .aa-ItemContentTitle { 2 | @apply overflow-visible; 3 | } 4 | -------------------------------------------------------------------------------- /components/@autocomplete/plugins/search-button.tsx: -------------------------------------------------------------------------------- 1 | import type { OnStateChangeProps } from '@algolia/autocomplete-js' 2 | import { useCallback } from 'react' 3 | import { render } from 'react-dom' 4 | 5 | import { createTemplatePlugin } from '@/lib/autocomplete/plugins/createTemplatePlugin' 6 | import { Button } from '@ui/button/button' 7 | 8 | type SearchButtonPluginCreatorParams = { 9 | initialQuery?: string 10 | onClick?: (props: OnStateChangeProps) => void 11 | } 12 | 13 | type SearchButtonProps = { 14 | props: OnStateChangeProps 15 | onClick?: (props: OnStateChangeProps) => void 16 | } 17 | 18 | function SearchButton({ 19 | props, 20 | onClick: customOnClick = () => {}, 21 | }: SearchButtonProps) { 22 | const onClick = useCallback( 23 | () => customOnClick(props), 24 | [customOnClick, props] 25 | ) 26 | 27 | return ( 28 | 36 | ) 37 | } 38 | 39 | export function searchButtonPluginCreator({ 40 | initialQuery, 41 | onClick, 42 | }: SearchButtonPluginCreatorParams) { 43 | return createTemplatePlugin({ 44 | initialQuery, 45 | container: '.aa-InputWrapperSuffix', 46 | render(root, props) { 47 | render(, root) 48 | }, 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /components/@autocomplete/plugins/voice-camera-icons.tsx: -------------------------------------------------------------------------------- 1 | import CropIcon from '@material-design-icons/svg/outlined/crop_free.svg' 2 | import VoiceIcon from '@material-design-icons/svg/outlined/keyboard_voice.svg' 3 | import CameraIcon from '@material-design-icons/svg/outlined/photo_camera.svg' 4 | import { render } from 'react-dom' 5 | 6 | import { Icon } from '@/components/@ui/icon/icon' 7 | import { createTemplatePlugin } from '@/lib/autocomplete/plugins/createTemplatePlugin' 8 | import { Button } from '@ui/button/button' 9 | 10 | type AutocompleteIconsProps = { 11 | voice?: boolean 12 | camera?: boolean 13 | } 14 | 15 | function AutocompleteIcons({ 16 | voice = true, 17 | camera = true, 18 | }: AutocompleteIconsProps) { 19 | return ( 20 |
21 | {voice && ( 22 | 25 | )} 26 | 27 | {camera && ( 28 | <> 29 | 30 | 37 | 38 | )} 39 |
40 | ) 41 | } 42 | 43 | export function voiceCameraIconsPluginCreator() { 44 | return createTemplatePlugin({ 45 | container: '.aa-InputWrapperSuffix', 46 | render(root) { 47 | render(, root) 48 | }, 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /components/@dev/debug-layer/debug-layer.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { atom } from 'jotai' 3 | import { useAtomValue } from 'jotai/utils' 4 | import React, { useState, useCallback } from 'react' 5 | 6 | export type DebugLayerProps = { 7 | children: React.ReactNode 8 | name: string 9 | } 10 | 11 | export type WithDebugLayerProps = Record 12 | 13 | export const debugLayerEnabledAtom = atom(false) 14 | 15 | function DebugLayer({ children, name }: DebugLayerProps) { 16 | const [isHovered, setIsHovered] = useState(false) 17 | 18 | const handleOver = useCallback((e) => { 19 | e.stopPropagation() 20 | setIsHovered(true) 21 | }, []) 22 | const handleOut = useCallback((e) => { 23 | e.stopPropagation() 24 | setIsHovered(false) 25 | }, []) 26 | 27 | return ( 28 |
37 | {children} 38 | 39 |
45 | 53 | {name} 54 | 55 |
56 |
57 | ) 58 | } 59 | 60 | export function withDebugLayer( 61 | wrappedComponent: React.FunctionComponent, 62 | name?: string 63 | ) { 64 | return function WithDebugLayer({ ...props }: WithDebugLayerProps) { 65 | const enabled = useAtomValue(debugLayerEnabledAtom) 66 | const c = wrappedComponent(props) 67 | 68 | if (!c) return null 69 | if (!enabled) return c 70 | 71 | return ( 72 | 75 | {c} 76 | 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /components/@dev/dev.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | 3 | import { Grids } from './grids/grids' 4 | import { Pane } from './pane/pane' 5 | 6 | export const Dev = memo(function Dev() { 7 | return ( 8 |
9 | 10 | 11 |
12 | ) 13 | }) 14 | -------------------------------------------------------------------------------- /components/@dev/grids/grid.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from 'react' 2 | import { useMemo } from 'react' 3 | 4 | export type GridProps = { 5 | columns: number 6 | margin: number 7 | gutter: number 8 | } 9 | 10 | export function Grid({ columns, margin, gutter }: GridProps) { 11 | const style = useMemo( 12 | () => ({ 13 | '--gutter': `${gutter}px`, 14 | '--margin': `${margin}px`, 15 | }), 16 | [gutter, margin] 17 | ) 18 | 19 | const columnsDivs = useMemo( 20 | () => 21 | [...Array(columns)].map((_, i) => ( 22 |
26 | )), 27 | [columns] 28 | ) 29 | 30 | return ( 31 |
35 | {columnsDivs} 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/@dev/grids/grids.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { useAtomValue } from 'jotai/utils' 3 | 4 | import { Laptop, Tablet } from '@/lib/media' 5 | 6 | import { Grid } from './grid' 7 | 8 | export const gridsHiddenAtom = atom(true) 9 | 10 | export function Grids() { 11 | const hidden = useAtomValue(gridsHiddenAtom) 12 | if (hidden) return null 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/@dev/pane/pane.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom } from 'jotai' 2 | import { useAtomValue } from 'jotai/utils' 3 | import { useRouter } from 'next/router' 4 | import { useEffect, useRef } from 'react' 5 | import { Pane as Tweakpane } from 'tweakpane' 6 | 7 | import { configAtom } from '@/config/config' 8 | import { debugLayerEnabledAtom } from '@dev/debug-layer/debug-layer' 9 | import { gridsHiddenAtom } from '@dev/grids/grids' 10 | 11 | export function Pane() { 12 | const paneContainer = useRef(null) 13 | const router = useRouter() 14 | 15 | const [gridsHidden, setGridsHidden] = useAtom(gridsHiddenAtom) 16 | const { refinementsLayoutAtom } = useAtomValue(configAtom) 17 | 18 | const [refinementsLayout, setRefinementsLayout] = useAtom( 19 | refinementsLayoutAtom 20 | ) 21 | const [debugLayerEnabled, setDebugLayerEnabled] = useAtom( 22 | debugLayerEnabledAtom 23 | ) 24 | 25 | useEffect(() => { 26 | const pane = new Tweakpane({ 27 | title: 'Dev', 28 | expanded: false, 29 | container: paneContainer.current!, 30 | }) 31 | 32 | // Routes 33 | const routesFolder = pane.addFolder({ title: 'Routes' }) 34 | routesFolder 35 | .addInput(router, 'route', { 36 | label: 'Current route', 37 | options: { 38 | index: '/', 39 | catalog: '/catalog', 40 | 'kit/buttons': '/kit/buttons', 41 | 'kit/chips': '/kit/chips', 42 | 'kit/banners': '/kit/banners', 43 | }, 44 | }) 45 | .on('change', (ev) => { 46 | router.push(ev.value) 47 | }) 48 | 49 | // Refinements 50 | const refinementsFolder = pane.addFolder({ title: 'Refinements' }) 51 | refinementsFolder 52 | .addInput({ refinementsLayout }, 'refinementsLayout', { 53 | label: 'Layout', 54 | options: { 55 | bar: 'bar', 56 | panel: 'panel', 57 | }, 58 | }) 59 | .on('change', (ev) => { 60 | setRefinementsLayout(ev.value) 61 | }) 62 | 63 | // Grids 64 | const gridFolder = pane.addFolder({ title: 'Grid' }) 65 | gridFolder 66 | .addInput({ gridsHidden }, 'gridsHidden', { label: 'Hidden' }) 67 | .on('change', (ev) => { 68 | setGridsHidden(ev.value) 69 | }) 70 | 71 | // Debug layer 72 | const debugLayerFolder = pane.addFolder({ title: 'Debug Layer' }) 73 | debugLayerFolder 74 | .addInput({ debugLayerEnabled }, 'debugLayerEnabled', { 75 | label: 'Enabled', 76 | }) 77 | .on('change', (ev) => { 78 | setDebugLayerEnabled(ev.value) 79 | }) 80 | 81 | return () => { 82 | pane.dispose() 83 | } 84 | // eslint-disable-next-line react-hooks/exhaustive-deps 85 | }, [paneContainer]) 86 | 87 | return
88 | } 89 | -------------------------------------------------------------------------------- /components/@instantsearch/hooks/useCurrentRefinementCount.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import type { Refinement } from 'react-instantsearch-core' 3 | 4 | export function useCurrentRefinementCount( 5 | items: Refinement[], 6 | attributes: string[] 7 | ) { 8 | return useMemo(() => { 9 | let count = 0 10 | 11 | attributes?.forEach((attribute) => { 12 | const tmp: string[] = [] 13 | 14 | let currentRefinement = items.find( 15 | (item) => item.attribute === attribute 16 | )?.currentRefinement 17 | currentRefinement = currentRefinement 18 | ? tmp.concat(currentRefinement) 19 | : tmp 20 | 21 | count += currentRefinement.length 22 | }) 23 | 24 | return count 25 | }, [items, attributes]) 26 | } 27 | -------------------------------------------------------------------------------- /components/@instantsearch/hooks/useGetRefinementWidgets.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | 3 | import { RefinementsPanelWidget } from '@/components/refinements-panel/refinements-panel-widget' 4 | import type { Refinement } from '@/typings/refinements' 5 | import { getPanelId } from '@instantsearch/utils/refinements' 6 | 7 | export function useGetRefinementWidgets(refinements: Refinement[]) { 8 | return useMemo( 9 | () => 10 | refinements.map((refinement) => { 11 | const panelId = getPanelId(refinement) 12 | 13 | let refinementWidgets 14 | if (refinement.widgets?.length) { 15 | refinementWidgets = ( 16 |
17 | {refinement.widgets.map((refinementWidget) => ( 18 | 23 | ))} 24 |
25 | ) 26 | } else { 27 | refinementWidgets = ( 28 | 32 | ) 33 | } 34 | 35 | return refinementWidgets 36 | }), 37 | [refinements] 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/@instantsearch/hooks/useHasRefinements.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import type { SearchResults } from 'react-instantsearch-core' 3 | 4 | export function useHasRefinements( 5 | searchResults: SearchResults, 6 | attributes: string[] = [] 7 | ) { 8 | const facets = useMemo(() => { 9 | const disjunctiveFacets = searchResults?.disjunctiveFacets || [] 10 | const hierarchicalFacets = searchResults?.hierarchicalFacets || [] 11 | return [...disjunctiveFacets, ...hierarchicalFacets] 12 | }, [searchResults]) 13 | 14 | const hasRefinements = useMemo(() => { 15 | let found = !attributes.length 16 | 17 | facets.forEach((facet) => { 18 | attributes?.forEach((attribute) => { 19 | if (facet.name === attribute && facet.data) { 20 | found = true 21 | } 22 | }) 23 | }) 24 | 25 | return found 26 | }, [facets, attributes]) 27 | 28 | return hasRefinements 29 | } 30 | -------------------------------------------------------------------------------- /components/@instantsearch/search.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import isEqual from 'react-fast-compare' 3 | import type { InstantSearchProps } from 'react-instantsearch-dom' 4 | import { Configure, InstantSearch } from 'react-instantsearch-dom' 5 | 6 | import { VirtualSearchBox } from '@instantsearch/widgets/virtual-search-box/virtual-search-box' 7 | import { VirtualStateResults } from '@instantsearch/widgets/virtual-state-results/virtual-state-results' 8 | import { VirtualStats } from '@instantsearch/widgets/virtual-stats/virtual-stats' 9 | 10 | export type SearchProps = InstantSearchProps & { 11 | children: React.ReactNode 12 | searchParameters?: Record 13 | } 14 | 15 | function SearchComponent({ 16 | children, 17 | searchParameters, 18 | ...props 19 | }: SearchProps) { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {children} 29 | 30 | ) 31 | } 32 | 33 | export const Search = memo(SearchComponent, isEqual) 34 | -------------------------------------------------------------------------------- /components/@instantsearch/utils/refinements.ts: -------------------------------------------------------------------------------- 1 | import type { Refinement, RefinementWidget } from '@/typings/refinements' 2 | 3 | export function getRefinementPanelId( 4 | refinement: Refinement | RefinementWidget 5 | ) { 6 | return refinement.options?.attributes 7 | ? refinement.options?.attributes.join(':') 8 | : refinement.options?.attribute 9 | } 10 | 11 | export function getPanelId(refinement: Refinement | RefinementWidget) { 12 | const widgets = (refinement as Refinement).widgets 13 | 14 | const panelId = [] 15 | if (widgets?.length) { 16 | widgets.forEach((refinementWidget: RefinementWidget) => 17 | panelId.push(getRefinementPanelId(refinementWidget)) 18 | ) 19 | } else { 20 | panelId.push(getRefinementPanelId(refinement)) 21 | } 22 | 23 | return panelId.join(':') 24 | } 25 | 26 | export function getRefinementPanelAttribute( 27 | refinement: Refinement | RefinementWidget 28 | ) { 29 | return refinement.options?.attributes 30 | ? refinement.options?.attributes[0] 31 | : refinement.options?.attribute 32 | } 33 | 34 | export function getPanelAttributes(refinement: Refinement | RefinementWidget) { 35 | const widgets = (refinement as Refinement)?.widgets 36 | 37 | const panelAttributes = [] 38 | if (widgets?.length) { 39 | widgets.forEach((refinementWidget: RefinementWidget) => 40 | panelAttributes.push(getRefinementPanelAttribute(refinementWidget)) 41 | ) 42 | } else { 43 | panelAttributes.push(getRefinementPanelAttribute(refinement)) 44 | } 45 | 46 | return panelAttributes 47 | } 48 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/clear-refinements/clear-refinements.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useCallback } from 'react' 2 | import isEqual from 'react-fast-compare' 3 | import type { CurrentRefinementsProvided } from 'react-instantsearch-core' 4 | import { connectCurrentRefinements } from 'react-instantsearch-dom' 5 | 6 | import type { ButtonType } from '@/components/@ui/button/button' 7 | import { Button } from '@/components/@ui/button/button' 8 | 9 | export type ClearRefinementsProps = CurrentRefinementsProvided & { 10 | children: React.ReactNode 11 | type?: ButtonType 12 | className?: string 13 | } 14 | 15 | function ClearRefinementsComponent({ 16 | children, 17 | type = 'native', 18 | className, 19 | items, 20 | refine, 21 | }: ClearRefinementsProps) { 22 | const handleButtonClick = useCallback(() => refine(items), [refine, items]) 23 | 24 | return ( 25 | 33 | ) 34 | } 35 | 36 | export const ClearRefinements = connectCurrentRefinements( 37 | memo(ClearRefinementsComponent, isEqual) 38 | ) 39 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/current-refinements/current-refinements.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { m } from 'framer-motion' 3 | import { atom } from 'jotai' 4 | import { useAtomValue, useUpdateAtom } from 'jotai/utils' 5 | import { memo, useEffect, useMemo } from 'react' 6 | import isEqual from 'react-fast-compare' 7 | import type { 8 | CurrentRefinementsProvided, 9 | RefinementValue, 10 | } from 'react-instantsearch-core' 11 | import { connectCurrentRefinements } from 'react-instantsearch-dom' 12 | 13 | import { Chip } from '@/components/@ui/chip/chip' 14 | import { configAtom } from '@/config/config' 15 | import { withDebugLayer } from '@dev/debug-layer/debug-layer' 16 | import { ClearRefinements } from '@instantsearch/widgets/clear-refinements/clear-refinements' 17 | 18 | import { getCurrentRefinement } from './getCurrentRefinement' 19 | 20 | export type CurrentRefinementsProps = CurrentRefinementsProvided & { 21 | header?: string 22 | className?: string 23 | } 24 | 25 | export type CurrentRefinement = { 26 | category?: string 27 | label: string 28 | value: RefinementValue 29 | } 30 | 31 | export const refinementCountAtom = atom(0) 32 | 33 | function CurrentRefinementsComponent({ 34 | items, 35 | refine, 36 | header, 37 | className, 38 | }: CurrentRefinementsProps) { 39 | const config = useAtomValue(configAtom) 40 | 41 | const refinements = useMemo( 42 | () => 43 | items.reduce((acc: CurrentRefinement[], current) => { 44 | return [...acc, ...getCurrentRefinement(current, config)] 45 | }, []), 46 | [config, items] 47 | ) 48 | 49 | const setRefinementCount = useUpdateAtom(refinementCountAtom) 50 | useEffect(() => { 51 | setRefinementCount(refinements.length) 52 | }, [setRefinementCount, refinements]) 53 | 54 | if (!refinements.length) return null 55 | 56 | return ( 57 |
58 | {header &&
{header}
} 59 |
    60 | {refinements.map((refinement) => { 61 | return ( 62 | 63 | refine(refinement.value)}> 64 | {refinement.category && ( 65 |
    {refinement.category}:
    66 | )} 67 |
    {refinement.label}
    68 |
    69 |
    70 | ) 71 | })} 72 |
  • 78 | 79 | Clear all 80 | 81 |
  • 82 |
83 |
84 | ) 85 | } 86 | 87 | export const CurrentRefinements = connectCurrentRefinements( 88 | memo( 89 | withDebugLayer(CurrentRefinementsComponent, 'CurrentRefinementsWidget'), 90 | isEqual 91 | ) 92 | ) 93 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/dynamic-widgets/dynamic-widgets.tsx: -------------------------------------------------------------------------------- 1 | import { ExperimentalDynamicWidgets } from 'react-instantsearch-dom' 2 | 3 | export type DynamicWidgetsProps = { 4 | children: React.ReactNode 5 | enabled?: boolean 6 | [index: string]: any 7 | } 8 | 9 | export function DynamicWidgets({ 10 | children, 11 | enabled = true, 12 | ...props 13 | }: DynamicWidgetsProps) { 14 | return enabled ? ( 15 | 16 | {children} 17 | 18 | ) : ( 19 |
{children}
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/expandable-panel/expandable-panel.tsx: -------------------------------------------------------------------------------- 1 | import AddIcon from '@material-design-icons/svg/outlined/add.svg' 2 | import RemoveIcon from '@material-design-icons/svg/outlined/remove.svg' 3 | import classNames from 'classnames' 4 | import { useAtomValue } from 'jotai/utils' 5 | import type { MouseEventHandler } from 'react' 6 | import { memo } from 'react' 7 | import isEqual from 'react-fast-compare' 8 | import type { 9 | CurrentRefinementsProvided, 10 | SearchResults, 11 | } from 'react-instantsearch-core' 12 | import { connectCurrentRefinements } from 'react-instantsearch-dom' 13 | 14 | import { withDebugLayer } from '@/components/@dev/debug-layer/debug-layer' 15 | import { Collapse } from '@/components/@ui/collapse/collapse' 16 | import { Count } from '@/components/@ui/count/count' 17 | import { useCurrentRefinementCount } from '@instantsearch/hooks/useCurrentRefinementCount' 18 | import { useHasRefinements } from '@instantsearch/hooks/useHasRefinements' 19 | import { searchResultsAtom } from '@instantsearch/widgets/virtual-state-results/virtual-state-results' 20 | import { Button } from '@ui/button/button' 21 | import { Icon } from '@ui/icon/icon' 22 | 23 | export type ExpandablePanelProps = CurrentRefinementsProvided & { 24 | children: React.ReactNode 25 | className?: string 26 | header?: React.ReactNode | string 27 | footer?: string 28 | attributes?: string[] 29 | isOpened?: boolean 30 | onToggle?: MouseEventHandler 31 | } 32 | 33 | function ExpandablePanelComponent({ 34 | children, 35 | className, 36 | items, 37 | header, 38 | footer, 39 | attributes = [], 40 | isOpened = false, 41 | onToggle, 42 | }: ExpandablePanelProps) { 43 | const searchResults = useAtomValue(searchResultsAtom) as SearchResults 44 | const hasRefinements = useHasRefinements(searchResults, attributes) 45 | const currentRefinementCount = useCurrentRefinementCount(items, attributes) 46 | 47 | return ( 48 |
57 | 77 | 78 | 79 |
{children}
80 | 81 | {footer &&
{footer}
} 82 |
83 |
84 | ) 85 | } 86 | 87 | export const ExpandablePanel = connectCurrentRefinements( 88 | memo( 89 | withDebugLayer(ExpandablePanelComponent, 'ExpandablePanelWidget'), 90 | isEqual 91 | ) 92 | ) 93 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/load-less/load-less.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue, useUpdateAtom } from 'jotai/utils' 2 | import { useCallback } from 'react' 3 | import type { InfiniteHitsProvided } from 'react-instantsearch-core' 4 | 5 | import { Button } from '@/components/@ui/button/button' 6 | import type { ProductCardHitProps } from '@/components/product-card/product-card-hit' 7 | import { searchStateAtom } from '@instantsearch/hooks/useUrlSync' 8 | import { isSearchStalledAtom } from '@instantsearch/widgets/virtual-state-results/virtual-state-results' 9 | 10 | export type LoadLessProps = Pick< 11 | InfiniteHitsProvided, 12 | 'hasPrevious' | 'refinePrevious' 13 | > 14 | 15 | export function LoadLess({ hasPrevious, refinePrevious }: LoadLessProps) { 16 | const setSearchState = useUpdateAtom(searchStateAtom) 17 | const isSearchStalled = useAtomValue(isSearchStalledAtom) 18 | 19 | const handleGoToFirstPage = useCallback( 20 | () => 21 | setSearchState((currentSearchState) => ({ 22 | ...currentSearchState, 23 | page: 1, 24 | })), 25 | [setSearchState] 26 | ) 27 | 28 | if (!hasPrevious) return null 29 | 30 | return ( 31 |
32 | 39 | 40 | 47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/load-more/load-more.css: -------------------------------------------------------------------------------- 1 | .ais-LoadMoreWithProgressBar { 2 | --bar-color: var(--color-neutral-light); 3 | --value-color: var(--color-brand-black); 4 | } 5 | 6 | .ais-LoadMoreWithProgressBar-progressBar-bar, 7 | .ais-LoadMoreWithProgressBar-progressBar-fallback { 8 | @apply w-52 rounded-2xl; 9 | } 10 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/load-more/load-more.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | ButtonComponentProps, 3 | TextTranslationArgs, 4 | } from '@algolia/react-instantsearch-widget-loadmore-with-progressbar' 5 | import { LoadMoreWithProgressBar } from '@algolia/react-instantsearch-widget-loadmore-with-progressbar' 6 | import { useAtomValue } from 'jotai/utils' 7 | import { memo, useCallback, useEffect, useRef } from 'react' 8 | 9 | import { withDebugLayer } from '@/components/@dev/debug-layer/debug-layer' 10 | import { searchQueryAtom } from '@/components/@instantsearch/hooks/useUrlSync' 11 | import { useIntersectionObserver } from '@/hooks/useIntersectionObserver' 12 | import { Button } from '@ui/button/button' 13 | 14 | function LoadMoreButton({ 15 | translations, 16 | isSearchStalled, 17 | refineNext, 18 | }: ButtonComponentProps) { 19 | const refineCounter = useRef(0) 20 | const loadMoreClicked = useRef(false) 21 | 22 | const { setObservedNode } = useIntersectionObserver({ 23 | callback: (entry) => { 24 | if ( 25 | entry.isIntersecting && 26 | !isSearchStalled && 27 | (refineCounter.current <= 2 || loadMoreClicked.current) 28 | ) { 29 | refineNext() 30 | refineCounter.current++ 31 | } 32 | }, 33 | threshold: 0, 34 | }) 35 | 36 | const handleLoadMoreClick = useCallback(() => { 37 | loadMoreClicked.current = true 38 | refineNext() 39 | }, [refineNext]) 40 | 41 | const searchQuery = useAtomValue(searchQueryAtom) 42 | useEffect(() => { 43 | refineCounter.current = 0 44 | loadMoreClicked.current = false 45 | }, [searchQuery]) 46 | 47 | return ( 48 | 56 | ) 57 | } 58 | 59 | function LoadMoreComponent() { 60 | return ( 61 | 66 | `You've seen ${nbSeenHits} product${ 67 | nbSeenHits > 1 ? 's' : '' 68 | } out of ${nbTotalHits}`, 69 | }} 70 | /> 71 | ) 72 | } 73 | 74 | export const LoadMore = memo( 75 | withDebugLayer(LoadMoreComponent, 'LoadMoreWidget') 76 | ) 77 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/no-results-handler/no-results-current-refinements.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai/utils' 2 | 3 | import { 4 | CurrentRefinements, 5 | refinementCountAtom, 6 | } from '@instantsearch/widgets/current-refinements/current-refinements' 7 | 8 | export function NoResultsCurrentRefinements() { 9 | const refinementCount = useAtomValue(refinementCountAtom) 10 | 11 | if (refinementCount === 0) return null 12 | 13 | return ( 14 |
  • 15 | 16 | You might also want to remove some filters 17 | 18 | 19 |
  • 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/no-results-handler/no-results-handler.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import isEqual from 'react-fast-compare' 3 | import type { StateResultsProvided } from 'react-instantsearch-core' 4 | import { connectStateResults } from 'react-instantsearch-dom' 5 | 6 | import { NoResultsCurrentRefinements } from './no-results-current-refinements' 7 | import { NoResultsQuerySuggestions } from './no-results-query-suggestions' 8 | 9 | export type NoResultsHandlerProps = StateResultsProvided & { 10 | children: React.ReactNode 11 | } 12 | 13 | export type NoResultsProps = { 14 | query: string 15 | isSearching: boolean 16 | } 17 | 18 | const NoResults = memo( 19 | function NoResults({ query }: NoResultsProps) { 20 | return ( 21 |
    22 |

    23 | 24 | Sorry, we found no results for{' '} 25 | 26 | 27 | “{query}” 28 | 29 |

    30 | 31 |

    Try the following:

    32 |
      33 |
    • 34 | Check your spelling 35 |
    • 36 | 37 | 38 |
    39 |
    40 | ) 41 | }, 42 | // Not re-rendering when it's searching allows to avoid inconsistent UIs 43 | // where you click on a Query Suggestion, the "no results" title updates 44 | // with the clicked query showing that there's no result whereas it's only 45 | // loading waiting for new results. 46 | (_, nextProps) => nextProps.isSearching === true 47 | ) 48 | 49 | function NoResultsHandlerComponent({ 50 | children, 51 | searchState, 52 | searchResults, 53 | searching, 54 | }: NoResultsHandlerProps) { 55 | if (searchState?.query && searchResults?.nbHits === 0) { 56 | return 57 | } 58 | 59 | return <>{children} 60 | } 61 | 62 | export const NoResultsHandler = connectStateResults( 63 | memo(NoResultsHandlerComponent, isEqual) 64 | ) 65 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/no-results-handler/no-results-query-suggestions.tsx: -------------------------------------------------------------------------------- 1 | import SearchIcon from '@material-design-icons/svg/outlined/search.svg' 2 | import { useAtomValue, useUpdateAtom } from 'jotai/utils' 3 | import { memo, useCallback } from 'react' 4 | import isEqual from 'react-fast-compare' 5 | import type { HitsProvided } from 'react-instantsearch-core' 6 | import { Configure, connectHits, Index } from 'react-instantsearch-dom' 7 | 8 | import { Button } from '@/components/@ui/button/button' 9 | import { Icon } from '@/components/@ui/icon/icon' 10 | import { configAtom } from '@/config/config' 11 | import { querySuggestionsIndexName } from '@/utils/env' 12 | import { searchStateAtom } from '@instantsearch/hooks/useUrlSync' 13 | 14 | export type NoResultsQuerySuggestionsProps = { 15 | query: string 16 | } 17 | 18 | export type NoResultsQuerySuggestionsHitsProps = HitsProvided 19 | 20 | export type NoResultsQuerySuggestionsHitButtonProps = { 21 | query: string 22 | } 23 | 24 | function NoResultsQuerySuggestionsHitButton({ 25 | query, 26 | }: NoResultsQuerySuggestionsHitButtonProps) { 27 | const setSearchState = useUpdateAtom(searchStateAtom) 28 | 29 | const handleClick = useCallback(() => { 30 | setSearchState((currentSearchState) => ({ ...currentSearchState, query })) 31 | }, [setSearchState, query]) 32 | 33 | return ( 34 | 37 | ) 38 | } 39 | 40 | function NoResultsQuerySuggestionsHitsComponent({ 41 | hits, 42 | }: NoResultsQuerySuggestionsHitsProps) { 43 | if (hits.length === 0) { 44 | return null 45 | } 46 | 47 | return ( 48 |
  • 49 | 50 | Try searching using a more general term 51 |
      52 | {hits.map((hit) => ( 53 |
    • 54 | 55 | 56 |
    • 57 | ))} 58 |
    59 |
    60 |
  • 61 | ) 62 | } 63 | 64 | const NoResultsQuerySuggestionsHits = connectHits( 65 | memo(NoResultsQuerySuggestionsHitsComponent, isEqual) 66 | ) 67 | 68 | export function NoResultsQuerySuggestions({ 69 | query, 70 | }: NoResultsQuerySuggestionsProps) { 71 | const { searchParameters } = useAtomValue(configAtom) 72 | 73 | if (!querySuggestionsIndexName) { 74 | return null 75 | } 76 | 77 | return ( 78 | 79 | 85 | 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/query-rule-banners/query-rule-banners.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue } from 'jotai/utils' 2 | import { memo } from 'react' 3 | import isEqual from 'react-fast-compare' 4 | import type { QueryRuleCustomDataProvided } from 'react-instantsearch-core' 5 | import { connectQueryRules } from 'react-instantsearch-dom' 6 | 7 | import { withDebugLayer } from '@/components/@dev/debug-layer/debug-layer' 8 | import { Banner } from '@/components/banner/banner' 9 | import { useIsMounted } from '@/hooks/useIsMounted' 10 | import { useTailwindScreens } from '@/hooks/useTailwindScreens' 11 | import { searchResultsAtom } from '@instantsearch/widgets/virtual-state-results/virtual-state-results' 12 | 13 | export type QueryRuleBannersProps = QueryRuleCustomDataProvided & { 14 | limit?: number 15 | } 16 | 17 | function QueryRuleBannersComponent({ 18 | items, 19 | limit = Infinity, 20 | }: QueryRuleBannersProps) { 21 | const searchResults = useAtomValue(searchResultsAtom) 22 | const { laptop } = useTailwindScreens() 23 | const isMounted = useIsMounted() 24 | 25 | if (!items.length || searchResults?.nbHits === 0) return null 26 | 27 | const slicedItems = items.slice(0, limit) 28 | 29 | return ( 30 |
    31 | {slicedItems.map(({ title, description, image }) => ( 32 | 43 | ))} 44 |
    45 | ) 46 | } 47 | 48 | export const QueryRuleBanners = connectQueryRules( 49 | memo( 50 | withDebugLayer(QueryRuleBannersComponent, 'QueryRuleBannersWidget'), 51 | isEqual 52 | ) 53 | ) 54 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/range-input/range-input-currency.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import type { ReactElement } from 'react' 3 | import { Children, cloneElement } from 'react' 4 | 5 | export type RangeInputCurrencyProps = { 6 | children: React.ReactNode 7 | currency?: string 8 | } 9 | 10 | export function RangeInputCurrency({ 11 | children, 12 | currency = '€', 13 | }: RangeInputCurrencyProps) { 14 | const child = Children.only(children) as ReactElement 15 | const props = { 16 | className: classNames(child.props.className, '!pl-5'), 17 | } 18 | 19 | return ( 20 | 24 | {cloneElement(child, props)} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/range-input/range-input.tsx: -------------------------------------------------------------------------------- 1 | import type { ChangeEvent } from 'react' 2 | import { memo, useCallback, useEffect, useMemo } from 'react' 3 | import isEqual from 'react-fast-compare' 4 | import type { RangeInputProvided } from 'react-instantsearch-core' 5 | import { connectRange } from 'react-instantsearch-dom' 6 | 7 | import { Input } from '@/components/@ui/input/input' 8 | import { useDebouncedCallback } from '@/hooks/useDebouncedCallback' 9 | import { useDeepCompareState } from '@/hooks/useDeepCompareSetState' 10 | import { clamp } from '@/utils/math' 11 | 12 | import { RangeInputCurrency } from './range-input-currency' 13 | 14 | export type RangeInputValueType = 'max' | 'min' 15 | 16 | export type RangeInputProps = RangeInputProvided 17 | 18 | function RangeInputComponent({ 19 | currentRefinement, 20 | min, 21 | max, 22 | precision, 23 | refine, 24 | }: RangeInputProps) { 25 | const [values, setValues] = useDeepCompareState({ min: '', max: '' }) 26 | const step = useMemo(() => 1 / Math.pow(10, precision), [precision]) 27 | 28 | const debouncedHandleInputChange = useDebouncedCallback( 29 | (v: string, valType: RangeInputValueType) => { 30 | let val = parseInt(v, 10) 31 | val = clamp(val, min, max) 32 | 33 | refine({ 34 | ...currentRefinement, 35 | [valType]: val || '', 36 | }) 37 | }, 38 | 300 39 | ) 40 | 41 | const handleInputChange = useCallback( 42 | (event: ChangeEvent, valType: RangeInputValueType) => { 43 | const val = event.target.value 44 | setValues((v) => ({ 45 | ...v, 46 | [valType]: val, 47 | })) 48 | 49 | debouncedHandleInputChange(val, valType) 50 | }, 51 | [setValues, debouncedHandleInputChange] 52 | ) 53 | 54 | useEffect(() => { 55 | if (currentRefinement.min === min) { 56 | setValues((v) => ({ 57 | ...v, 58 | min: '', 59 | })) 60 | } 61 | if (currentRefinement.max === max) { 62 | setValues((v) => ({ 63 | ...v, 64 | max: '', 65 | })) 66 | } 67 | }, [setValues, currentRefinement, min, max]) 68 | 69 | return ( 70 |
    71 | 72 | handleInputChange(event, 'min')} 80 | /> 81 | 82 | 83 | {' to '} 84 | 85 | 86 | handleInputChange(event, 'max')} 94 | /> 95 | 96 |
    97 | ) 98 | } 99 | 100 | export const RangeInput = connectRange(memo(RangeInputComponent, isEqual)) 101 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/rating-selector/rating-selector.tsx: -------------------------------------------------------------------------------- 1 | import StarFillIcon from '@material-design-icons/svg/outlined/star.svg' 2 | import StarOulineIcon from '@material-design-icons/svg/outlined/star_outline.svg' 3 | import classNames from 'classnames' 4 | import { memo, useMemo, useState } from 'react' 5 | import isEqual from 'react-fast-compare' 6 | import type { RatingMenuProvided } from 'react-instantsearch-core' 7 | import { connectRange } from 'react-instantsearch-dom' 8 | 9 | import { Button } from '@/components/@ui/button/button' 10 | import { Icon } from '@/components/@ui/icon/icon' 11 | 12 | export type RatingSelectorProps = RatingMenuProvided 13 | 14 | function RatingSelectorComponent({ 15 | currentRefinement, 16 | min, 17 | max, 18 | count, 19 | refine, 20 | }: RatingSelectorProps) { 21 | const [currentHoverIdx, setCurrentHoverIdx] = useState(-1) 22 | const currRefinement = currentRefinement.min 23 | const currCount = useMemo( 24 | () => count.find((v) => v.value === currRefinement?.toString())?.count || 0, 25 | [count, currRefinement] 26 | ) 27 | 28 | if (typeof min === 'undefined' || typeof max === 'undefined') return null 29 | 30 | return ( 31 |
    32 | 33 | {[...Array(max)].map((_, i) => ( 34 | 56 | ))} 57 |
    58 | {currCount} 59 |
    60 |
    61 | ) 62 | } 63 | 64 | export const RatingSelector = connectRange( 65 | memo(RatingSelectorComponent, isEqual) 66 | ) 67 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/refinements-dropdown/dropdown-refinements.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | import isEqual from 'react-fast-compare' 3 | import type { CurrentRefinementsProvided } from 'react-instantsearch-core' 4 | import { connectCurrentRefinements } from 'react-instantsearch-dom' 5 | 6 | import { Dropdown } from '@/components/@ui/dropdown/dropdown' 7 | import type { DropdownProps } from '@/components/@ui/dropdown/dropdown' 8 | import { useCurrentRefinementCount } from '@instantsearch/hooks/useCurrentRefinementCount' 9 | 10 | export type RefinementsDropdownProps = CurrentRefinementsProvided & 11 | DropdownProps & { 12 | children: React.ReactNode 13 | attributes?: string[] 14 | } 15 | 16 | export function RefinementsDropdownComponent({ 17 | children, 18 | attributes = [], 19 | items, 20 | header, 21 | className, 22 | classNameContainer, 23 | }: RefinementsDropdownProps) { 24 | const currentRefinementCount = useCurrentRefinementCount(items, attributes) 25 | 26 | return ( 27 | 33 |
    {children}
    34 |
    35 | ) 36 | } 37 | 38 | export const RefinementsDropdown = connectCurrentRefinements( 39 | memo(RefinementsDropdownComponent, isEqual) 40 | ) 41 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/relevant-sort/relevant-sort.tsx: -------------------------------------------------------------------------------- 1 | import InfoIcon from '@material-design-icons/svg/outlined/info.svg' 2 | import { memo } from 'react' 3 | import type { RelevantSortProvided } from 'react-instantsearch-core' 4 | import { connectRelevantSort } from 'react-instantsearch-dom' 5 | 6 | import { Button } from '@ui/button/button' 7 | import { Icon } from '@ui/icon/icon' 8 | import { Pill } from '@ui/pill/pill' 9 | 10 | export type RevelantSortProps = RelevantSortProvided 11 | function RelevantSortComponent({ 12 | isVirtualReplica, 13 | isRelevantSorted, 14 | refine, 15 | }: RevelantSortProps) { 16 | return !isVirtualReplica ? null : ( 17 | 18 | 19 | 20 | {isRelevantSorted 21 | ? 'We removed some search results to show you the most relevant ones.' 22 | : 'Currently showing all results.'} 23 | 24 | 25 | 31 | 32 | ) 33 | } 34 | 35 | export const RelevantSort = connectRelevantSort(memo(RelevantSortComponent)) 36 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/see-results-button/see-results-button.tsx: -------------------------------------------------------------------------------- 1 | import type { MouseEventHandler } from 'react' 2 | import { memo } from 'react' 3 | import type { StatsProvided } from 'react-instantsearch-core' 4 | import { connectStats } from 'react-instantsearch-dom' 5 | 6 | import { Button } from '@/components/@ui/button/button' 7 | 8 | export type SeeResultsButtonProps = StatsProvided & { 9 | onClick: MouseEventHandler 10 | } 11 | 12 | function SeeResultsButtonComponent({ nbHits, onClick }: SeeResultsButtonProps) { 13 | return ( 14 | 22 | ) 23 | } 24 | 25 | export const SeeResultsButton = connectStats( 26 | memo(SeeResultsButtonComponent) 27 | ) 28 | -------------------------------------------------------------------------------- /components/@instantsearch/widgets/sort-by/sort-by.tsx: -------------------------------------------------------------------------------- 1 | import SortIcon from '@material-design-icons/svg/outlined/sort.svg' 2 | import classNames from 'classnames' 3 | import { memo, useCallback, useMemo } from 'react' 4 | import isEqual from 'react-fast-compare' 5 | import type { SortByProvided } from 'react-instantsearch-core' 6 | import { connectSortBy } from 'react-instantsearch-dom' 7 | 8 | import { IconLabel } from '@/components/@ui/icon-label/icon-label' 9 | import { Select } from '@/components/@ui/select/select' 10 | import { withDebugLayer } from '@dev/debug-layer/debug-layer' 11 | import { ExpandablePanel } from '@instantsearch/widgets/expandable-panel/expandable-panel' 12 | 13 | export type SortByView = 'dropdown' | 'select' 14 | 15 | export type SortByProps = SortByProvided & { 16 | className?: string 17 | isOpened?: boolean 18 | view: SortByView 19 | } 20 | 21 | function SortByComponent({ 22 | items, 23 | currentRefinement, 24 | refine, 25 | view, 26 | createURL, 27 | className, 28 | isOpened, 29 | ...props 30 | }: SortByProps) { 31 | const currentOption = useMemo( 32 | () => items.find((item) => item.value === currentRefinement), 33 | [items, currentRefinement] 34 | ) 35 | const refinedOption = items.find((item) => item.isRefined) 36 | 37 | const handleSelectChange = useCallback( 38 | (selectedOption) => { 39 | refine(selectedOption.value) 40 | }, 41 | // eslint-disable-next-line react-hooks/exhaustive-deps 42 | [] 43 | ) 44 | 45 | return view === 'dropdown' ? ( 46 | 49 |
    Sort
    50 | {!isOpened && ( 51 |
    52 | {refinedOption?.label} 53 |
    54 | )} 55 |
    56 | } 57 | isOpened={isOpened} 58 | {...props} 59 | > 60 | 76 | 77 | ) : ( 78 | 11 | } 12 | -------------------------------------------------------------------------------- /components/@ui/label/label.tsx: -------------------------------------------------------------------------------- 1 | export type LabelProps = { 2 | label: string 3 | className?: string 4 | } 5 | 6 | export function Label({ 7 | label = '', 8 | className = 'label-uppercase', 9 | }: LabelProps) { 10 | return
    {label}
    11 | } 12 | -------------------------------------------------------------------------------- /components/@ui/link/link.tsx: -------------------------------------------------------------------------------- 1 | import type { LinkProps as NextLinkProps } from 'next/link' 2 | import NextLink from 'next/link' 3 | import { useRouter } from 'next/router' 4 | import type { AnchorHTMLAttributes, PropsWithChildren } from 'react' 5 | import { useCallback } from 'react' 6 | 7 | export type LinkProps = PropsWithChildren< 8 | NextLinkProps & Omit, 'href'> 9 | > 10 | 11 | export function Link({ 12 | children, 13 | href, 14 | as, 15 | replace, 16 | scroll, 17 | shallow, 18 | prefetch, 19 | locale, 20 | ...anchorProps 21 | }: LinkProps) { 22 | const router = useRouter() 23 | 24 | const onClick = useCallback( 25 | (e) => { 26 | if (router.asPath === href) { 27 | e.preventDefault() 28 | } 29 | }, 30 | [router, href] 31 | ) 32 | 33 | return ( 34 | 38 | 39 | {children} 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/@ui/pill/pill.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | 3 | export type PillProps = { 4 | children: React.ReactNode 5 | color?: 'nebula' | 'neutral' 6 | } 7 | 8 | export function Pill({ children, color = 'neutral' }: PillProps) { 9 | return ( 10 |
    17 | {children} 18 |
    19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /components/client-only/client-only.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export type ClientOnlyProps = { 4 | children: React.ReactNode 5 | } 6 | 7 | export function ClientOnly({ children }: ClientOnlyProps) { 8 | const [hasMounted, setHasMounted] = useState(false) 9 | 10 | useEffect(() => { 11 | setHasMounted(true) 12 | }, []) 13 | 14 | if (!hasMounted) { 15 | return null 16 | } 17 | 18 | return <>{children} 19 | } 20 | -------------------------------------------------------------------------------- /components/container/container.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | 3 | export type ContainerProps = { 4 | children: React.ReactNode 5 | className?: string 6 | } 7 | 8 | export function Container({ children, className }: ContainerProps) { 9 | return ( 10 |
    11 | {children} 12 |
    13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/dummy-wrapper/dummy-wrapper.tsx: -------------------------------------------------------------------------------- 1 | export type DummyWrapperProps = { 2 | children?: React.ReactNode 3 | } 4 | 5 | export function DummyWrapper({ children, ...props }: DummyWrapperProps) { 6 | return
    {children}
    7 | } 8 | -------------------------------------------------------------------------------- /components/footer/footer.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react' 2 | 3 | import { LogoSymbol } from '@/components/logo/logo' 4 | import { Link } from '@ui/link/link' 5 | 6 | export type FooterProps = Record 7 | 8 | export const Footer = memo(function Footer() { 9 | return ( 10 |
    11 |
    12 | {Array.from(Array(6), (ei, i) => ( 13 |
    14 |
    Footer {i + 1}
    15 |
      16 | {Array.from(Array(3), (ej, j) => ( 17 |
    • 18 | e.preventDefault()} 23 | > 24 | {`Link ${j + 1}`} 25 | 26 |
    • 27 | ))} 28 |
    29 |
    30 | ))} 31 |
    32 | 33 |
    34 | 35 | 36 | 37 | 38 |
      39 |
    • 40 | 41 | Home 42 | 43 |
    • 44 |
    • 45 | 46 | About Us 47 | 48 |
    • 49 |
    • 50 | 51 | Contact 52 | 53 |
    • 54 |
    55 |
    56 |
    57 | ) 58 | }) 59 | -------------------------------------------------------------------------------- /components/header/header.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useState } from 'react' 3 | 4 | import { Nav } from '@/components/nav/nav' 5 | import { useIntersectionObserver } from '@/hooks/useIntersectionObserver' 6 | 7 | export type HeaderProps = Record 8 | 9 | export function Header() { 10 | const [isSticky, setIsSticky] = useState(false) 11 | 12 | const { setObservedNode } = useIntersectionObserver({ 13 | callback: (e) => setIsSticky(e.intersectionRatio < 1), 14 | threshold: [1], 15 | }) 16 | 17 | return ( 18 |
    27 |
    29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /components/loader/loader.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { useAtomValue } from 'jotai/utils' 3 | import { useRouter } from 'next/router' 4 | import { useEffect, useState } from 'react' 5 | 6 | import { isSearchStalledAtom } from '@instantsearch/widgets/virtual-state-results/virtual-state-results' 7 | 8 | export type LoaderProps = { 9 | layout?: 'bar' | 'overlay' 10 | } 11 | 12 | const routeLoadingThreshold = 400 // im ms 13 | 14 | export function Loader({ layout = 'overlay' }: LoaderProps) { 15 | const router = useRouter() 16 | const [isRouteLoading, setIsRouteLoading] = useState(false) 17 | 18 | useEffect(() => { 19 | let timeout: ReturnType 20 | 21 | const handleRouteChangeStart = ( 22 | url: string, 23 | { shallow }: { shallow: boolean } 24 | ) => { 25 | if (shallow) return 26 | 27 | timeout = setTimeout(() => { 28 | setIsRouteLoading(true) 29 | }, routeLoadingThreshold) 30 | } 31 | 32 | const handleRouteChangeComplete = ( 33 | url: string, 34 | { shallow }: { shallow: boolean } 35 | ) => { 36 | if (shallow) return 37 | 38 | clearTimeout(timeout) 39 | setIsRouteLoading(false) 40 | } 41 | 42 | const handleRouteChangeError = ( 43 | _: any, 44 | url: string, 45 | { shallow }: { shallow: boolean } 46 | ) => { 47 | handleRouteChangeComplete(url, { shallow }) 48 | } 49 | 50 | router.events.on('routeChangeStart', handleRouteChangeStart) 51 | router.events.on('routeChangeComplete', handleRouteChangeComplete) 52 | router.events.on('routeChangeError', handleRouteChangeError) 53 | 54 | return () => { 55 | router.events.off('routeChangeStart', handleRouteChangeStart) 56 | router.events.off('routeChangeComplete', handleRouteChangeComplete) 57 | router.events.off('routeChangeError', handleRouteChangeError) 58 | } 59 | }, [router?.events]) 60 | 61 | const isSearchStalled = useAtomValue(isSearchStalledAtom) 62 | 63 | const cn = classNames('loader', `loader--${layout}`, { 64 | 'loader--loading': isSearchStalled || isRouteLoading, 65 | }) 66 | 67 | return ( 68 |
    69 | {layout === 'overlay' && ( 70 |
    71 |
    72 |
    73 |
    74 |
    75 |
    76 |
    77 |
    78 | )} 79 |
    80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /components/nav/nav-bottom.tsx: -------------------------------------------------------------------------------- 1 | import MenuIcon from '@material-design-icons/svg/outlined/menu.svg' 2 | import { useRouter } from 'next/router' 3 | import { useMemo } from 'react' 4 | 5 | import { Laptop, Tablet } from '@/lib/media' 6 | import { parseUrl } from '@/utils/parseUrl' 7 | import { Button } from '@ui/button/button' 8 | import { IconLabel } from '@ui/icon-label/icon-label' 9 | 10 | import { NavAutocomplete } from './nav-autocomplete' 11 | import { NavItem } from './nav-item' 12 | 13 | export function NavBottom() { 14 | const router = useRouter() 15 | const currentCategory = useMemo(() => { 16 | const { pathname } = parseUrl(router?.asPath) 17 | return pathname.match(/\/catalog\/(.[^/]*)\/?/)?.[1] 18 | }, [router?.asPath]) 19 | 20 | const genderSubCategories = ( 21 | <> 22 | {currentCategory === 'Women' && ( 23 | 24 | )} 25 | 26 | 27 | 28 | ) 29 | 30 | const accessoriesSubCategories = ( 31 | <> 32 | 33 | 34 | 35 | ) 36 | 37 | return ( 38 |
    39 | 40 | 43 | 44 | 45 | 46 | {currentCategory && ( 47 | 54 | )} 55 | 56 | 57 | 58 |
    59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /components/nav/nav-item.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useMemo } from 'react' 3 | 4 | import { Link } from '@ui/link/link' 5 | 6 | export type NavItemProps = { 7 | label: string 8 | href?: string 9 | } 10 | 11 | export function NavItem({ label, href = '' }: NavItemProps) { 12 | const router = useRouter() 13 | 14 | const isSelected = useMemo( 15 | () => router?.asPath.startsWith(href), 16 | [router?.asPath, href] 17 | ) 18 | 19 | const labelLowercase = useMemo( 20 | () => encodeURIComponent(label.toLowerCase()), 21 | [label] 22 | ) 23 | 24 | return ( 25 |
  • 26 | 32 | {label} 33 | 34 |
  • 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /components/nav/nav.tsx: -------------------------------------------------------------------------------- 1 | import { NavBottom } from './nav-bottom' 2 | import { NavTop } from './nav-top' 3 | 4 | export function Nav() { 5 | return ( 6 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /components/overlay/overlay.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { atom, useAtom } from 'jotai' 3 | import { useCallback } from 'react' 4 | 5 | export type OverlayAtomValue = { 6 | visible: boolean 7 | blur?: boolean 8 | zIndex?: 'z-overlay-full' | 'z-overlay-header' 9 | } 10 | 11 | export const overlayAtom = atom({ 12 | visible: false, 13 | blur: true, 14 | zIndex: 'z-overlay-full', 15 | }) 16 | 17 | export function Overlay() { 18 | const [{ visible, blur, zIndex }, setOverlay] = useAtom(overlayAtom) 19 | 20 | const onClick = useCallback(() => { 21 | setOverlay((prev) => ({ ...prev, visible: false })) 22 | }, [setOverlay]) 23 | 24 | const cn = classNames( 25 | 'fixed w-full h-full inset-0 bg-black bg-opacity-50 opacity-0 transition-opacity pointer-events-none cursor-pointer', 26 | zIndex, 27 | { 28 | 'opacity-100 pointer-events-auto': visible, 29 | 'backdrop-blur-sm': blur, 30 | } 31 | ) 32 | 33 | return ( 34 |
    41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /components/product-detail/product-detail-hit.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useCallback } from 'react' 3 | import searchInsights from 'search-insights' 4 | 5 | import type { ProductTagType } from '@/components/product/product-tag' 6 | import type { HitComponentProps, ProductHit } from '@/typings/hits' 7 | import { indexName } from '@/utils/env' 8 | 9 | import { ProductDetail } from './product-detail' 10 | import type { ProductDetailProps } from './product-detail' 11 | 12 | export type ProductDetailHitProps = HitComponentProps 13 | 14 | export function ProductDetailHit({ hit }: ProductDetailHitProps) { 15 | const product: ProductDetailProps = { 16 | image: hit.image_urls[0], 17 | label: hit.brand, 18 | title: hit.name, 19 | description: hit.description, 20 | tags: [], 21 | sizes: [], 22 | rating: hit.reviews.rating, 23 | reviews: hit.reviews.count, 24 | price: hit.price.value, 25 | currency: { 26 | symbol: hit.price.currency === 'EUR' ? '€' : '$', 27 | position: hit.price.currency === 'EUR' ? 'suffix' : 'prefix', 28 | }, 29 | } 30 | 31 | // On sales 32 | if (hit.price.on_sales) { 33 | product.originalPrice = hit.price.value 34 | product.price = hit.price.discounted_value 35 | 36 | product.tags?.push({ 37 | label: `on sale ${hit.price.discount_level}%`, 38 | theme: 'on-sale', 39 | } as ProductTagType) 40 | } 41 | 42 | // Tags 43 | if (product.reviews && product.reviews >= 90) { 44 | product.popular = true 45 | product.tags?.push({ 46 | label: 'popular', 47 | theme: 'popular', 48 | } as ProductTagType) 49 | } 50 | 51 | // Sizes 52 | if (hit.available_sizes.length) { 53 | product.sizes?.push( 54 | ...hit.available_sizes.map((size) => ({ size, available: true })) 55 | ) 56 | } 57 | 58 | const router = useRouter() 59 | const queryID = router?.query?.queryID as string 60 | 61 | const handleCheckoutClick = useCallback(() => { 62 | searchInsights( 63 | queryID ? 'convertedObjectIDsAfterSearch' : 'convertedObjectIDs', 64 | { 65 | index: indexName, 66 | eventName: queryID 67 | ? 'PDP: Product Added to Cart after Search' 68 | : 'PDP: Product Added to Cart', 69 | objectIDs: [hit.objectID], 70 | queryID, 71 | } 72 | ) 73 | }, [queryID, hit.objectID]) 74 | 75 | return 76 | } 77 | -------------------------------------------------------------------------------- /components/product/product-color-variation-item.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@ui/button/button' 2 | 3 | export type ProductColorVariationItemProps = { 4 | color: string 5 | } 6 | 7 | export function ProductColorVariationItem({ 8 | color, 9 | }: ProductColorVariationItemProps) { 10 | return ( 11 |
  • 12 | 18 |
  • 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /components/product/product-color-variation-list.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef, useState } from 'react' 2 | 3 | import { Button } from '@ui/button/button' 4 | 5 | import { ProductColorVariationItem } from './product-color-variation-item' 6 | 7 | export type ProductColorVariationListProps = { 8 | colors: string[] 9 | limit?: number 10 | } 11 | 12 | export function ProductColorVariationList({ 13 | colors, 14 | limit = 5, 15 | }: ProductColorVariationListProps) { 16 | const colorsSliced = useMemo(() => colors.slice(0, limit), [colors, limit]) 17 | const colorsRemaining = useMemo(() => colors.slice(limit), [colors, limit]) 18 | const colorsRemainingLength = useMemo( 19 | () => colorsRemaining.length, 20 | [colorsRemaining] 21 | ) 22 | const showMoreClicked = useRef(false) 23 | 24 | const [currentColors, setCurrentColors] = useState(colorsSliced) 25 | 26 | const showMore = useCallback(() => { 27 | showMoreClicked.current = true 28 | setCurrentColors(colors) 29 | }, [colors]) 30 | 31 | return ( 32 |
      33 | {currentColors.map((color) => ( 34 | 35 | ))} 36 | 37 | {colorsRemainingLength > 0 && !showMoreClicked.current && ( 38 |
    • 39 | 46 |
    • 47 | )} 48 |
    49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /components/product/product-description.tsx: -------------------------------------------------------------------------------- 1 | export type ProductDescriptionProps = { 2 | children: React.ReactNode 3 | snippeting?: React.ComponentType 4 | className?: string 5 | } 6 | 7 | export function ProductDescription({ 8 | children, 9 | snippeting: Snippeting, 10 | className = 'small-regular', 11 | }: ProductDescriptionProps) { 12 | return

    {Snippeting ? : children}

    13 | } 14 | -------------------------------------------------------------------------------- /components/product/product-favorite.tsx: -------------------------------------------------------------------------------- 1 | import FavoriteFillIcon from '@material-design-icons/svg/outlined/favorite.svg' 2 | import FavoriteOutlineIcon from '@material-design-icons/svg/outlined/favorite_border.svg' 3 | import classNames from 'classnames' 4 | import type { MouseEventHandler } from 'react' 5 | 6 | import { Button } from '@ui/button/button' 7 | import { IconLabel } from '@ui/icon-label/icon-label' 8 | import { Icon } from '@ui/icon/icon' 9 | 10 | export type ProductFavoriteProps = { 11 | isFavorite?: boolean 12 | onClick: MouseEventHandler 13 | layout?: 'icon-label' | 'icon' 14 | className?: string 15 | } 16 | 17 | export function ProductFavorite({ 18 | isFavorite = false, 19 | onClick, 20 | layout = 'icon', 21 | className, 22 | }: ProductFavoriteProps) { 23 | const cn = classNames( 24 | 'flex', 25 | { 'bg-white rounded-sm w-7 h-7 shadow': layout === 'icon' }, 26 | className 27 | ) 28 | const icon = isFavorite ? FavoriteFillIcon : FavoriteOutlineIcon 29 | const text = isFavorite ? 'Remove from favorite' : 'Add to favorite' 30 | 31 | return ( 32 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /components/product/product-image.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import Image from 'next/image' 3 | import { useCallback, useState } from 'react' 4 | 5 | import { useIsMounted } from '@/hooks/useIsMounted' 6 | 7 | export type ProductImageProps = { 8 | src: string 9 | alt?: string 10 | className?: string 11 | } 12 | 13 | export function ProductImage({ src, alt = '', className }: ProductImageProps) { 14 | const [loaded, setLoaded] = useState(false) 15 | const isMounted = useIsMounted() 16 | 17 | const handleLoadingComplete = useCallback( 18 | () => (isMounted() ? setLoaded(true) : null), 19 | [isMounted] 20 | ) 21 | 22 | return ( 23 |
    24 | {alt} 38 |
    39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/product/product-label.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | 3 | export type ProductLabelProps = { 4 | children: React.ReactNode 5 | highlighting?: React.ComponentType 6 | className?: string 7 | } 8 | 9 | export function ProductLabel({ 10 | children, 11 | highlighting: Highlighting, 12 | className = 'tag-bold tracking-normal', 13 | }: ProductLabelProps) { 14 | return ( 15 |

    16 | {Highlighting ? : children} 17 |

    18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/product/product-price.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | 3 | export type ProductPriceCurrency = { 4 | symbol: string 5 | position: 'prefix' | 'suffix' 6 | } 7 | 8 | export type ProductPriceProps = { 9 | price: number 10 | originalPrice?: number 11 | currency?: ProductPriceCurrency 12 | precision?: number 13 | className?: string 14 | classNamePrice?: string 15 | classNameOriginalPrice?: string 16 | } 17 | 18 | export function ProductPrice({ 19 | price, 20 | originalPrice, 21 | currency, 22 | precision = 2, 23 | className = 'items-baseline gap-2 italic', 24 | classNamePrice, 25 | classNameOriginalPrice, 26 | }: ProductPriceProps) { 27 | return ( 28 |
    29 | 30 | {currency?.position === 'prefix' ? currency.symbol : null} 31 | {price.toFixed(precision).toLocaleString()} 32 | {currency?.position === 'suffix' ? currency.symbol : null} 33 | 34 | {originalPrice && ( 35 | 41 | {currency?.position === 'prefix' ? currency.symbol : null} 42 | {originalPrice.toFixed(precision).toLocaleString()} 43 | {currency?.position === 'suffix' ? currency.symbol : null} 44 | 45 | )} 46 |
    47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /components/product/product-rating.tsx: -------------------------------------------------------------------------------- 1 | import StarFillIcon from '@material-design-icons/svg/outlined/star.svg' 2 | import StarOulineIcon from '@material-design-icons/svg/outlined/star_outline.svg' 3 | import classNames from 'classnames' 4 | import { useMemo } from 'react' 5 | 6 | import { clamp } from '@/utils/math' 7 | 8 | export type ProductRatingProps = { 9 | rating: number 10 | maxRating?: number 11 | reviews?: number 12 | reviewComponent?: React.ComponentType<{ reviews: number }> 13 | className?: string 14 | classNameStar?: string 15 | } 16 | 17 | export function ProductRating({ 18 | rating, 19 | maxRating = 4, 20 | reviews, 21 | reviewComponent: ReviewComponent, 22 | className, 23 | classNameStar = 'w-3 h-3', 24 | }: ProductRatingProps) { 25 | const ratingParsed = useMemo( 26 | () => clamp(Math.round(rating), 0, maxRating), 27 | [rating, maxRating] 28 | ) 29 | 30 | const stars = [] 31 | for (let i = 0; i < maxRating; i++) { 32 | const Star = i >= ratingParsed ? StarOulineIcon : StarFillIcon 33 | stars.push(
  • {}
  • ) 34 | } 35 | 36 | return ( 37 |
    38 |
      {stars}
    39 | {reviews && 40 | (ReviewComponent ? ( 41 | 42 | ) : ( 43 | ({reviews}) 44 | ))} 45 |
    46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /components/product/product-sizes.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react' 2 | 3 | import { Button } from '@ui/button/button' 4 | 5 | export type ProductSizeType = { 6 | size: string 7 | available: boolean 8 | } 9 | 10 | export type ProductSizesProps = { 11 | sizes: ProductSizeType[] 12 | } 13 | 14 | export type ProductSizeProps = ProductSizeType & { 15 | selected: boolean 16 | onClick: (size: string) => void 17 | } 18 | 19 | function ProductSize({ size, available, selected, onClick }: ProductSizeProps) { 20 | const handleClick = useCallback(() => onClick(size), [onClick, size]) 21 | return ( 22 |
  • 23 | 32 |
  • 33 | ) 34 | } 35 | 36 | export function ProductSizes({ sizes }: ProductSizesProps) { 37 | const [selectedSize, setSelectedSize] = useState('') 38 | const handleSizeClick = useCallback( 39 | (newSelectedSize: string) => 40 | setSelectedSize(newSelectedSize === selectedSize ? '' : newSelectedSize), 41 | [selectedSize] 42 | ) 43 | 44 | return ( 45 |
      46 | {sizes.map(({ size, available }) => ( 47 | 54 | ))} 55 |
    56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /components/product/product-tag.css: -------------------------------------------------------------------------------- 1 | .tag-theme-default { 2 | @apply bg-gray-400 text-white uppercase; 3 | } 4 | 5 | .tag-theme-eco { 6 | @apply bg-uranus-base text-white uppercase; 7 | } 8 | 9 | .tag-theme-on-sale { 10 | @apply bg-venus-base text-white uppercase; 11 | } 12 | 13 | .tag-theme-popular { 14 | @apply bg-nebula-dark text-white uppercase; 15 | } 16 | 17 | .tag-theme-out-of-stock { 18 | @apply bg-gray-500 text-white uppercase; 19 | } 20 | -------------------------------------------------------------------------------- /components/product/product-tag.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | 3 | export type ProductTagType = { 4 | label: string 5 | theme?: 'default' | 'eco' | 'on-sale' | 'out-of-stock' | 'popular' 6 | } 7 | 8 | export type ProductTagProps = ProductTagType 9 | 10 | export function ProductTag({ label, theme = 'default' }: ProductTagProps) { 11 | const cn = classNames(`tag-theme-${theme}`, 'rounded-sm px-2 py-0.5') 12 | 13 | return {label} 14 | } 15 | -------------------------------------------------------------------------------- /components/product/product-title.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | 3 | export type ProductTitleProps = { 4 | children: React.ReactNode 5 | highlighting?: React.ComponentType 6 | className?: string 7 | } 8 | 9 | export function ProductTitle({ 10 | children, 11 | highlighting: Highlighting, 12 | className = 'small-bold tracking-normal', 13 | }: ProductTitleProps) { 14 | return ( 15 |

    16 | {Highlighting ? : children} 17 |

    18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/products-showcase/products-showcase.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { Configure, Index } from 'react-instantsearch-dom' 3 | 4 | import { Container } from '@/components/container/container' 5 | import { indexName as defaultIndexName } from '@/utils/env' 6 | import { InfiniteHits } from '@instantsearch/widgets/infinite-hits/infinite-hits' 7 | 8 | export type ProductsShowcaseProps = { 9 | title?: string 10 | indexName?: string 11 | indexId?: string 12 | className?: string 13 | hitComponent: React.ComponentType 14 | [index: string]: any 15 | } 16 | 17 | export function ProductsShowcase({ 18 | indexName = defaultIndexName, 19 | indexId, 20 | title, 21 | className, 22 | hitComponent, 23 | ...searchParameters 24 | }: ProductsShowcaseProps) { 25 | return ( 26 | 27 | 28 | 29 |
    30 | 31 | {title && ( 32 |

    33 | {title} 34 |

    35 | )} 36 | 41 |
    42 |
    43 |
    44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /components/refinements-bar/refinements-bar-dropdowns.tsx: -------------------------------------------------------------------------------- 1 | import FilterIcon from '@material-design-icons/svg/outlined/filter_list.svg' 2 | import classNames from 'classnames' 3 | import { useAtomValue } from 'jotai/utils' 4 | import { useCallback, useMemo, useState } from 'react' 5 | 6 | import { configAtom } from '@/config/config' 7 | import { useGetRefinementWidgets } from '@instantsearch/hooks/useGetRefinementWidgets' 8 | import { 9 | getPanelAttributes, 10 | getPanelId, 11 | } from '@instantsearch/utils/refinements' 12 | import { DynamicWidgets } from '@instantsearch/widgets/dynamic-widgets/dynamic-widgets' 13 | import { RefinementsDropdown } from '@instantsearch/widgets/refinements-dropdown/dropdown-refinements' 14 | import { Button } from '@ui/button/button' 15 | import { IconLabel } from '@ui/icon-label/icon-label' 16 | 17 | export type RefinementsBarDropdownsProps = { 18 | dynamicWidgets?: boolean 19 | showMore?: boolean 20 | limit?: number 21 | } 22 | 23 | export function RefinementsBarDropdowns({ 24 | dynamicWidgets, 25 | showMore = true, 26 | limit = 4, 27 | }: RefinementsBarDropdownsProps) { 28 | const { refinements } = useAtomValue(configAtom) 29 | const [showAll, setShowAll] = useState(!showMore) 30 | 31 | const handleShowMoreClick = useCallback(() => setShowAll((v) => !v), []) 32 | 33 | const widgets = useGetRefinementWidgets(refinements) 34 | const widgetsDropdowns = useMemo( 35 | () => 36 | widgets.map((widget, i) => { 37 | const refinement = refinements[i] 38 | const panelId = getPanelId(refinement) 39 | const panelAttributes = getPanelAttributes(refinement) 40 | 41 | return ( 42 | 49 | {widget} 50 | 51 | ) 52 | }), 53 | [widgets, refinements, showAll, limit] 54 | ) 55 | 56 | return ( 57 |
    58 | 59 | {widgetsDropdowns} 60 | 61 | 62 | {showMore && widgets.length > limit && ( 63 | 72 | )} 73 |
    74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /components/refinements-panel/refinements-panel-footer.tsx: -------------------------------------------------------------------------------- 1 | import { useUpdateAtom } from 'jotai/utils' 2 | import { useCallback } from 'react' 3 | 4 | import { ClearRefinements } from '@instantsearch/widgets/clear-refinements/clear-refinements' 5 | import { SeeResultsButton } from '@instantsearch/widgets/see-results-button/see-results-button' 6 | 7 | import { refinementsPanelMobileExpandedAtom } from './refinements-panel' 8 | 9 | export function RefinementsPanelFooter() { 10 | const setMobileExpanded = useUpdateAtom(refinementsPanelMobileExpandedAtom) 11 | 12 | const onSeeResultsClick = useCallback( 13 | () => setMobileExpanded(false), 14 | [setMobileExpanded] 15 | ) 16 | 17 | return ( 18 |
    19 | 20 | Clear filters 21 | 22 | 23 |
    24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /components/refinements-panel/refinements-panel-header.tsx: -------------------------------------------------------------------------------- 1 | import AddIcon from '@material-design-icons/svg/outlined/add.svg' 2 | import CloseIcon from '@material-design-icons/svg/outlined/close.svg' 3 | import FilterIcon from '@material-design-icons/svg/outlined/filter_list.svg' 4 | import RemoveIcon from '@material-design-icons/svg/outlined/remove.svg' 5 | import { useAtom } from 'jotai' 6 | import { useCallback } from 'react' 7 | 8 | import { useLockedBody } from '@/hooks/useLockedBody' 9 | import { Laptop, Tablet } from '@/lib/media' 10 | import { CurrentRefinements } from '@instantsearch/widgets/current-refinements/current-refinements' 11 | import { Button } from '@ui/button/button' 12 | import { IconLabel } from '@ui/icon-label/icon-label' 13 | import { Icon } from '@ui/icon/icon' 14 | 15 | import { refinementsPanelMobileExpandedAtom } from './refinements-panel' 16 | import { refinementsPanelsExpandedAtom } from './refinements-panel-body' 17 | 18 | export function RefinementsPanelHeader() { 19 | const [mobileExpanded, setMobileExpanded] = useAtom( 20 | refinementsPanelMobileExpandedAtom 21 | ) 22 | const [refinementsPanelsExpanded, setRefinementsPanelsExpanded] = useAtom( 23 | refinementsPanelsExpandedAtom 24 | ) 25 | 26 | useLockedBody(mobileExpanded) 27 | 28 | const onCloseClick = useCallback( 29 | () => setMobileExpanded(false), 30 | [setMobileExpanded] 31 | ) 32 | 33 | const onTogglePanelsClick = useCallback( 34 | () => setRefinementsPanelsExpanded((expanded: boolean) => !expanded), 35 | [setRefinementsPanelsExpanded] 36 | ) 37 | 38 | return ( 39 |
    40 |
    41 | 42 |
    43 | 50 | 51 | 54 |
    55 |
    56 | 57 | 58 | 66 | 67 | 78 | 79 |
    80 | 81 | 82 | 83 | 84 |
    85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /components/refinements-panel/refinements-panel-widget.tsx: -------------------------------------------------------------------------------- 1 | import { ColorRefinementList } from '@algolia/react-instantsearch-widget-color-refinement-list' 2 | import { SizeRefinementList } from '@algolia/react-instantsearch-widget-size-refinement-list' 3 | import { HierarchicalMenu, RefinementList } from 'react-instantsearch-dom' 4 | 5 | import type { RefinementType } from '@/typings/refinements' 6 | import { RangeInput } from '@instantsearch/widgets/range-input/range-input' 7 | import { RatingSelector } from '@instantsearch/widgets/rating-selector/rating-selector' 8 | 9 | export type RefinementsPanelWidgetProps = any & { 10 | type: RefinementType 11 | } 12 | 13 | export function RefinementsPanelWidget({ 14 | type, 15 | ...props 16 | }: RefinementsPanelWidgetProps) { 17 | switch (type) { 18 | case 'color': 19 | return 20 | 21 | case 'size': 22 | return 23 | 24 | case 'list': 25 | return 26 | 27 | case 'hierarchical': 28 | return 29 | 30 | case 'rating': 31 | return 32 | 33 | case 'price': 34 | return 35 | 36 | default: 37 | return null 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /components/refinements-panel/refinements-panel.css: -------------------------------------------------------------------------------- 1 | .RefinementsPanel { 2 | /* Mobile */ 3 | @apply flex justify-center bg-white shadow-medium z-refinements fixed w-[85%] h-full top-0 right-0 overflow-y-auto transition-all ease-out-expo duration-500 translate-x-[105%]; 4 | 5 | /* Laptop */ 6 | @apply laptop:block laptop:bg-none laptop:shadow-none laptop:z-auto laptop:flex-shrink-0 laptop:sticky laptop:w-0 laptop:h-container laptop:top-header laptop:right-auto laptop:overflow-y-hidden laptop:transform-none laptop:opacity-0; 7 | } 8 | 9 | .RefinementsPanel-mobileExpanded { 10 | @apply translate-x-0 laptop:transform-none; 11 | } 12 | 13 | .RefinementsPanel-desktopExpanded { 14 | @apply laptop:w-[276px] laptop:opacity-100; 15 | } 16 | 17 | .RefinementsPanel-gradient { 18 | @apply hidden absolute top-0 right-0 w-5 h-full bg-gradient-to-l from-white via-white pointer-events-none laptop:block; 19 | } 20 | -------------------------------------------------------------------------------- /components/refinements-panel/refinements-panel.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import { atom } from 'jotai' 3 | import { useAtomValue } from 'jotai/utils' 4 | 5 | import { overlayAtom } from '@/components/overlay/overlay' 6 | import { Tablet } from '@/lib/media' 7 | import { searchResultsAtom } from '@instantsearch/widgets/virtual-state-results/virtual-state-results' 8 | 9 | import { RefinementsPanelBody } from './refinements-panel-body' 10 | import { RefinementsPanelFooter } from './refinements-panel-footer' 11 | import { RefinementsPanelHeader } from './refinements-panel-header' 12 | 13 | export type RefinementsPanelProps = { 14 | dynamicWidgets?: boolean 15 | } 16 | 17 | const mobileExpandedAtom = atom(false) 18 | export const refinementsPanelMobileExpandedAtom = atom( 19 | (get) => get(mobileExpandedAtom) && get(overlayAtom).visible, 20 | (get, set, expanded: boolean) => { 21 | set(mobileExpandedAtom, expanded) 22 | set(overlayAtom, { visible: expanded, zIndex: 'z-overlay-full' }) 23 | } 24 | ) 25 | export const refinementsPanelDesktopExpandedAtom = atom(true) 26 | 27 | export function RefinementsPanel({ 28 | dynamicWidgets = true, 29 | }: RefinementsPanelProps) { 30 | const mobileExpanded = useAtomValue(refinementsPanelMobileExpandedAtom) 31 | const desktopExpanded = useAtomValue(refinementsPanelDesktopExpandedAtom) 32 | const searchResults = useAtomValue(searchResultsAtom) 33 | 34 | const cn = classNames('RefinementsPanel', { 35 | 'RefinementsPanel-mobileExpanded': mobileExpanded, 36 | 'RefinementsPanel-desktopExpanded': desktopExpanded, 37 | hidden: searchResults?.nbHits === 0, 38 | }) 39 | 40 | return ( 41 |
    42 |
    43 |
    44 |
    45 | 46 | 47 |
    48 | 49 | 50 | 51 |
    52 |
    53 | 54 |
    55 |
    56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /components/toggle-filters/toggle-filters.tsx: -------------------------------------------------------------------------------- 1 | import FilterIcon from '@material-design-icons/svg/outlined/filter_list.svg' 2 | import { useAtom } from 'jotai' 3 | 4 | import { ClientOnly } from '@/components/client-only/client-only' 5 | import { refinementsPanelDesktopExpandedAtom } from '@/components/refinements-panel/refinements-panel' 6 | import { withDebugLayer } from '@dev/debug-layer/debug-layer' 7 | import { Button } from '@ui/button/button' 8 | import { IconLabel } from '@ui/icon-label/icon-label' 9 | 10 | function ToggleFiltersComponent() { 11 | // 'desktopExpanded' is only available in localStorage (client-side only) 12 | const [desktopExpanded, setDesktopExpanded] = useAtom( 13 | refinementsPanelDesktopExpandedAtom 14 | ) 15 | 16 | return ( 17 | 18 | 30 | 31 | ) 32 | } 33 | 34 | export const ToggleFilters = withDebugLayer( 35 | ToggleFiltersComponent, 36 | 'ToggleFiltersWidget' 37 | ) 38 | -------------------------------------------------------------------------------- /components/view-modes/view-modes.tsx: -------------------------------------------------------------------------------- 1 | import ListViewIcon from '@material-design-icons/svg/outlined/format_list_bulleted.svg' 2 | import GridViewIcon from '@material-design-icons/svg/outlined/grid_view.svg' 3 | import classNames from 'classnames' 4 | import { atom, useAtom } from 'jotai' 5 | 6 | import { withDebugLayer } from '@dev/debug-layer/debug-layer' 7 | import { Button } from '@ui/button/button' 8 | import { Icon } from '@ui/icon/icon' 9 | 10 | export type ViewMode = 'grid' | 'list' 11 | 12 | export const viewModeAtom = atom('grid') 13 | 14 | function ViewModesComponent() { 15 | const [viewMode, setViewMode] = useAtom(viewModeAtom) 16 | 17 | return ( 18 |
    19 |
    Display
    20 | 21 | 31 | 41 |
    42 | ) 43 | } 44 | 45 | export const ViewModes = withDebugLayer(ViewModesComponent, 'ViewModesWidget') 46 | -------------------------------------------------------------------------------- /config/config.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | import { freezeAtom } from 'jotai/utils' 3 | 4 | import type { SetUserToken } from '@/hooks/useSearchInsights' 5 | import type { Refinement, RefinementLayout } from '@/typings/refinements' 6 | import { capitalize } from '@/utils/capitalize' 7 | import { indexName } from '@/utils/env' 8 | 9 | export type Config = typeof config 10 | 11 | const refinementsLayoutAtom = atom('panel') 12 | 13 | const refinements: Refinement[] = [ 14 | { 15 | type: 'hierarchical', 16 | header: 'Categories', 17 | label: 'Category', 18 | isExpanded: true, 19 | options: { 20 | attributes: [ 21 | 'hierarchical_categories.lvl0', 22 | 'hierarchical_categories.lvl1', 23 | 'hierarchical_categories.lvl2', 24 | ], 25 | }, 26 | }, 27 | { 28 | type: 'list', 29 | header: 'Brands', 30 | label: 'Brand', 31 | options: { 32 | searchable: true, 33 | attribute: 'brand', 34 | }, 35 | }, 36 | { 37 | type: 'price', 38 | header: 'Price', 39 | label: 'Price', 40 | options: { 41 | attribute: 'price.value', 42 | }, 43 | }, 44 | { 45 | type: 'size', 46 | header: 'Sizes', 47 | label: 'Size', 48 | options: { 49 | attribute: 'available_sizes', 50 | limit: 8, 51 | }, 52 | }, 53 | { 54 | type: 'color', 55 | header: 'Colors', 56 | label: 'Color', 57 | options: { 58 | attribute: 'color.filter_group', 59 | separator: ';', 60 | limit: 9, 61 | showMore: true, 62 | showMoreLimit: 15, 63 | transformItems: (items: any) => 64 | items.map((item: any) => ({ 65 | ...item, 66 | label: capitalize(item.label), 67 | })), 68 | }, 69 | }, 70 | { 71 | type: 'rating', 72 | header: 'Rating', 73 | label: 'Rating', 74 | options: { 75 | attribute: 'reviews.rating', 76 | }, 77 | }, 78 | ] 79 | 80 | const sorts = [ 81 | { value: indexName, label: 'Most popular', isDefault: true }, 82 | { value: `${indexName}_price_asc`, label: 'Price Low to High' }, 83 | { value: `${indexName}_price_desc`, label: 'Price High to Low' }, 84 | ] 85 | 86 | const breadcrumbAttributes = [ 87 | 'hierarchical_categories.lvl0', 88 | 'hierarchical_categories.lvl1', 89 | 'hierarchical_categories.lvl2', 90 | ] 91 | 92 | const searchParameters = { 93 | hitsPerPage: 10, 94 | maxValuesPerFacet: 50, 95 | attributesToSnippet: ['description:60'], 96 | snippetEllipsisText: '…', 97 | analytics: true, 98 | clickAnalytics: true, 99 | } 100 | 101 | const setUserToken: SetUserToken = (generatedUserToken, setToken) => { 102 | setToken(generatedUserToken) 103 | } 104 | 105 | const autocomplete = { 106 | placeholders: ['products', 'articles', 'faq'], 107 | debouncing: 800, // in ms 108 | detachedMediaQuery: '(max-width: 1439px)', 109 | } 110 | 111 | const url = { 112 | debouncing: 1500, // in ms 113 | } 114 | 115 | const config = { 116 | refinementsLayoutAtom, 117 | refinements, 118 | sorts, 119 | breadcrumbAttributes, 120 | searchParameters, 121 | setUserToken, 122 | autocomplete, 123 | url, 124 | } 125 | 126 | export const configAtom = freezeAtom(atom(() => config)) 127 | -------------------------------------------------------------------------------- /config/screens.js: -------------------------------------------------------------------------------- 1 | const screens = { 2 | tablet: '768px', 3 | laptop: '1440px', 4 | 'can-hover': { raw: '(any-hover: hover)' }, 5 | 'cannot-hover': { raw: '(any-hover: none)' }, 6 | } 7 | 8 | module.exports = screens 9 | -------------------------------------------------------------------------------- /hooks/useDebouncedCallback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef } from 'react' 2 | 3 | export function useDebouncedCallback( 4 | callback: (...args: T) => void, 5 | wait: number = 150 6 | ) { 7 | const argsRef = useRef() 8 | const timeout = useRef>() 9 | 10 | const cleanup = useCallback(() => { 11 | if (timeout.current) { 12 | clearTimeout(timeout.current) 13 | } 14 | }, []) 15 | 16 | useEffect(() => { 17 | return () => cleanup() 18 | }, [cleanup]) 19 | 20 | return useMemo( 21 | () => 22 | function debouncedCallback(...args: T) { 23 | argsRef.current = args 24 | 25 | cleanup() 26 | 27 | timeout.current = setTimeout(() => { 28 | if (argsRef.current) { 29 | callback(...argsRef.current) 30 | } 31 | }, wait) 32 | }, 33 | [cleanup, callback, wait] 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /hooks/useDeepCompareCallback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | 3 | import { isDev } from '@/utils/env' 4 | 5 | import { checkDeps, useDeepCompareMemoize } from './useDeepCompareMemoize' 6 | 7 | export function useDeepCompareCallback any>( 8 | callback: T, 9 | dependencies: React.DependencyList 10 | ) { 11 | if (isDev) { 12 | checkDeps(dependencies, 'useDeepCompareCallback') 13 | } 14 | 15 | // eslint-disable-next-line react-hooks/exhaustive-deps 16 | return useCallback(callback, useDeepCompareMemoize(dependencies)) 17 | } 18 | -------------------------------------------------------------------------------- /hooks/useDeepCompareEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { isDev } from '@/utils/env' 4 | 5 | import { checkDeps, useDeepCompareMemoize } from './useDeepCompareMemoize' 6 | 7 | export function useDeepCompareEffect( 8 | effect: React.EffectCallback, 9 | dependencies: React.DependencyList 10 | ) { 11 | if (isDev) { 12 | checkDeps(dependencies, 'useDeepCompareEffect') 13 | } 14 | 15 | // eslint-disable-next-line react-hooks/exhaustive-deps 16 | useEffect(effect, useDeepCompareMemoize(dependencies)) 17 | } 18 | -------------------------------------------------------------------------------- /hooks/useDeepCompareMemo.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | 3 | import { isDev } from '@/utils/env' 4 | 5 | import { checkDeps, useDeepCompareMemoize } from './useDeepCompareMemoize' 6 | 7 | export function useDeepCompareMemo( 8 | factory: () => T, 9 | dependencies: React.DependencyList 10 | ) { 11 | if (isDev) { 12 | checkDeps(dependencies, 'useDeepCompareMemo') 13 | } 14 | 15 | // eslint-disable-next-line react-hooks/exhaustive-deps 16 | return useMemo(factory, useDeepCompareMemoize(dependencies)) 17 | } 18 | -------------------------------------------------------------------------------- /hooks/useDeepCompareMemoize.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | import isEqual from 'react-fast-compare' 3 | 4 | export function checkDeps(deps: React.DependencyList, name: string) { 5 | const reactHookName = `React.${name.replace(/DeepCompare/, '')}` 6 | 7 | if (!deps || deps.length === 0) { 8 | throw new Error( 9 | `${name} should not be used with no dependencies. Use ${reactHookName} instead.` 10 | ) 11 | } 12 | } 13 | 14 | export function useDeepCompareMemoize(value: React.DependencyList) { 15 | const ref = useRef([]) 16 | 17 | if (!isEqual(value, ref.current)) { 18 | ref.current = value 19 | } 20 | 21 | return ref.current 22 | } 23 | -------------------------------------------------------------------------------- /hooks/useDeepCompareSetState.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch, SetStateAction } from 'react' 2 | import { useEffect, useRef, useState, useCallback } from 'react' 3 | import isEqual from 'react-fast-compare' 4 | 5 | export function useDeepCompareState( 6 | initialState: T | (() => T) 7 | ): [T, Dispatch>] { 8 | const [currentState, _setState] = useState(initialState) 9 | 10 | const currentStateRef = useRef(currentState) 11 | 12 | useEffect(() => { 13 | currentStateRef.current = currentState 14 | }, [currentState]) 15 | 16 | const setState = useCallback((state: SetStateAction) => { 17 | const currState = currentStateRef.current 18 | 19 | const nextState = 20 | typeof state === 'function' 21 | ? (state as (prevState: T) => T)(currState) 22 | : state 23 | 24 | if (!isEqual(currState, nextState)) { 25 | _setState(nextState) 26 | } 27 | }, []) 28 | 29 | return [currentState, setState] 30 | } 31 | -------------------------------------------------------------------------------- /hooks/useDeepUpdateAtom.ts: -------------------------------------------------------------------------------- 1 | import type { WritableAtom } from 'jotai' 2 | import { useAtom } from 'jotai' 3 | import type { Scope, SetAtom } from 'jotai/core/atom' 4 | import { useCallback, useEffect, useRef } from 'react' 5 | import isEqual from 'react-fast-compare' 6 | 7 | type ResolveType = T extends Promise ? V : T 8 | 9 | export function useDeepUpdateAtom( 10 | anAtom: WritableAtom, 11 | scope?: Scope 12 | ): [ResolveType, SetAtom] { 13 | const [currentAtomValue, _setAtomValue] = useAtom(anAtom, scope) 14 | 15 | const currentAtomValueRef = useRef(currentAtomValue) 16 | 17 | useEffect(() => { 18 | currentAtomValueRef.current = currentAtomValue 19 | }, [currentAtomValue]) 20 | 21 | const setAtomValue = useCallback((update?: TUpdate) => { 22 | const currAtomValue = currentAtomValueRef.current 23 | 24 | const nextAtomValue = 25 | typeof update === 'function' ? update(currAtomValue) : update 26 | 27 | if (!isEqual(currAtomValue, nextAtomValue)) { 28 | _setAtomValue(nextAtomValue) 29 | } 30 | }, []) // eslint-disable-line react-hooks/exhaustive-deps 31 | 32 | return [currentAtomValue, setAtomValue] 33 | } 34 | -------------------------------------------------------------------------------- /hooks/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useRef } from 'react' 2 | 3 | export function useEventListener( 4 | element: Document | null | undefined, 5 | eventType: KD, 6 | listener: (this: Document, evt: DocumentEventMap[KD]) => void, 7 | options?: AddEventListenerOptions | boolean 8 | ): void 9 | export function useEventListener( 10 | element: HTMLElement | null | undefined, 11 | eventType: KH, 12 | listener: (this: HTMLElement, evt: HTMLElementEventMap[KH]) => void, 13 | options?: AddEventListenerOptions | boolean 14 | ): void 15 | export function useEventListener( 16 | element: Window | null | undefined, 17 | eventType: KW, 18 | listener: (this: Window, evt: WindowEventMap[KW]) => void, 19 | options?: AddEventListenerOptions | boolean 20 | ): void 21 | export function useEventListener( 22 | element: Document | HTMLElement | Window | null | undefined, 23 | eventType: string, 24 | listener: (evt: Event) => void, 25 | options?: AddEventListenerOptions | boolean 26 | ): void 27 | 28 | export function useEventListener< 29 | KD extends keyof DocumentEventMap, 30 | KH extends keyof HTMLElementEventMap, 31 | KW extends keyof WindowEventMap 32 | >( 33 | element: Document | HTMLElement | Window | null | undefined, 34 | eventType: KD | KH | KW | string, 35 | listener: ( 36 | this: typeof element, 37 | evt: 38 | | DocumentEventMap[KD] 39 | | Event 40 | | HTMLElementEventMap[KH] 41 | | WindowEventMap[KW] 42 | ) => void, 43 | options?: AddEventListenerOptions | boolean 44 | ): void { 45 | const listenerRef = useRef(listener) 46 | listenerRef.current = listener 47 | 48 | const memorizedOptions = useMemo(() => options, [options]) 49 | 50 | useEffect(() => { 51 | if (!element) return undefined 52 | 53 | const wrappedListener: typeof listenerRef.current = (evt) => 54 | listenerRef.current.call(element, evt) 55 | 56 | element.addEventListener(eventType, wrappedListener, memorizedOptions) 57 | 58 | return () => { 59 | element.removeEventListener(eventType, wrappedListener, memorizedOptions) 60 | } 61 | }, [element, eventType, memorizedOptions]) 62 | } 63 | -------------------------------------------------------------------------------- /hooks/useIntersectionObserver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | 3 | type IntersectionObserverHook = { 4 | callback: (entry: IntersectionObserverEntry) => void 5 | root?: Document | Element | null 6 | rootMargin?: string 7 | threshold?: number[] | number 8 | } 9 | 10 | export function useIntersectionObserver({ 11 | callback, 12 | root, 13 | rootMargin, 14 | threshold, 15 | }: IntersectionObserverHook) { 16 | const [node, setNode] = useState() 17 | const observer = useRef() 18 | 19 | useEffect(() => { 20 | if (observer.current) { 21 | observer.current.disconnect() 22 | } 23 | 24 | function onIntersection(entries: IntersectionObserverEntry[]) { 25 | entries.forEach((entry) => callback(entry)) 26 | } 27 | 28 | observer.current = new IntersectionObserver(onIntersection, { 29 | root, 30 | rootMargin, 31 | threshold, 32 | }) 33 | 34 | if (node) { 35 | observer.current.observe(node) 36 | } 37 | 38 | return () => { 39 | if (observer.current) { 40 | observer.current.disconnect() 41 | } 42 | } 43 | }, [node, root, rootMargin, threshold, callback]) 44 | 45 | return { 46 | setObservedNode: setNode, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export function useInterval(callback: () => void, delay: number) { 4 | const savedCallback = useRef<() => void>() 5 | 6 | useEffect(() => { 7 | savedCallback.current = callback 8 | }, [callback]) 9 | 10 | useEffect(() => { 11 | if (!delay) return undefined 12 | 13 | function tick() { 14 | if (savedCallback.current) savedCallback.current() 15 | } 16 | 17 | const id = setInterval(tick, delay) 18 | return () => clearInterval(id) 19 | }, [delay]) 20 | } 21 | -------------------------------------------------------------------------------- /hooks/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react' 2 | 3 | import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect' 4 | 5 | export function useIsMounted(shouldTriggerRender = false) { 6 | const isMounted = useRef(false) 7 | const [, triggerRender] = useState(false) 8 | 9 | useIsomorphicLayoutEffect(() => { 10 | isMounted.current = true 11 | if (shouldTriggerRender) triggerRender(true) 12 | 13 | return () => { 14 | isMounted.current = false 15 | if (shouldTriggerRender) triggerRender(false) 16 | } 17 | }, []) 18 | 19 | return useCallback(() => isMounted.current, []) 20 | } 21 | -------------------------------------------------------------------------------- /hooks/useIsVisible.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react' 2 | 3 | import { isomorphicDocument } from '@/utils/browser' 4 | 5 | import { useEventListener } from './useEventListener' 6 | import { useKeyPress } from './useKeyPress' 7 | 8 | export function useIsVisible(initialIsVisible = false) { 9 | const [isVisible, setIsVisible] = useState(initialIsVisible) 10 | const ref = useRef(null) 11 | 12 | const handleClickOutside = useCallback((event: Event) => { 13 | if (ref.current && !ref.current.contains(event.target as Node)) { 14 | setIsVisible(false) 15 | } 16 | }, []) 17 | 18 | const isEscapePressed = useKeyPress('Escape') 19 | useEffect(() => { 20 | if (isEscapePressed) { 21 | setIsVisible(false) 22 | } 23 | }, [isEscapePressed]) 24 | 25 | useEventListener(isomorphicDocument, 'click', handleClickOutside) 26 | 27 | return { ref, isVisible, setIsVisible } 28 | } 29 | -------------------------------------------------------------------------------- /hooks/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react' 2 | 3 | import { isBrowser } from '@/utils/browser' 4 | 5 | export const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect 6 | -------------------------------------------------------------------------------- /hooks/useKeyPress.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react' 2 | 3 | import { isomorphicWindow } from '@/utils/browser' 4 | 5 | import { useEventListener } from './useEventListener' 6 | 7 | export function useKeyPress(targetKey: string): boolean { 8 | const [keyPressed, setKeyPressed] = useState(false) 9 | 10 | const downHandler = useCallback( 11 | ({ key }: KeyboardEvent) => { 12 | if (key === targetKey) { 13 | setKeyPressed(true) 14 | } 15 | }, 16 | [targetKey] 17 | ) 18 | 19 | const upHandler = useCallback( 20 | ({ key }: KeyboardEvent) => { 21 | if (key === targetKey) { 22 | setKeyPressed(false) 23 | } 24 | }, 25 | [targetKey] 26 | ) 27 | 28 | useEventListener(isomorphicWindow, 'keydown', downHandler) 29 | useEventListener(isomorphicWindow, 'keyup', upHandler) 30 | 31 | return keyPressed 32 | } 33 | -------------------------------------------------------------------------------- /hooks/useLockedBody.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { getScrollbarWidth } from '@/utils/browser' 4 | 5 | type ReturnType = [boolean, (locked: boolean) => void] 6 | 7 | export function useLockedBody(initialLocked = false): ReturnType { 8 | const [locked, setLocked] = useState(initialLocked) 9 | 10 | // Do the side effect before render 11 | useEffect(() => { 12 | if (!locked) { 13 | return undefined 14 | } 15 | 16 | // Save initial body style 17 | const originalOverflow = document.body.style.overflow 18 | const originalPaddingRight = document.body.style.paddingRight 19 | 20 | // Get the scrollBar width 21 | const scrollBarWidth = getScrollbarWidth() 22 | 23 | // Avoid width reflow 24 | if (scrollBarWidth) { 25 | document.body.style.paddingRight = `${scrollBarWidth}px` 26 | } 27 | 28 | // Lock body scroll 29 | document.body.style.overflow = 'hidden' 30 | 31 | return () => { 32 | if (scrollBarWidth) { 33 | document.body.style.paddingRight = originalPaddingRight 34 | } 35 | 36 | document.body.style.overflow = originalOverflow 37 | } 38 | }, [locked]) 39 | 40 | // Update state if initialValue changes 41 | useEffect(() => { 42 | if (locked !== initialLocked) { 43 | setLocked(initialLocked) 44 | } 45 | // eslint-disable-next-line react-hooks/exhaustive-deps 46 | }, [initialLocked]) 47 | 48 | return [locked, setLocked] 49 | } 50 | -------------------------------------------------------------------------------- /hooks/useMatchMedia.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { isBrowser } from '@/utils/browser' 4 | 5 | export function useMedia(queries: string[]) { 6 | const [state, setState] = useState( 7 | isBrowser 8 | ? () => queries.map((query) => window.matchMedia(query).matches) 9 | : new Array(queries.length).fill(false) 10 | ) 11 | 12 | useEffect(() => { 13 | let mounted = true 14 | 15 | const updateState = (matches: boolean, i: number) => { 16 | setState((prevState) => { 17 | if (prevState[i] === matches) return prevState 18 | const newState = prevState 19 | newState[i] = matches 20 | return [...newState] 21 | }) 22 | } 23 | 24 | const onChange = (e: MediaQueryListEvent, i: number) => { 25 | if (!mounted) { 26 | return 27 | } 28 | 29 | updateState(e.matches, i) 30 | } 31 | 32 | const mqls = queries.map((query, i) => { 33 | const mql = window.matchMedia(query) 34 | 35 | const mqlCb = (e: MediaQueryListEvent) => onChange(e, i) 36 | mql.addEventListener('change', mqlCb) 37 | 38 | updateState(mql.matches, i) 39 | 40 | return { mql, mqlCb } 41 | }) 42 | 43 | return () => { 44 | mounted = false 45 | mqls.forEach(({ mql, mqlCb }) => mql.removeEventListener('change', mqlCb)) 46 | } 47 | }, [queries]) 48 | 49 | return state 50 | } 51 | -------------------------------------------------------------------------------- /hooks/useSearchClient.ts: -------------------------------------------------------------------------------- 1 | import type { SearchClient } from 'algoliasearch/lite' 2 | import algoliasearch from 'algoliasearch/lite' 3 | import { useMemo } from 'react' 4 | 5 | // eslint-disable-next-line import/extensions 6 | import packageJson from '@/package.json' 7 | import { querySuggestionsIndexName } from '@/utils/env' 8 | 9 | export type SearchClientHookOptions = { 10 | appId: string 11 | searchApiKey: string 12 | } 13 | 14 | export function getSearchClient( 15 | appId: string, 16 | searchApiKey: string 17 | ): SearchClient { 18 | const client = algoliasearch(appId, searchApiKey) 19 | client.addAlgoliaAgent(`pwa-ecom-react-ui-template (${packageJson.version})`) 20 | 21 | return { 22 | ...client, 23 | search(requests) { 24 | const modifiedRequests = requests.map((searchParameters) => { 25 | const detachedSearchParams = { 26 | ...searchParameters.params, 27 | page: 0, 28 | facetFilters: [], 29 | numericFilters: [], 30 | optionalFilters: [], 31 | tagFilters: [], 32 | } 33 | 34 | // In React InstantSearch, `Index` components inherit search 35 | // parameters from their parents. However, when displaying results 36 | // for result suggestions or Query Suggestions, we want to reset these 37 | // search parameters because we expect different results. 38 | // We cannot reset these search parameters with React components, we 39 | // need to use a client proxy. 40 | if (searchParameters.indexName === querySuggestionsIndexName) { 41 | if (!querySuggestionsIndexName) { 42 | throw new Error( 43 | `A search request was sent to the Query Suggestions index but the index name is not specified as an environment variable 'NEXT_PUBLIC_INSTANTSEARCH_QUERY_SUGGESTIONS_INDEX_NAME'.` 44 | ) 45 | } 46 | 47 | return { 48 | ...searchParameters, 49 | indexName: querySuggestionsIndexName, 50 | params: detachedSearchParams, 51 | } 52 | } 53 | 54 | return searchParameters 55 | }) 56 | 57 | return client.search(modifiedRequests) 58 | }, 59 | } 60 | } 61 | 62 | export function useSearchClient({ 63 | appId, 64 | searchApiKey, 65 | }: SearchClientHookOptions): SearchClient { 66 | return useMemo( 67 | () => getSearchClient(appId, searchApiKey), 68 | [appId, searchApiKey] 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /hooks/useSearchInsights.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import searchInsights from 'search-insights' 3 | 4 | export type SetUserToken = ( 5 | generatedUserToken: string, 6 | setToken: (userToken: string) => void 7 | ) => void 8 | 9 | export type SearchInsightsHookOptions = { 10 | appId: string 11 | searchApiKey: string 12 | setUserToken: SetUserToken 13 | } 14 | 15 | export function useSearchInsights({ 16 | appId, 17 | searchApiKey, 18 | setUserToken, 19 | }: SearchInsightsHookOptions) { 20 | useEffect(() => { 21 | searchInsights('init', { 22 | appId, 23 | apiKey: searchApiKey, 24 | useCookie: true, 25 | }) 26 | 27 | if (typeof setUserToken === 'function') { 28 | searchInsights('getUserToken', null, (_, generatedUserToken) => { 29 | setUserToken(generatedUserToken, (userToken) => { 30 | searchInsights('setUserToken', userToken) 31 | }) 32 | }) 33 | } 34 | }, [appId, searchApiKey, setUserToken]) 35 | } 36 | -------------------------------------------------------------------------------- /hooks/useTailwindScreens.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | 3 | import tailwindScreens from '@/utils/tailwindScreens' 4 | 5 | import { useMedia } from './useMatchMedia' 6 | 7 | export type Screens = { 8 | [key: string]: boolean 9 | } 10 | 11 | export function useTailwindScreens() { 12 | const screens = useMemo(() => tailwindScreens, []) 13 | 14 | const [queries, names] = useMemo(() => { 15 | const q = [] 16 | const n = [] 17 | for (const screenName in screens) { 18 | if (Object.prototype.hasOwnProperty.call(screens, screenName)) { 19 | const screenSize = screens[screenName] 20 | q.push(`(min-width: ${screenSize}px)`) 21 | n.push(screenName) 22 | } 23 | } 24 | return [q, n] 25 | }, [screens]) 26 | 27 | const matches = useMedia(queries) 28 | 29 | const screensResults: Screens = {} 30 | names.forEach((screenName: string, i: number) => { 31 | screensResults[screenName] = matches[i] 32 | }) 33 | 34 | return screensResults 35 | } 36 | -------------------------------------------------------------------------------- /hooks/useUserToken.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import searchInsights from 'search-insights' 3 | 4 | export function useUserToken() { 5 | const userTokenRef = useRef(undefined) 6 | 7 | useEffect(() => { 8 | searchInsights('onUserTokenChange', (userToken) => { 9 | userTokenRef.current = userToken 10 | }) 11 | }, []) 12 | 13 | return userTokenRef.current 14 | } 15 | -------------------------------------------------------------------------------- /layouts/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import type { SearchClient } from 'algoliasearch/lite' 2 | import { LazyMotion } from 'framer-motion' 3 | import { atom, Provider as JotaiProvider } from 'jotai' 4 | import { useAtomValue } from 'jotai/utils' 5 | 6 | import { configAtom } from '@/config/config' 7 | import { useSearchClient } from '@/hooks/useSearchClient' 8 | import { useSearchInsights } from '@/hooks/useSearchInsights' 9 | import { MediaContextProvider } from '@/lib/media' 10 | import { createInitialValues } from '@/utils/createInitialValues' 11 | import { appId, searchApiKey } from '@/utils/env' 12 | 13 | export type AppLayoutProps = { 14 | children: React.ReactNode 15 | } 16 | 17 | const loadFramerMotionFeatures = () => 18 | import(/* webpackChunkName: 'lib' */ '@/lib/framer-motion-features').then( 19 | (mod) => mod.default 20 | ) 21 | 22 | export const searchClientAtom = atom(undefined) 23 | 24 | export function AppLayout({ children }: AppLayoutProps) { 25 | const { setUserToken } = useAtomValue(configAtom) 26 | 27 | // Initialize search client 28 | const searchClient = useSearchClient({ 29 | appId, 30 | searchApiKey, 31 | }) 32 | 33 | const { get, set } = createInitialValues() 34 | set(searchClientAtom, searchClient) 35 | 36 | // Initialize search insights 37 | useSearchInsights({ 38 | appId, 39 | searchApiKey, 40 | setUserToken, 41 | }) 42 | 43 | return ( 44 | 45 | 46 | 47 | {children} 48 | 49 | 50 | 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /layouts/basic-page-layout.tsx: -------------------------------------------------------------------------------- 1 | import { m } from 'framer-motion' 2 | import { memo } from 'react' 3 | import isEqual from 'react-fast-compare' 4 | 5 | export type BasicPageLayoutProps = { 6 | children: React.ReactNode 7 | } 8 | 9 | const variants = { 10 | visible: { opacity: 1 }, 11 | hidden: { opacity: 0 }, 12 | } 13 | 14 | const transition = { type: 'linear' } 15 | 16 | function BasicPageLayoutComponent({ children }: BasicPageLayoutProps) { 17 | return ( 18 | 25 | {children} 26 | 27 | ) 28 | } 29 | 30 | export const BasicPageLayout = memo(BasicPageLayoutComponent, isEqual) 31 | -------------------------------------------------------------------------------- /lib/autocomplete/plugins/createClearLeftPlugin.ts: -------------------------------------------------------------------------------- 1 | import type { BaseItem } from '@algolia/autocomplete-core' 2 | import type { 3 | OnStateChangeProps, 4 | AutocompletePlugin, 5 | } from '@algolia/autocomplete-js' 6 | 7 | type CreateClearLeftPluginProps = { 8 | initialQuery?: string 9 | } 10 | 11 | type CustomAutocompletePlugin< 12 | TItem extends BaseItem, 13 | TData 14 | > = AutocompletePlugin & { 15 | unsubscribe?: () => void 16 | } 17 | 18 | export function createClearLeftPlugin< 19 | TItem extends Record, 20 | TData 21 | >({ 22 | initialQuery = '', 23 | }: CreateClearLeftPluginProps = {}): CustomAutocompletePlugin { 24 | let clearBtnEl: HTMLElement | null 25 | let submitBtnEl: HTMLElement | null 26 | let rafId = -1 27 | 28 | const toggleBtns = (queryEmpty: boolean) => { 29 | if (clearBtnEl) clearBtnEl.style.display = queryEmpty ? 'none' : 'block' 30 | if (submitBtnEl) submitBtnEl.style.display = queryEmpty ? 'block' : 'none' 31 | } 32 | 33 | return { 34 | subscribe() { 35 | // Wait for the autocomplete to be mounted 36 | rafId = window.requestAnimationFrame(() => { 37 | clearBtnEl = document.querySelector('.aa-ClearButton') 38 | submitBtnEl = document.querySelector('.aa-SubmitButton') 39 | 40 | // Move clear button from suffix container to prefix 41 | const prefixLabelEl = document.querySelector( 42 | '.aa-InputWrapperPrefix .aa-Label' 43 | ) 44 | if (clearBtnEl) { 45 | prefixLabelEl?.prepend(clearBtnEl) 46 | } 47 | 48 | toggleBtns(!initialQuery) 49 | }) 50 | }, 51 | 52 | unsubscribe() { 53 | window.cancelAnimationFrame(rafId) 54 | }, 55 | 56 | onStateChange({ state }: OnStateChangeProps) { 57 | // Show/hide clear/submit button elements based on the current query 58 | toggleBtns(!state.query) 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/autocomplete/plugins/createFocusBlurPlugin.ts: -------------------------------------------------------------------------------- 1 | import type { BaseItem } from '@algolia/autocomplete-core' 2 | import type { AutocompletePlugin } from '@algolia/autocomplete-js' 3 | 4 | type CreateFocusBlurPluginProps = { 5 | onFocusBlur?: (isFocused: boolean) => void 6 | } 7 | 8 | type CustomAutocompletePlugin< 9 | TItem extends BaseItem, 10 | TData 11 | > = AutocompletePlugin & { 12 | unsubscribe?: () => void 13 | } 14 | 15 | export function createFocusBlurPlugin< 16 | TItem extends Record, 17 | TData 18 | >({ onFocusBlur }: CreateFocusBlurPluginProps = {}): CustomAutocompletePlugin< 19 | TItem, 20 | TData 21 | > { 22 | let inputEl: HTMLInputElement | null 23 | let rafId = -1 24 | 25 | const onFocus = () => { 26 | if (typeof onFocusBlur === 'function') onFocusBlur(true) 27 | } 28 | const onBlur = () => { 29 | if (typeof onFocusBlur === 'function') onFocusBlur(false) 30 | } 31 | 32 | return { 33 | subscribe() { 34 | // Wait for the autocomplete to be mounted 35 | rafId = window.requestAnimationFrame(() => { 36 | inputEl = document.querySelector('.aa-Input') 37 | 38 | if (inputEl) { 39 | inputEl.addEventListener('focus', onFocus) 40 | inputEl.addEventListener('blur', onBlur) 41 | } 42 | }) 43 | }, 44 | 45 | unsubscribe() { 46 | window.cancelAnimationFrame(rafId) 47 | 48 | if (inputEl) { 49 | inputEl.removeEventListener('focus', onFocus) 50 | inputEl.removeEventListener('blur', onBlur) 51 | } 52 | }, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/autocomplete/plugins/createTemplatePlugin.tsx: -------------------------------------------------------------------------------- 1 | import type { BaseItem } from '@algolia/autocomplete-core' 2 | import type { 3 | AutocompletePlugin, 4 | OnStateChangeProps, 5 | } from '@algolia/autocomplete-js' 6 | import { unmountComponentAtNode } from 'react-dom' 7 | 8 | type CreateTemplatePluginProps = { 9 | container: HTMLElement | string 10 | render?: (root: HTMLElement, props: OnStateChangeProps) => void 11 | initialQuery?: string 12 | } 13 | 14 | type CustomAutocompletePlugin< 15 | TItem extends BaseItem, 16 | TData 17 | > = AutocompletePlugin & { 18 | unsubscribe?: () => void 19 | } 20 | 21 | export function createTemplatePlugin< 22 | TItem extends Record, 23 | TData 24 | >({ 25 | container, 26 | render, 27 | initialQuery = '', 28 | }: CreateTemplatePluginProps): CustomAutocompletePlugin { 29 | const rootEl = 30 | typeof document !== 'undefined' ? document.createElement('div') : undefined 31 | let rafId = -1 32 | 33 | const renderFn = (props: OnStateChangeProps) => { 34 | if (render && rootEl) { 35 | render(rootEl, props) 36 | } 37 | } 38 | 39 | return { 40 | subscribe() { 41 | rafId = window.requestAnimationFrame(() => { 42 | const containerEl = 43 | typeof container === 'string' 44 | ? document.querySelector(container) 45 | : container 46 | 47 | if (rootEl) { 48 | rootEl.setAttribute( 49 | 'style', 50 | 'position: relative; width: 100%; height: 100%;' 51 | ) 52 | containerEl?.appendChild(rootEl) 53 | 54 | renderFn({ 55 | state: { query: initialQuery }, 56 | } as OnStateChangeProps) 57 | } 58 | }) 59 | }, 60 | 61 | unsubscribe() { 62 | window.cancelAnimationFrame(rafId) 63 | 64 | if (rootEl) { 65 | unmountComponentAtNode(rootEl) 66 | } 67 | }, 68 | 69 | onStateChange(props) { 70 | renderFn(props as OnStateChangeProps) 71 | }, 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/framer-motion-features.ts: -------------------------------------------------------------------------------- 1 | import { domMax } from 'framer-motion' 2 | 3 | export default domMax 4 | -------------------------------------------------------------------------------- /lib/media.tsx: -------------------------------------------------------------------------------- 1 | import { createMedia } from '@artsy/fresnel' 2 | import classNames from 'classnames' 3 | import type { PropsWithChildren } from 'react' 4 | 5 | import tailwindScreens from '@/utils/tailwindScreens' 6 | 7 | type BreakpointProps = PropsWithChildren<{ 8 | className?: string 9 | }> 10 | 11 | export type Breakpoints = 'laptop' | 'mobile' | 'tablet' 12 | 13 | export const AppMedia = createMedia({ 14 | breakpoints: { 15 | mobile: 0, 16 | ...tailwindScreens, 17 | }, 18 | }) 19 | 20 | export const mediaStyles = AppMedia.createMediaStyle() 21 | 22 | export const { Media, MediaContextProvider } = AppMedia 23 | 24 | const getMediaRender = (children: React.ReactNode, className?: string) => { 25 | return function MediaRender( 26 | mediaClassNames: string, 27 | renderChildren: React.ReactNode 28 | ) { 29 | return ( 30 |
    34 | {renderChildren ? children : null} 35 |
    36 | ) 37 | } 38 | } 39 | 40 | export function Mobile({ children, className }: BreakpointProps) { 41 | return {getMediaRender(children, className)} 42 | } 43 | 44 | export function Tablet({ children, className }: BreakpointProps) { 45 | return {getMediaRender(children, className)} 46 | } 47 | 48 | export function Laptop({ children, className }: BreakpointProps) { 49 | return ( 50 | 51 | {getMediaRender(children, className)} 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = ".next" 4 | 5 | [[plugins]] 6 | package = "@netlify/plugin-nextjs" 7 | 8 | [[plugins]] 9 | package = "@netlify/plugin-lighthouse" 10 | 11 | [plugins.inputs] 12 | output_path = "reports/lighthouse.html" 13 | 14 | [[plugins.inputs.audits]] 15 | path = "./" 16 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withNextPlugins = require('next-compose-plugins') 2 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 3 | enabled: process.env.ANALYZE === 'true', 4 | }) 5 | const withPWA = require('next-pwa') 6 | 7 | const ifdefOpts = { 8 | DEV: process.env.NODE_ENV === 'development', 9 | PROD: process.env.NODE_ENV === 'production', 10 | TEST: process.env.NODE_ENV === 'test', 11 | } 12 | 13 | /** @type {import('next').NextConfig} */ 14 | module.exports = withNextPlugins([withBundleAnalyzer, withPWA], { 15 | generateBuildId: () => 'build', 16 | reactStrictMode: true, 17 | eslint: { 18 | dirs: ['pages', 'components', 'config', 'layouts', 'lib', 'utils', 'hooks'], 19 | }, 20 | images: { 21 | formats: ['image/avif', 'image/webp'], 22 | domains: ['res.cloudinary.com'], 23 | deviceSizes: [375, 425, 768, 828, 1024, 1440, 1920, 2560], 24 | minimumCacheTTL: 60 * 60 * 24, 25 | }, 26 | pwa: { 27 | dest: 'public', 28 | disable: process.env.NODE_ENV !== 'production', 29 | }, 30 | webpack: (config) => { 31 | const rules = config.module.rules 32 | 33 | // Ifdef loader 34 | rules.push({ 35 | test: /\.tsx$/, 36 | use: [ 37 | { 38 | loader: 'ifdef-loader', 39 | options: ifdefOpts, 40 | }, 41 | ], 42 | }) 43 | 44 | // SVGR loader 45 | rules.push({ 46 | test: /\.svg$/, 47 | use: [ 48 | { 49 | loader: '@svgr/webpack', 50 | options: { 51 | icon: true, 52 | svgProps: { 53 | fill: 'currentColor', 54 | }, 55 | }, 56 | }, 57 | ], 58 | }) 59 | 60 | return config 61 | }, 62 | }) 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwa-ecom-ui-template", 3 | "version": "1.0.3", 4 | "description": "React/Next.js based starter kit, focused on delivering a rich Search & Discovery e-commerce experience.", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "analyze": "ANALYZE=true next build", 12 | "cli": "cd cli && npm start", 13 | "postinstall": "cd cli && npm install" 14 | }, 15 | "dependencies": { 16 | "@algolia/autocomplete-js": "^1.5.1", 17 | "@algolia/autocomplete-plugin-query-suggestions": "^1.5.1", 18 | "@algolia/autocomplete-plugin-recent-searches": "^1.5.1", 19 | "@algolia/autocomplete-preset-algolia": "^1.5.1", 20 | "@algolia/autocomplete-theme-classic": "^1.5.1", 21 | "@algolia/react-instantsearch-widget-color-refinement-list": "^1.4.5", 22 | "@algolia/react-instantsearch-widget-loadmore-with-progressbar": "^1.3.8", 23 | "@algolia/react-instantsearch-widget-size-refinement-list": "^1.0.2", 24 | "@artsy/fresnel": "^3.0.2", 25 | "@material-design-icons/svg": "^0.10.8", 26 | "algoliasearch": "^4.11.0", 27 | "classnames": "^2.3.1", 28 | "framer-motion": "^5.2.0", 29 | "jotai": "^1.4.7", 30 | "next": "^12.1.4", 31 | "qs": "^6.10.3", 32 | "react": "17.0.2", 33 | "react-dom": "17.0.2", 34 | "react-fast-compare": "^3.2.0", 35 | "react-instantsearch-core": "^6.15.0", 36 | "react-instantsearch-dom": "^6.15.0", 37 | "search-insights": "^2.1.0" 38 | }, 39 | "devDependencies": { 40 | "@netlify/plugin-nextjs": "^4.3.2", 41 | "@next/bundle-analyzer": "^12.1.4", 42 | "@svgr/webpack": "^5.5.0", 43 | "@tailwindcss/aspect-ratio": "^0.4.0", 44 | "@tweakpane/core": "^1.0.9", 45 | "@types/node": "^16.11.12", 46 | "@types/qs": "^6.9.7", 47 | "@types/react": "^17.0.38", 48 | "@types/react-dom": "^17.0.11", 49 | "@types/react-instantsearch-dom": "^6.12.2", 50 | "@types/tailwindcss": "^2.2.4", 51 | "@typescript-eslint/eslint-plugin": "^4.32.0", 52 | "@typescript-eslint/parser": "^4.32.0", 53 | "autoprefixer": "^10.4.4", 54 | "babel-eslint": "^10.1.0", 55 | "eslint": "^7.32.0", 56 | "eslint-config-algolia": "^19.0.2", 57 | "eslint-config-next": "^12.0.8", 58 | "eslint-config-prettier": "^8.3.0", 59 | "eslint-plugin-eslint-comments": "^3.2.0", 60 | "eslint-plugin-import": "^2.25.4", 61 | "eslint-plugin-jsdoc": "^37.0.3", 62 | "eslint-plugin-jsx-a11y": "^6.5.1", 63 | "eslint-plugin-prettier": "^4.0.0", 64 | "eslint-plugin-react": "^7.27.1", 65 | "eslint-plugin-react-hooks": "^4.3.0", 66 | "eslint-plugin-unused-imports": "^1.1.5", 67 | "ifdef-loader": "^2.3.2", 68 | "next-compose-plugins": "^2.2.1", 69 | "next-pwa": "^5.5.0", 70 | "postcss": "^8.4.12", 71 | "postcss-easy-import": "^3.0.0", 72 | "prettier": "^2.4.1", 73 | "stylelint": "^14.1.0", 74 | "stylelint-config-standard": "^23.0.0", 75 | "tailwindcss": "^2.2.19", 76 | "tweakpane": "^3.0.8", 77 | "typescript": "^4.4.4" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence } from 'framer-motion' 2 | import type { AppProps } from 'next/app' 3 | import dynamic from 'next/dynamic' 4 | import Head from 'next/head' 5 | import Script from 'next/script' 6 | import { useMemo } from 'react' 7 | 8 | /// #if DEV 9 | // eslint-disable-next-line import/order 10 | import { Dev } from '@dev/dev' 11 | /// #endif 12 | 13 | import { Banner } from '@/components/banner/banner' 14 | import type { FooterProps } from '@/components/footer/footer' 15 | import type { HeaderProps } from '@/components/header/header' 16 | import { Loader } from '@/components/loader/loader' 17 | import { Overlay } from '@/components/overlay/overlay' 18 | import { AppLayout } from '@/layouts/app-layout' 19 | import { gaTrackingId, isDev, isProd } from '@/utils/env' 20 | import { scrollToTop } from '@/utils/scrollToTop' 21 | 22 | import '@/styles/_index.css' 23 | 24 | export const Header = dynamic(() => 25 | import(/* webpackChunkName: 'common' */ '@/components/header/header').then( 26 | (mod) => mod.Header 27 | ) 28 | ) 29 | 30 | export const Footer = dynamic(() => 31 | import(/* webpackChunkName: 'common' */ '@/components/footer/footer').then( 32 | (mod) => mod.Footer 33 | ) 34 | ) 35 | 36 | export default function App({ Component, pageProps, router }: AppProps) { 37 | const isCatalogPage = useMemo( 38 | () => router?.pathname === '/catalog/[[...slugs]]', 39 | [router?.pathname] 40 | ) 41 | 42 | return ( 43 | 44 | 45 | Spencer and Williams 46 | 50 | 51 | 52 | {/* Google Analytics */} 53 | {isProd && ( 54 | <> 55 | 68 | 69 | )} 70 | 71 | 72 | 20% Off! Code: SPRING21 - Terms apply* 73 | 74 |
    75 | 76 | 77 | 78 | 79 | 80 |