From 7de0e462684c7f783b7777158d72b24126f1277d Mon Sep 17 00:00:00 2001 From: Federico Badini Date: Fri, 9 Feb 2024 09:01:22 +0100 Subject: [PATCH] feat: product page migration wip --- frontend/.env.development | 2 + frontend/.env.test | 2 + .../app-router/Parts/[...slug]/page.tsx | 102 ++++++ .../(defaultLayout)/app-router/Parts/page.tsx | 55 ++- .../app-router/Shop/[handle]/page.tsx | 62 ++++ .../app-router/Tools/[handle]/page.tsx | 80 ++++ .../(defaultLayout)/app-router/Tools/page.tsx | 54 +++ .../_data/product-list/concerns/algolia.ts | 16 + .../component/product-list-ancestor.ts | 87 +++++ .../concerns/component/product-list-child.ts | 14 + .../component/product-list-preview.ts | 38 ++ .../concerns/component/product-list-type.ts | 29 ++ .../product-list/concerns/device-wiki.ts | 62 ++++ .../app/_data/product-list/concerns/facets.ts | 232 ++++++++++++ .../app/_data/product-list/concerns/hits.ts | 37 ++ .../_data/product-list/concerns/queries.ts | 49 +++ .../featured-product-lists-section.ts | 38 ++ .../sections/filterable-products-section.ts | 17 + .../concerns/sections/hero-section.ts | 15 + .../product-list/concerns/sections/index.ts | 151 ++++++++ .../sections/product-list-children-section.ts | 17 + .../concerns/sections/reusable-sections.ts | 155 ++++++++ frontend/app/_data/product-list/index.tsx | 79 ++++ .../_data/product-list/useAlgoliaSearch.tsx | 45 +++ frontend/app/_helpers/product-list-helpers.ts | 46 +++ frontend/config/flags.ts | 5 + frontend/helpers/algolia-helpers.ts | 9 - .../shopify-storefront-sdk/generated/sdk.ts | 7 + frontend/package.json | 1 + .../product-list/ProductListView.tsx | 113 +++--- .../hooks/useAvailableItemTypes.ts | 12 +- .../hooks/useCurrentProductList.tsx | 10 +- .../hooks/useDevicePartsItemType.ts | 12 +- .../facets/MenuFacet.tsx | 38 +- .../facets/RefinementListFacet.tsx | 33 +- .../accordion/FacetMenuAccordionItem.tsx | 68 ++-- .../FacetRefinementListAccordionItem.tsx | 33 +- .../facets/accordion/index.tsx | 67 ++-- .../accordion/useFacetAccordionItemState.tsx | 4 +- .../facets/useCountRefinements.tsx | 12 +- .../facets/useFacets.tsx | 22 +- .../FilterableProductsSection/index.tsx | 342 ++++++++---------- .../useHasAnyVisibleFacet.ts | 38 +- .../product-list/sections/HeroSection.tsx | 11 +- .../sections/ProductListChildrenSection.tsx | 16 +- packages/helpers/generic-helpers.ts | 25 ++ packages/helpers/index.ts | 1 + packages/helpers/sort-helpers.ts | 50 +++ pnpm-lock.yaml | 13 + 49 files changed, 1941 insertions(+), 485 deletions(-) create mode 100644 frontend/app/(defaultLayout)/app-router/Parts/[...slug]/page.tsx create mode 100644 frontend/app/(defaultLayout)/app-router/Shop/[handle]/page.tsx create mode 100644 frontend/app/(defaultLayout)/app-router/Tools/[handle]/page.tsx create mode 100644 frontend/app/(defaultLayout)/app-router/Tools/page.tsx create mode 100644 frontend/app/_data/product-list/concerns/algolia.ts create mode 100644 frontend/app/_data/product-list/concerns/component/product-list-ancestor.ts create mode 100644 frontend/app/_data/product-list/concerns/component/product-list-child.ts create mode 100644 frontend/app/_data/product-list/concerns/component/product-list-preview.ts create mode 100644 frontend/app/_data/product-list/concerns/component/product-list-type.ts create mode 100644 frontend/app/_data/product-list/concerns/device-wiki.ts create mode 100644 frontend/app/_data/product-list/concerns/facets.ts create mode 100644 frontend/app/_data/product-list/concerns/hits.ts create mode 100644 frontend/app/_data/product-list/concerns/queries.ts create mode 100644 frontend/app/_data/product-list/concerns/sections/featured-product-lists-section.ts create mode 100644 frontend/app/_data/product-list/concerns/sections/filterable-products-section.ts create mode 100644 frontend/app/_data/product-list/concerns/sections/hero-section.ts create mode 100644 frontend/app/_data/product-list/concerns/sections/index.ts create mode 100644 frontend/app/_data/product-list/concerns/sections/product-list-children-section.ts create mode 100644 frontend/app/_data/product-list/concerns/sections/reusable-sections.ts create mode 100644 frontend/app/_data/product-list/index.tsx create mode 100644 frontend/app/_data/product-list/useAlgoliaSearch.tsx create mode 100644 frontend/app/_helpers/product-list-helpers.ts create mode 100644 packages/helpers/sort-helpers.ts diff --git a/frontend/.env.development b/frontend/.env.development index 6938b845..23df8291 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -20,3 +20,5 @@ NEXT_PUBLIC_FLAG__STORE_HOME_PAGE_ENABLED=true NEXT_PUBLIC_FLAG__TROUBLESHOOTING_COLLECTIONS_ENABLED=true NEXT_PUBLIC_FLAG__APP_ROUTER_PRODUCT_PAGE_ENABLED=true NEXT_PUBLIC_FLAG__APP_ROUTER_PARTS_PAGE_ENABLED=true +NEXT_PUBLIC_FLAG__APP_ROUTER_TOOLS_PAGE_ENABLED=true +NEXT_PUBLIC_FLAG__APP_ROUTER_MARKETING_PAGE_ENABLED=true diff --git a/frontend/.env.test b/frontend/.env.test index 7b6840b6..9d4e5fec 100644 --- a/frontend/.env.test +++ b/frontend/.env.test @@ -10,3 +10,5 @@ NODE_OPTIONS="--dns-result-order ipv4first" NEXT_PUBLIC_DEV_API_AUTH_TOKEN= NEXT_PUBLIC_FLAG__APP_ROUTER_PRODUCT_PAGE_ENABLED=true NEXT_PUBLIC_FLAG__APP_ROUTER_PARTS_PAGE_ENABLED=true +NEXT_PUBLIC_FLAG__APP_ROUTER_TOOLS_PAGE_ENABLED=true +NEXT_PUBLIC_FLAG__APP_ROUTER_MARKETING_PAGE_ENABLED=true diff --git a/frontend/app/(defaultLayout)/app-router/Parts/[...slug]/page.tsx b/frontend/app/(defaultLayout)/app-router/Parts/[...slug]/page.tsx new file mode 100644 index 00000000..09823782 --- /dev/null +++ b/frontend/app/(defaultLayout)/app-router/Parts/[...slug]/page.tsx @@ -0,0 +1,102 @@ +import { flags } from '@config/flags'; +import { productListPath } from '@helpers/path-helpers'; +import { + destylizeDeviceItemType, + destylizeDeviceTitle, +} from '@helpers/product-list-helpers'; +import { invariant } from '@ifixit/helpers'; +import ProductList from '@pages/api/nextjs/cache/product-list'; +import { ifixitOrigin, shouldSkipCache } from 'app/_helpers/app-helpers'; +import { + getDeviceCanonicalPath, + parseSearchParams, +} from 'app/_helpers/product-list-helpers'; +import { notFound, redirect } from 'next/navigation'; +import { ProductListType } from '@models/product-list'; +import { search } from 'app/_data/product-list'; + +export interface DevicePartsPageProps { + params: { + slug?: string[]; + }; + searchParams: { + disableCacheGets?: string | string[] | undefined; + }; +} + +export default async function DevicePartsPage({ + params, + searchParams, +}: DevicePartsPageProps) { + if (!flags.APP_ROUTER_PARTS_PAGE_ENABLED) notFound(); + + if (params.slug == null) notFound(); + + const [deviceHandle, itemTypeHandle, ...otherPathSegments] = params.slug; + + if (deviceHandle == null || otherPathSegments.length > 0) notFound(); + + const deviceTitle = destylizeDeviceTitle(deviceHandle); + const itemType = itemTypeHandle + ? destylizeDeviceItemType(itemTypeHandle) + : null; + + const productList = await ProductList.get( + { + filters: { + deviceTitle: { + eqi: deviceTitle, + }, + }, + ifixitOrigin: ifixitOrigin(), + }, + { forceMiss: shouldSkipCache(searchParams) } + ); + + if (productList == null) notFound(); + + const shouldRedirectToCanonical = + typeof productList?.deviceTitle === 'string' && + productList.deviceTitle !== deviceTitle; + + const canonicalPath = getDeviceCanonicalPath( + productList?.deviceTitle, + itemType + ); + + if (shouldRedirectToCanonical) { + invariant(canonicalPath != null, 'canonical path is required'); + redirect(canonicalPath); + } + + if (productList.redirectTo) { + const path = productListPath({ + productList: productList.redirectTo, + itemType: itemType ?? undefined, + }); + return redirect(`${path}?${params}`); + } + + const urlState = parseSearchParams(searchParams); + const { hitsCount } = await search({ + productListType: ProductListType.DeviceParts, + baseFilters: `device:${JSON.stringify(deviceTitle)}`, + // Todo: handle this + excludePro: true, + ...urlState, + }); + + console.log({ + productListType: ProductListType.DeviceParts, + excludePro: true, + ...urlState, + }); + + return ( + <> +
Device parts page
+
{JSON.stringify(productList)}
+
{hitsCount}
+ + ); +} diff --git a/frontend/app/(defaultLayout)/app-router/Parts/page.tsx b/frontend/app/(defaultLayout)/app-router/Parts/page.tsx index 3532fea2..e36cc04f 100644 --- a/frontend/app/(defaultLayout)/app-router/Parts/page.tsx +++ b/frontend/app/(defaultLayout)/app-router/Parts/page.tsx @@ -1,10 +1,57 @@ import { flags } from '@config/flags'; -import { notFound } from 'next/navigation'; +import { productListPath } from '@helpers/path-helpers'; +import { ProductListType } from '@models/product-list'; +import ProductList from '@pages/api/nextjs/cache/product-list'; +import { ProductListView } from '@templates/product-list/ProductListView'; +import { search } from 'app/_data/product-list'; +import { AlgoliaSearchProvider } from 'app/_data/product-list/useAlgoliaSearch'; +import { ifixitOrigin, shouldSkipCache } from 'app/_helpers/app-helpers'; +import { parseSearchParams } from 'app/_helpers/product-list-helpers'; +import { notFound, redirect } from 'next/navigation'; -export interface PartsPageProps {} +export interface AllPartsPageProps { + params: {}; + searchParams: { + disableCacheGets?: string | string[] | undefined; + }; +} -export default async function PartsPage() { +export default async function AllPartsPage({ + params, + searchParams, +}: AllPartsPageProps) { if (!flags.APP_ROUTER_PARTS_PAGE_ENABLED) notFound(); - return
Parts page
; + const productList = await ProductList.get( + { + filters: { handle: { eq: 'Parts' } }, + ifixitOrigin: ifixitOrigin(), + }, + { forceMiss: shouldSkipCache(searchParams) } + ); + + if (productList == null) notFound(); + + if (productList.redirectTo) { + const path = productListPath({ + productList: productList.redirectTo, + }); + return redirect(`${path}?${params}`); + } + + const urlState = parseSearchParams(searchParams); + const algoliaSearchResponse = await search({ + productListType: ProductListType.DeviceParts, + // Todo: handle this + excludePro: true, + ...urlState, + }); + + return ( + <> + + + + + ); } diff --git a/frontend/app/(defaultLayout)/app-router/Shop/[handle]/page.tsx b/frontend/app/(defaultLayout)/app-router/Shop/[handle]/page.tsx new file mode 100644 index 00000000..93ef11c6 --- /dev/null +++ b/frontend/app/(defaultLayout)/app-router/Shop/[handle]/page.tsx @@ -0,0 +1,62 @@ +import { flags } from '@config/flags'; +import { productListPath } from '@helpers/path-helpers'; +import { invariant } from '@ifixit/helpers'; +import ProductList from '@pages/api/nextjs/cache/product-list'; +import { ifixitOrigin, shouldSkipCache } from 'app/_helpers/app-helpers'; +import { notFound, redirect } from 'next/navigation'; + +export interface ToolCategoryPageProps { + params: { + handle?: string; + }; + searchParams: { + disableCacheGets?: string | string[] | undefined; + }; +} + +export default async function ShopPage({ + params, + searchParams, +}: ToolCategoryPageProps) { + if (!flags.APP_ROUTER_MARKETING_PAGE_ENABLED) notFound(); + + if (params.handle == null) notFound(); + + const productList = await ProductList.get( + { + filters: { + handle: { eqi: params.handle }, + type: { eq: 'marketing' }, + }, + ifixitOrigin: ifixitOrigin(), + }, + { forceMiss: shouldSkipCache(searchParams) } + ); + + if (productList == null) notFound(); + + const shouldRedirectToCanonical = + typeof productList?.handle === 'string' && + productList.handle !== params.handle; + const canonicalPath = + typeof productList?.handle === 'string' + ? `/Shop/${productList.handle}` + : null; + + if (shouldRedirectToCanonical) { + invariant(canonicalPath != null, 'canonical path is required'); + redirect(canonicalPath); + } + if (productList.redirectTo) { + const path = productListPath({ + productList: productList.redirectTo, + }); + return redirect(`${path}?${params}`); + } + return ( + <> +
Marketing page
+
{JSON.stringify(productList)}
+ + ); +} diff --git a/frontend/app/(defaultLayout)/app-router/Tools/[handle]/page.tsx b/frontend/app/(defaultLayout)/app-router/Tools/[handle]/page.tsx new file mode 100644 index 00000000..63394721 --- /dev/null +++ b/frontend/app/(defaultLayout)/app-router/Tools/[handle]/page.tsx @@ -0,0 +1,80 @@ +import { flags } from '@config/flags'; +import { productListPath } from '@helpers/path-helpers'; +import { invariant } from '@ifixit/helpers'; +import { ProductListType } from '@models/product-list'; +import ProductList from '@pages/api/nextjs/cache/product-list'; +import { search } from 'app/_data/product-list'; +import { ifixitOrigin, shouldSkipCache } from 'app/_helpers/app-helpers'; +import { parseSearchParams } from 'app/_helpers/product-list-helpers'; +import { notFound, redirect } from 'next/navigation'; + +export interface ToolCategoryPageProps { + params: { + handle?: string; + }; + searchParams: { + disableCacheGets?: string | string[] | undefined; + }; +} + +export default async function ToolCategoryPage({ + params, + searchParams, +}: ToolCategoryPageProps) { + if (!flags.APP_ROUTER_TOOLS_PAGE_ENABLED) notFound(); + + if (params.handle == null) notFound(); + + const productList = await ProductList.get( + { + filters: { + handle: { eqi: params.handle }, + type: { in: ['marketing', 'tools'] }, + }, + ifixitOrigin: ifixitOrigin(), + }, + { forceMiss: shouldSkipCache(searchParams) } + ); + + if (productList == null) notFound(); + + const isMarketing = productList?.type === ProductListType.Marketing; + const isMiscapitalized = + typeof productList?.handle === 'string' && + productList.handle !== params.handle; + const shouldRedirectToCanonical = isMiscapitalized || isMarketing; + const canonicalPath = + typeof productList?.handle === 'string' + ? isMarketing + ? `/Shop/${productList.handle}` + : `/Tools/${productList.handle}` + : null; + + if (shouldRedirectToCanonical) { + invariant(canonicalPath != null, 'canonical path is required'); + redirect(canonicalPath); + } + + if (productList.redirectTo) { + const path = productListPath({ + productList: productList.redirectTo, + }); + return redirect(`${path}?${params}`); + } + + const urlState = parseSearchParams(searchParams); + const { hitsCount } = await search({ + productListType: ProductListType.AllTools, + excludePro: true, + baseFilters: productList.filters || undefined, + ...urlState, + }); + + return ( + <> +
Tool category page
+
{JSON.stringify(productList)}
+
{hitsCount}
+ + ); +} diff --git a/frontend/app/(defaultLayout)/app-router/Tools/page.tsx b/frontend/app/(defaultLayout)/app-router/Tools/page.tsx new file mode 100644 index 00000000..7235ff68 --- /dev/null +++ b/frontend/app/(defaultLayout)/app-router/Tools/page.tsx @@ -0,0 +1,54 @@ +import { flags } from '@config/flags'; +import { productListPath } from '@helpers/path-helpers'; +import { ProductListType } from '@models/product-list'; +import ProductList from '@pages/api/nextjs/cache/product-list'; +import { search } from 'app/_data/product-list'; +import { ifixitOrigin, shouldSkipCache } from 'app/_helpers/app-helpers'; +import { parseSearchParams } from 'app/_helpers/product-list-helpers'; +import { notFound, redirect } from 'next/navigation'; + +export interface AllToolsPageProps { + params: {}; + searchParams: { + disableCacheGets?: string | string[] | undefined; + }; +} + +export default async function AllToolsPage({ + params, + searchParams, +}: AllToolsPageProps) { + if (!flags.APP_ROUTER_TOOLS_PAGE_ENABLED) notFound(); + + const productList = await ProductList.get( + { + filters: { handle: { eq: 'Tools' } }, + ifixitOrigin: ifixitOrigin(), + }, + { forceMiss: shouldSkipCache(searchParams) } + ); + + if (productList == null) notFound(); + + if (productList.redirectTo) { + const path = productListPath({ + productList: productList.redirectTo, + }); + return redirect(`${path}?${params}`); + } + + const urlState = parseSearchParams(searchParams); + const { hitsCount } = await search({ + productListType: ProductListType.AllTools, + excludePro: true, + ...urlState, + }); + + return ( + <> +
All tools page
+
{JSON.stringify(productList)}
+
{hitsCount}
+ + ); +} diff --git a/frontend/app/_data/product-list/concerns/algolia.ts b/frontend/app/_data/product-list/concerns/algolia.ts new file mode 100644 index 00000000..99858f92 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/algolia.ts @@ -0,0 +1,16 @@ +import { createFetchRequester } from '@algolia/requester-fetch'; +import { ALGOLIA_API_KEY, ALGOLIA_APP_ID } from '@config/env'; +import algoliasearch, { SearchClient } from 'algoliasearch'; + +export type { SearchResponse } from '@algolia/client-search'; + +export function createClient() { + const client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY, { + requester: createFetchRequester(), + }); + return client; +} + +export type MultipleQueriesQuery = Parameters< + SearchClient['multipleQueries'] +>[0][number]; diff --git a/frontend/app/_data/product-list/concerns/component/product-list-ancestor.ts b/frontend/app/_data/product-list/concerns/component/product-list-ancestor.ts new file mode 100644 index 00000000..188c4dd6 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/component/product-list-ancestor.ts @@ -0,0 +1,87 @@ +import { getProductListTitle } from '@/helpers/product-list-helpers'; +import type { ProductListFieldsFragment } from '@/lib/strapi-sdk'; +import { z } from 'zod'; +import { DeviceWiki } from '../device-wiki'; +import { + ProductListType, + ProductListTypeSchema, + productListTypeFromStrapi, +} from './product-list-type'; + +export type ProductListAncestor = z.infer; + +export const ProductListAncestorSchema = z.object({ + deviceTitle: z.string().nullable(), + title: z.string(), + type: ProductListTypeSchema, + handle: z.string(), +}); + +export function createProductListAncestorsFromStrapiOrDeviceWiki( + strapiProductList: ProductListFieldsFragment | null | undefined, + deviceWiki: DeviceWiki | null +): ProductListAncestor[] { + if (strapiProductList?.parent) { + return createProductListAncestorsFromStrapi(strapiProductList?.parent); + } + if (deviceWiki?.ancestors) { + return createProductListAncestorsFromDeviceWiki(deviceWiki?.ancestors); + } + return []; +} + +function createProductListAncestorsFromStrapi( + parent: ProductListFieldsFragment['parent'] +): ProductListAncestor[] { + const attributes = parent?.data?.attributes; + if (attributes == null) { + return []; + } + const ancestors = createProductListAncestorsFromStrapi(attributes.parent); + + const type = productListTypeFromStrapi(attributes.type); + + return ancestors.concat({ + deviceTitle: attributes.deviceTitle ?? null, + title: getProductListTitle({ + title: attributes.title, + type, + }), + type, + handle: attributes.handle, + }); +} + +function createProductListAncestorsFromDeviceWiki( + ancestorsTitles: string[] +): ProductListAncestor[] { + if (ancestorsTitles.length === 0) return []; + + const [ancestorTitle, ...remainingTitles] = ancestorsTitles; + + if (ancestorTitle == null) return []; + + const ancestorsList = + createProductListAncestorsFromDeviceWiki(remainingTitles); + + const ancestor = createAncestorFromWikiTitle(ancestorTitle); + + return ancestorsList.concat(ancestor); +} + +function createAncestorFromWikiTitle(title: string): ProductListAncestor { + if (title === 'Root') { + return { + deviceTitle: title, + title: 'All Parts', + type: ProductListType.AllParts, + handle: 'Parts', + }; + } + return { + deviceTitle: title, + title: `${title} Parts`, + type: ProductListType.DeviceParts, + handle: '', + }; +} diff --git a/frontend/app/_data/product-list/concerns/component/product-list-child.ts b/frontend/app/_data/product-list/concerns/component/product-list-child.ts new file mode 100644 index 00000000..7e7fb784 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/component/product-list-child.ts @@ -0,0 +1,14 @@ +import { ImageSchema } from '@/models/concerns/components/image'; +import { z } from 'zod'; +import { ProductListTypeSchema } from './product-list-type'; + +export type ProductListChild = z.infer; + +export const ProductListChildSchema = z.object({ + title: z.string(), + deviceTitle: z.string().nullable(), + handle: z.string(), + image: ImageSchema.nullable(), + sortPriority: z.number().nullable(), + type: ProductListTypeSchema, +}); diff --git a/frontend/app/_data/product-list/concerns/component/product-list-preview.ts b/frontend/app/_data/product-list/concerns/component/product-list-preview.ts new file mode 100644 index 00000000..0a849539 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/component/product-list-preview.ts @@ -0,0 +1,38 @@ +import type { ProductListPreviewFieldsFragment } from '@/lib/strapi-sdk'; +import { + ImageSchema, + imageFromStrapi, +} from '@/models/concerns/components/image'; +import { z } from 'zod'; +import { + ProductListTypeSchema, + productListTypeFromStrapi, +} from './product-list-type'; + +export type ProductListPreview = z.infer; + +export const ProductListPreviewSchema = z.object({ + handle: z.string(), + title: z.string(), + type: ProductListTypeSchema, + deviceTitle: z.string().nullable(), + description: z.string(), + image: ImageSchema.nullable(), + filters: z.string().nullable(), +}); + +export function productListPreviewFromStrapi( + fragment: ProductListPreviewFieldsFragment | null | undefined +): ProductListPreview | null { + if (fragment == null) return null; + + return { + handle: fragment.handle, + title: fragment.title, + type: productListTypeFromStrapi(fragment.type), + deviceTitle: fragment.deviceTitle ?? null, + description: fragment.description, + image: imageFromStrapi(fragment.image), + filters: fragment.filters ?? null, + }; +} diff --git a/frontend/app/_data/product-list/concerns/component/product-list-type.ts b/frontend/app/_data/product-list/concerns/component/product-list-type.ts new file mode 100644 index 00000000..94f0d52b --- /dev/null +++ b/frontend/app/_data/product-list/concerns/component/product-list-type.ts @@ -0,0 +1,29 @@ +import { Enum_Productlist_Type } from '@/lib/strapi-sdk'; +import { z } from 'zod'; + +export enum ProductListType { + AllParts = 'parts', + DeviceParts = 'device-parts', + AllTools = 'tools', + ToolsCategory = 'tools-category', + Marketing = 'marketing', +} + +export const ProductListTypeSchema = z.nativeEnum(ProductListType); + +export function productListTypeFromStrapi( + type?: Enum_Productlist_Type | null +): ProductListType { + switch (type) { + case Enum_Productlist_Type.AllParts: + return ProductListType.AllParts; + case Enum_Productlist_Type.AllTools: + return ProductListType.AllTools; + case Enum_Productlist_Type.Tools: + return ProductListType.ToolsCategory; + case Enum_Productlist_Type.Marketing: + return ProductListType.Marketing; + default: + return ProductListType.DeviceParts; + } +} diff --git a/frontend/app/_data/product-list/concerns/device-wiki.ts b/frontend/app/_data/product-list/concerns/device-wiki.ts new file mode 100644 index 00000000..a7359471 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/device-wiki.ts @@ -0,0 +1,62 @@ +import { invariant } from '@/helpers/application-helpers'; +import { stylizeDeviceItemType } from '@/helpers/product-list-helpers'; +import { IFixitAPIClient } from '@/lib/ifixit-sdk'; +import { z } from 'zod'; + +export type DeviceWiki = Record; + +export async function fetchDeviceWiki( + deviceTitle: string +): Promise { + const client = new IFixitAPIClient(); + const deviceHandle = encodeURIComponent(stylizeDeviceItemType(deviceTitle)); + try { + invariant( + deviceHandle.length > 0, + 'deviceHandle cannot be a blank string' + ); + return await client.get(`cart/part_collections/devices/${deviceHandle}`); + } catch (error: any) { + return null; + } +} + +export type MultipleDeviceApiResponse = z.infer< + typeof MultipleDeviceApiResponseSchema +>; + +const MultipleDeviceApiResponseSchema = z.object({ + images: z.record(z.string()), +}); + +export type GuideImageSize = + | 'mini' + | 'thumbnail' + | '140x105' + | '200x150' + | 'standard' + | '440x330' + | 'medium' + | 'large' + | 'huge'; + +export async function fetchMultipleDeviceImages( + deviceTitles: string[], + size: GuideImageSize +): Promise { + const client = new IFixitAPIClient(); + const params = new URLSearchParams(); + params.set('size', size); + deviceTitles.forEach((deviceTitle) => { + params.append('t[]', deviceTitle); + }); + try { + const result = await client.get( + `wikis/topic_images?` + params.toString() + ); + return MultipleDeviceApiResponseSchema.parse(result); + } catch (error) { + console.error(error); + return { images: {} }; + } +} diff --git a/frontend/app/_data/product-list/concerns/facets.ts b/frontend/app/_data/product-list/concerns/facets.ts new file mode 100644 index 00000000..e0eddfd9 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/facets.ts @@ -0,0 +1,232 @@ +import { + compareAlphabetically, + compareCapacity, + comparePriceRangeFn, +} from '@ifixit/helpers'; +import { filterNullableItems, isBlank, isPresent } from '@ifixit/helpers'; +import startCase from 'lodash/startCase'; +import { ProductListType } from '@models/product-list'; +import { SearchResponse } from './algolia'; + +export const SUPPORTED_FACETS = [ + 'facet_tags.Capacity', + 'facet_tags.Device Brand', + 'facet_tags.Device Category', + 'facet_tags.Device Type', + 'facet_tags.Item Type', + 'facet_tags.OS', + 'facet_tags.Part or Kit', + 'facet_tags.Tool Category', + 'device', + 'worksin', +]; + +const MULTI_OPTION_FACETS = ['facet_tags.Capacity']; + +const TOOLS_FACETS_EXCLUSION_LIST = [ + 'facet_tags.Item Type', + 'facet_tags.Capacity', + 'device', + 'facet_tags.Device Brand', + 'facet_tags.Device Category', + 'facet_tags.Device Type', + 'facet_tags.OS', + 'facet_tags.Part or Kit', +]; + +const PARTS_FACET_RANKING: Record = { + 'facet_tags.Item Type': 2, + 'facet_tags.Part or Kit': 1, +}; + +const TOOLS_FACET_RANKING: Record = { + 'facet_tags.Tool Category': 2, + price_range: 1, +}; + +export interface Facet { + name: string; + displayName: string; + multiple: boolean; + options: FacetOption[]; + selectedCount: number; +} + +export interface FacetOption { + value: string; + count: number; + selected: boolean; +} + +type AlgoliaFacets = Record; +type OptionsCountByValue = Record; + +interface CreateFacetsOptions { + productListType: ProductListType; + results: SearchResponse[]; + refinements: Record; +} + +export function createFacets({ + productListType, + results, + refinements, +}: CreateFacetsOptions): Facet[] { + const facetsList = filterNullableItems(results.map((r) => r.facets)); + + if (facetsList == null || facetsList.length === 0) return []; + + return filterNullableItems( + SUPPORTED_FACETS.map((facetName) => { + const options = findFacetOptions( + facetsList, + facetName, + refinements[facetName] + ); + + if (isBlank(options)) return null; + + return createFacet(facetName, options, refinements[facetName]); + }) + ) + .filter(facetFilterFn(productListType)) + .sort(facetCompareFn(productListType)); +} + +interface ProductListAttributes { + filters?: string | null; + deviceTitle?: string | null; +} + +export function getProductListAlgoliaFiltersPreset< + T extends ProductListAttributes +>({ filters, deviceTitle }: T): string | undefined { + if (isPresent(filters)) { + // Algolia can't handle newlines in the filter, so replace with space. + return filters.replace(/(\n|\r)+/g, ' '); + } + if (isPresent(deviceTitle)) { + return `device:${JSON.stringify(deviceTitle)}`; + } + return undefined; +} + +function findFacetOptions( + algoliaFacetsList: AlgoliaFacets[], + facetName: string, + refinements: string[] +) { + const [nonRefinedFacets, ...refinedFacetsList] = algoliaFacetsList; + + const facets = + refinedFacetsList.find( + (refinedFacets) => refinedFacets[facetName] != null + ) ?? nonRefinedFacets; + + const options = facets[facetName] ?? {}; + return addEmptyRefinedOptions(options, refinements); +} + +// Make sure that options that are refined but have no results are still +// included in the facets list. +function addEmptyRefinedOptions( + optionsCountByValue: OptionsCountByValue, + refinedOptions: string[] = [] +) { + for (const refinement of refinedOptions) { + optionsCountByValue[refinement] = optionsCountByValue[refinement] ?? 0; + } + return optionsCountByValue; +} + +function facetFilterFn( + productListType: ProductListType +): (facet: Facet) => boolean { + switch (productListType) { + case 'tools': + case 'tools-category': + return (facet) => !TOOLS_FACETS_EXCLUSION_LIST.includes(facet.name); + default: + return () => true; + } +} + +function facetCompareFn( + productListType: ProductListType +): (a: Facet, b: Facet) => number { + switch (productListType) { + case 'parts': + case 'device-parts': + return compareWithRankingFn(PARTS_FACET_RANKING); + case 'tools': + case 'tools-category': + return compareWithRankingFn(TOOLS_FACET_RANKING); + default: + return (a, b) => compareAlphabetically(a.displayName, b.displayName); + } +} + +function createFacet( + facetName: string, + algoliaOptions: Record, + refinedOptions: string[] = [] +): Facet { + let selectedCount = 0; + + const options = Object.entries(algoliaOptions).map( + ([value, count]): FacetOption => { + const selected = refinedOptions.includes(value); + if (selected) selectedCount++; + return { value, count, selected }; + } + ); + options.sort(facetOptionCompareFn(facetName)); + + return { + name: facetName, + displayName: getFacetDisplayName(facetName), + multiple: MULTI_OPTION_FACETS.includes(facetName), + options, + selectedCount, + }; +} + +function compareWithRankingFn(ranking: Record) { + return (a: Facet, b: Facet) => { + const aRank = ranking[a.name] ?? 0; + const bRank = ranking[b.name] ?? 0; + return ( + bRank - aRank || compareAlphabetically(a.displayName, b.displayName) + ); + }; +} + +function getFacetDisplayName(facetName: string) { + let displayName = facetName.replace('facet_tags.', ''); + displayName = startCase(displayName); + return displayName; +} + +const facetOptionCompareFn = + (facetName: string) => (a: FacetOption, b: FacetOption) => { + switch (facetName) { + case 'price_range': + return comparePriceRangeFn(a.value, b.value); + case 'facet_tags.Capacity': + return compareCapacity(a.value, b.value); + case 'facet_tags.Item Type': + return compareAlphabetically(a.value, b.value); + default: + return compareFacetOptionByCountHighToLowAndAZ(a, b); + } + }; + +function compareFacetOptionByCountHighToLowAndAZ( + a: FacetOption, + b: FacetOption +) { + const countDiff = b.count - a.count; + if (countDiff !== 0) return countDiff; + + return compareAlphabetically(a.value, b.value); +} diff --git a/frontend/app/_data/product-list/concerns/hits.ts b/frontend/app/_data/product-list/concerns/hits.ts new file mode 100644 index 00000000..48ab9c47 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/hits.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export type ProductListType = + | 'parts' + | 'tools' + | 'device-parts' + | 'tools-category' + | 'marketing'; + +const PriceTierSchema = z.object({ + default_variant_price: z.union([z.string(), z.number()]), + min: z.union([z.string(), z.number()]), + max: z.union([z.string(), z.number()]), +}); + +export type PriceTier = z.infer; + +export const ProductSearchHitSchema = z.object({ + objectID: z.string(), + title: z.string(), + handle: z.string(), + price_float: z.number(), + compare_at_price: z.number().optional(), + price_tiers: z.record(PriceTierSchema).optional(), + sku: z.string(), + image_url: z.string(), + short_description: z.string().optional(), + quantity_available: z.number(), + lifetime_warranty: z.boolean(), + oem_partnership: z.string().nullable(), + rating: z.number(), + rating_count: z.number(), + url: z.string(), + is_pro: z.number(), +}); + +export type ProductSearchHit = z.infer; diff --git a/frontend/app/_data/product-list/concerns/queries.ts b/frontend/app/_data/product-list/concerns/queries.ts new file mode 100644 index 00000000..db00becc --- /dev/null +++ b/frontend/app/_data/product-list/concerns/queries.ts @@ -0,0 +1,49 @@ +import { ALGOLIA_PRODUCT_INDEX_NAME } from '@config/env'; +import { ProductListType } from '@models/product-list'; +import map from 'lodash/map'; +import { MultipleQueriesQuery } from './algolia'; + +export function createQuery( + query: string, + params: MultipleQueriesQuery['params'] +): MultipleQueriesQuery { + return { + indexName: ALGOLIA_PRODUCT_INDEX_NAME, + query, + params, + }; +} + +export function createFacetFilters(refinements: Record) { + return map(refinements, (values, facet) => { + return values.map((value) => `${facet}:${value}`); + }); +} + +export function createFilters({ + productListType, + baseFilters, + excludePro, +}: { + productListType: ProductListType; + baseFilters: string; + excludePro: boolean; +}) { + const filters: string[] = baseFilters ? [baseFilters] : []; + + filters.push('public=1'); + if (excludePro) { + filters.push('is_pro!=1'); + } + + switch (productListType) { + case ProductListType.AllParts: + filters.push("'facet_tags.Main Category': 'Parts'"); + break; + case ProductListType.AllTools: + filters.push("'facet_tags.Main Category': 'Tools'"); + break; + } + console.log('filters', filters); + return filters.join(' AND '); +} diff --git a/frontend/app/_data/product-list/concerns/sections/featured-product-lists-section.ts b/frontend/app/_data/product-list/concerns/sections/featured-product-lists-section.ts new file mode 100644 index 00000000..9ed866a2 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/sections/featured-product-lists-section.ts @@ -0,0 +1,38 @@ +import { filterNullableItems } from '@/helpers/application-helpers'; +import type { ProductListLinkedProductListSetSectionFieldsFragment } from '@/lib/strapi-sdk'; +import { z } from 'zod'; +import { + productListPreviewFromStrapi, + ProductListPreviewSchema, +} from '../component/product-list-preview'; + +export type FeaturedProductListsSection = z.infer< + typeof FeaturedProductListsSectionSchema +>; + +export const FeaturedProductListsSectionSchema = z.object({ + type: z.literal('FeaturedProductLists'), + id: z.string(), + title: z.string(), + productLists: z.array(ProductListPreviewSchema), +}); + +export function featuredProductListsSectionFromStrapi( + fragment: + | ProductListLinkedProductListSetSectionFieldsFragment + | null + | undefined, + sectionId: string +): FeaturedProductListsSection { + const productLists = filterNullableItems( + fragment?.productLists?.data.map(({ attributes }) => + productListPreviewFromStrapi(attributes) + ) + ); + return { + type: 'FeaturedProductLists', + id: sectionId, + title: fragment?.title ?? '', + productLists, + }; +} diff --git a/frontend/app/_data/product-list/concerns/sections/filterable-products-section.ts b/frontend/app/_data/product-list/concerns/sections/filterable-products-section.ts new file mode 100644 index 00000000..2704ebdf --- /dev/null +++ b/frontend/app/_data/product-list/concerns/sections/filterable-products-section.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export type FilterableProductsSection = z.infer< + typeof FilterableProductsSectionSchema +>; + +export const FilterableProductsSectionSchema = z.object({ + type: z.literal('FilterableProducts'), + id: z.string(), +}); + +export function filterableProductsSection(): FilterableProductsSection { + return { + type: 'FilterableProducts', + id: 'filterable-products', + }; +} diff --git a/frontend/app/_data/product-list/concerns/sections/hero-section.ts b/frontend/app/_data/product-list/concerns/sections/hero-section.ts new file mode 100644 index 00000000..8fd4549a --- /dev/null +++ b/frontend/app/_data/product-list/concerns/sections/hero-section.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export type HeroSection = z.infer; + +export const HeroSectionSchema = z.object({ + type: z.literal('Hero'), + id: z.string(), +}); + +export function heroSection(): HeroSection { + return { + type: 'Hero', + id: 'hero', + }; +} diff --git a/frontend/app/_data/product-list/concerns/sections/index.ts b/frontend/app/_data/product-list/concerns/sections/index.ts new file mode 100644 index 00000000..091d431e --- /dev/null +++ b/frontend/app/_data/product-list/concerns/sections/index.ts @@ -0,0 +1,151 @@ +import { filterNullableItems } from '@/helpers/application-helpers'; +import type { ProductListFieldsFragment } from '@/lib/strapi-sdk'; +import { BannersSectionSchema } from '@/models/concerns/sections/banners-section'; +import { FAQsSectionSchema } from '@/models/concerns/sections/faqs-section'; +import { + lifetimeWarrantySectionFromStrapi, + LifetimeWarrantySectionSchema, +} from '@/models/concerns/sections/lifetime-warranty-section'; +import { PressQuotesSectionSchema } from '@/models/concerns/sections/press-quotes-section'; +import { QuoteGallerySectionSchema } from '@/models/concerns/sections/quote-gallery-section'; +import { + relatedPostsSectionFromStrapi, + RelatedPostsSectionSchema, +} from '@/models/concerns/sections/related-posts-section'; +import { SplitWithImageSectionSchema } from '@/models/concerns/sections/split-with-image-section'; +import { createSectionId } from '@/models/concerns/strapi-utils'; +import type { ReusableSection } from '@/models/reusable-section'; +import groupBy from 'lodash/groupBy'; +import mapValues from 'lodash/mapValues'; +import orderBy from 'lodash/orderBy'; +import { z } from 'zod'; +import { + featuredProductListsSectionFromStrapi, + FeaturedProductListsSectionSchema, +} from './featured-product-lists-section'; +import { + filterableProductsSection, + FilterableProductsSectionSchema, +} from './filterable-products-section'; +import { heroSection, HeroSectionSchema } from './hero-section'; +import { + productListChildrenSection, + ProductListChildrenSectionSchema, +} from './product-list-children-section'; + +export type ProductListSection = z.infer; + +export const ProductListSectionSchema = z.union([ + HeroSectionSchema, + ProductListChildrenSectionSchema, + FilterableProductsSectionSchema, + LifetimeWarrantySectionSchema, + RelatedPostsSectionSchema, + FeaturedProductListsSectionSchema, + BannersSectionSchema, + SplitWithImageSectionSchema, + QuoteGallerySectionSchema, + PressQuotesSectionSchema, + FAQsSectionSchema, +]); + +interface ProductListSectionsArgs { + strapiProductList: ProductListFieldsFragment | null | undefined; + reusableSections: ReusableSection[]; +} + +export function productListSections({ + strapiProductList, + reusableSections, +}: ProductListSectionsArgs) { + const sections: ProductListSection[] = [ + heroSection(), + productListChildrenSection(), + filterableProductsSection(), + ]; + const sectionsByPosition = getSectionsBucketByPosition(reusableSections); + + insertSections({ + from: sectionsByPosition['top'] ?? [], + into: sections, + after: 'top', + }); + + insertSections({ + from: sectionsByPosition['after products'] ?? [], + into: sections, + after: 'FilterableProducts', + }); + + const strapiSections = filterNullableItems( + strapiProductList?.sections.map(productListSection) + ); + insertSections({ + from: strapiSections, + into: sections, + after: 'bottom', + }); + + insertSections({ + from: sectionsByPosition['bottom'] ?? [], + into: sections, + after: 'bottom', + }); + + return sections; +} + +interface InsertSectionsArgs { + into: ProductListSection[]; + from: ProductListSection[]; + after: 'top' | 'bottom' | ProductListSection['type']; +} + +function insertSections({ into, from, after }: InsertSectionsArgs): void { + if (after === 'top') { + into.splice(0, 0, ...from); + } else if (after === 'bottom') { + into.push(...from); + } else { + const index = into.findIndex((section) => section.type === after); + if (index !== -1) { + into.splice(index + 1, 0, ...from); + } + } +} + +function getSectionsBucketByPosition( + reusableSections: ReusableSection[] +): Record { + const sectionsByPosition = groupBy( + reusableSections, + 'positionInProductList' + ) as Record; + const sortedReusableSectionsByPosition = mapValues( + sectionsByPosition, + (sections): ProductListSection[] => + orderBy(sections, 'priority', 'desc').map((section) => section.section) + ); + return sortedReusableSectionsByPosition; +} + +function productListSection( + strapiSection: ProductListFieldsFragment['sections'][number] +): ProductListSection | null { + if (strapiSection == null) return null; + + const sectionId = createSectionId(strapiSection); + + if (sectionId == null) return null; + + switch (strapiSection.__typename) { + case 'ComponentProductListBanner': + return lifetimeWarrantySectionFromStrapi(strapiSection, sectionId); + case 'ComponentProductListRelatedPosts': + return relatedPostsSectionFromStrapi(strapiSection, sectionId); + case 'ComponentProductListLinkedProductListSet': + return featuredProductListsSectionFromStrapi(strapiSection, sectionId); + default: + return null; + } +} diff --git a/frontend/app/_data/product-list/concerns/sections/product-list-children-section.ts b/frontend/app/_data/product-list/concerns/sections/product-list-children-section.ts new file mode 100644 index 00000000..a4780638 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/sections/product-list-children-section.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export type ProductListChildrenSection = z.infer< + typeof ProductListChildrenSectionSchema +>; + +export const ProductListChildrenSectionSchema = z.object({ + type: z.literal('ProductsListChildren'), + id: z.string(), +}); + +export function productListChildrenSection(): ProductListChildrenSection { + return { + type: 'ProductsListChildren', + id: 'product-list-children', + }; +} diff --git a/frontend/app/_data/product-list/concerns/sections/reusable-sections.ts b/frontend/app/_data/product-list/concerns/sections/reusable-sections.ts new file mode 100644 index 00000000..a69a18a0 --- /dev/null +++ b/frontend/app/_data/product-list/concerns/sections/reusable-sections.ts @@ -0,0 +1,155 @@ +import { filterFalsyItems } from '@/helpers/application-helpers'; +import type { ComponentMiscPlacementFiltersInput } from '@/lib/strapi-sdk'; +import { ProductListFieldsFragment } from '@/lib/strapi-sdk'; +import { + compareFAQs, + FAQ, + faqFromStrapi, +} from '@/models/concerns/components/faq'; +import type { ReusableSection } from '@/models/reusable-section'; +import { findReusableSections } from '@/models/reusable-section/find'; +import type { Dictionary } from 'lodash'; +import keyBy from 'lodash/keyBy'; +import uniqBy from 'lodash/uniqBy'; + +interface FindProductListReusableSectionsArgs { + strapiProductList: ProductListFieldsFragment | null | undefined; + ancestorHandles: string[]; +} + +export async function findProductListReusableSections({ + strapiProductList, + ancestorHandles, +}: FindProductListReusableSectionsArgs): Promise { + const reusableSections = await findReusableSections({ + filters: { + placement: { + or: getPlacementConditions({ + strapiProductList, + ancestorHandles, + }), + }, + }, + }); + + return reusableSections.map((reusableSection) => { + switch (reusableSection.section.type) { + case 'FAQs': { + const ancestryFaqsByCategory = + getAncestryFAQsByCategory(strapiProductList); + const ancestryFaqsWithoutCategory = + getAncestryFAQsWithoutCategory(strapiProductList); + + const sectionFaqsByCategory = keyBy( + reusableSection.section.faqs.filter( + (faq) => faq.category != null + ), + 'category' + ); + const sectionFaqsWithoutCategory = + reusableSection.section.faqs.filter( + (faq) => faq.category == null + ); + + const allFaqsByCategory = { + ...ancestryFaqsByCategory, + ...sectionFaqsByCategory, + }; + + reusableSection.section.faqs = uniqBy( + [ + ...ancestryFaqsWithoutCategory, + ...sectionFaqsWithoutCategory, + ...Object.values(allFaqsByCategory), + ], + 'id' + ).sort(compareFAQs); + + return reusableSection; + } + default: + return reusableSection; + } + }); +} + +function getPlacementConditions({ + strapiProductList, + ancestorHandles, +}: FindProductListReusableSectionsArgs): ComponentMiscPlacementFiltersInput[] { + const conditions: ComponentMiscPlacementFiltersInput[] = [ + { + showInProductListPages: { + eq: 'only descendants', + }, + productLists: { + handle: { + in: ancestorHandles, + }, + }, + }, + ]; + if (strapiProductList) { + conditions.push({ + showInProductListPages: { + eq: 'only selected', + }, + productLists: { + handle: { + eq: strapiProductList.handle, + }, + }, + }); + + conditions.push({ + showInProductListPages: { + eq: 'selected and descendants', + }, + productLists: { + handle: { + in: ancestorHandles.concat(strapiProductList.handle), + }, + }, + }); + } + return conditions; +} + +type ProductListWithFAQs = Pick & { + parent?: ProductListFieldsFragment['parent'] | null | undefined; +}; + +function getAncestryFAQsByCategory( + productList: ProductListWithFAQs | null | undefined +): Dictionary { + if (productList == null) { + return {}; + } + const ancestryFaqByCategory = getAncestryFAQsByCategory( + productList.parent?.data?.attributes + ); + + const faqs = filterFalsyItems(productList.faqs?.data?.map(faqFromStrapi)); + const faqsWithCategory = faqs.filter((faq) => faq.category != null); + + return { + ...ancestryFaqByCategory, + ...keyBy(faqsWithCategory, 'category'), + }; +} + +function getAncestryFAQsWithoutCategory( + productList: ProductListWithFAQs | null | undefined +): FAQ[] { + if (productList == null) { + return []; + } + const ancestryFaqsWithoutCategory = getAncestryFAQsWithoutCategory( + productList.parent?.data?.attributes + ); + + const faqs = filterFalsyItems(productList.faqs?.data?.map(faqFromStrapi)); + const faqsWithoutCategory = faqs.filter((faq) => faq.category == null); + + return ancestryFaqsWithoutCategory.concat(faqsWithoutCategory); +} diff --git a/frontend/app/_data/product-list/index.tsx b/frontend/app/_data/product-list/index.tsx new file mode 100644 index 00000000..1eb322a5 --- /dev/null +++ b/frontend/app/_data/product-list/index.tsx @@ -0,0 +1,79 @@ +import { ProductListType } from '@models/product-list'; +import { + MultipleQueriesQuery, + SearchResponse, + createClient, +} from './concerns/algolia'; +import { SUPPORTED_FACETS, createFacets } from './concerns/facets'; +import type { ProductSearchHit } from './concerns/hits'; +import { + createFacetFilters, + createFilters, + createQuery, +} from './concerns/queries'; + +const HITS_PER_PAGE = 24; + +export interface AlgoliaSearchOptions { + productListType: ProductListType; + baseFilters?: string; + query: string; + page: number; + refinements: Record; + excludePro?: boolean; +} + +export type AlgoliaSearchResult = Awaited>; + +export async function search({ + productListType, + baseFilters = '', + query, + page, + refinements, + excludePro = true, +}: AlgoliaSearchOptions) { + const client = createClient(); + + const baseParams: MultipleQueriesQuery['params'] = { + filters: createFilters({ productListType, baseFilters, excludePro }), + facetingAfterDistinct: true, + }; + + const refinedFacetNames = Object.keys(refinements); + const response = await client.multipleQueries([ + createQuery(query, { + ...baseParams, + facetFilters: createFacetFilters(refinements), + facets: SUPPORTED_FACETS, + hitsPerPage: HITS_PER_PAGE, + }), + ...refinedFacetNames.map((facetName): MultipleQueriesQuery => { + const { [facetName]: _, ...otherFacets } = refinements; + return createQuery(query, { + ...baseParams, + facets: [facetName], + facetFilters: createFacetFilters(otherFacets), + hitsPerPage: 0, + }); + }), + ]); + + const results = response.results as SearchResponse[]; + + const facets = createFacets({ + productListType, + results, + refinements, + }); + + const hits = results[0].hits as ProductSearchHit[]; + + return { + hits, + hitsCount: results[0].nbHits, + facets, + page, + query, + }; +} diff --git a/frontend/app/_data/product-list/useAlgoliaSearch.tsx b/frontend/app/_data/product-list/useAlgoliaSearch.tsx new file mode 100644 index 00000000..5ff5b0e7 --- /dev/null +++ b/frontend/app/_data/product-list/useAlgoliaSearch.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { SentryError } from '@ifixit/sentry'; +import React from 'react'; +import { AlgoliaSearchResult } from '.'; + +const AlgoliaSearchContext = React.createContext( + null +); + +export function AlgoliaSearchProvider({ + hits, + hitsCount, + facets, + page, + query, + children, +}: React.PropsWithChildren) { + const value = React.useMemo( + (): AlgoliaSearchResult => ({ + hits, + hitsCount, + facets, + page, + query, + }), + [hits, hitsCount, facets, page, query] + ); + + return ( + + {children} + + ); +} + +export function useAlgoliaSearch() { + const context = React.useContext(AlgoliaSearchContext); + if (context == null) { + throw new SentryError( + 'useAlgoliaSearch must be used within an AlgoliaSearchProvider' + ); + } + return context; +} diff --git a/frontend/app/_helpers/product-list-helpers.ts b/frontend/app/_helpers/product-list-helpers.ts new file mode 100644 index 00000000..85222cfb --- /dev/null +++ b/frontend/app/_helpers/product-list-helpers.ts @@ -0,0 +1,46 @@ +import { + stylizeDeviceItemType, + stylizeDeviceTitle, +} from '@helpers/product-list-helpers'; +import { useFacets } from '@templates/product-list/sections/FilterableProductsSection/facets/useFacets'; + +export function getDeviceCanonicalPath( + deviceTitle: string | null | undefined, + itemType: string | null +) { + if (deviceTitle == null) { + return null; + } + const slug = itemType + ? `/${encodeURIComponent(stylizeDeviceItemType(itemType))}` + : ''; + const canonicalDeviceHandle = encodeURIComponent( + stylizeDeviceTitle(deviceTitle) + ); + return `/Parts/${canonicalDeviceHandle}${slug}`; +} + +export interface ProductListPageSearchParams { + p?: string; + q?: string; + [key: string]: string | string[] | undefined; +} + +export function parseSearchParams(searchParams: ProductListPageSearchParams) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const facets = useFacets(); + const page = searchParams.p ? parseInt(searchParams.p) : 1; + const query = searchParams.q ?? ''; + const refinements: Record = {}; + + facets.forEach((facet) => { + const filterValues = searchParams[facet]; + if (filterValues) { + refinements[facet] = Array.isArray(filterValues) + ? filterValues + : [filterValues]; + } + }); + + return { page, query, refinements }; +} diff --git a/frontend/config/flags.ts b/frontend/config/flags.ts index ec8a8c61..1ee5ff94 100644 --- a/frontend/config/flags.ts +++ b/frontend/config/flags.ts @@ -12,4 +12,9 @@ export const flags = { process.env.NEXT_PUBLIC_FLAG__APP_ROUTER_PRODUCT_PAGE_ENABLED === 'true', APP_ROUTER_PARTS_PAGE_ENABLED: process.env.NEXT_PUBLIC_FLAG__APP_ROUTER_PARTS_PAGE_ENABLED === 'true', + APP_ROUTER_TOOLS_PAGE_ENABLED: + process.env.NEXT_PUBLIC_FLAG__APP_ROUTER_TOOLS_PAGE_ENABLED === 'true', + APP_ROUTER_MARKETING_PAGE_ENABLED: + process.env.NEXT_PUBLIC_FLAG__APP_ROUTER_MARKETING_PAGE_ENABLED === + 'true', }; diff --git a/frontend/helpers/algolia-helpers.ts b/frontend/helpers/algolia-helpers.ts index 4a70fd7c..a0a5052d 100644 --- a/frontend/helpers/algolia-helpers.ts +++ b/frontend/helpers/algolia-helpers.ts @@ -3,7 +3,6 @@ import { AlgoliaSearchOptions } from 'algoliasearch'; import { createNodeHttpRequester } from '@algolia/requester-node-http'; import { Requester, Request, Response } from '@algolia/requester-common'; import { timeAsync } from '@ifixit/helpers'; -import { useMenu } from 'react-instantsearch'; const FACETS_NAME_OVERRIDES: { [rawName: string]: string } = { price_range: 'Price Range', @@ -66,11 +65,3 @@ export function formatFacetName(algoliaName: string): string { export function escapeFilterValue(value: string) { return value.replaceAll("'", "\\'").replaceAll('"', '\\"'); } - -/** - * This temporary hack allows to correctly populate the itemType facet during SSR - * see: https://github.com/algolia/instantsearch/issues/5571 - */ -export function AlgoliaAttributeSSRHack(attributeName: string) { - useMenu({ attribute: attributeName }); -} diff --git a/frontend/lib/shopify-storefront-sdk/generated/sdk.ts b/frontend/lib/shopify-storefront-sdk/generated/sdk.ts index 13f10a07..471fad62 100644 --- a/frontend/lib/shopify-storefront-sdk/generated/sdk.ts +++ b/frontend/lib/shopify-storefront-sdk/generated/sdk.ts @@ -4796,6 +4796,7 @@ export type MenuItemResource = | Article | Blog | Collection + | Metaobject | Page | Product | ShopPolicy; @@ -4816,6 +4817,8 @@ export enum MenuItemType { Frontpage = 'FRONTPAGE', /** An http link. */ Http = 'HTTP', + /** A metaobject page link. */ + Metaobject = 'METAOBJECT', /** A page link. */ Page = 'PAGE', /** A product link. */ @@ -6333,6 +6336,8 @@ export type ProductSellingPlanGroupsArgs = { * */ export type ProductVariantBySelectedOptionsArgs = { + caseInsensitiveMatch?: InputMaybe; + ignoreUnknownOptions?: InputMaybe; selectedOptions: Array; }; @@ -6571,6 +6576,8 @@ export type ProductVariant = HasMetafields & sku?: Maybe; /** The in-store pickup availability of this variant by location. */ storeAvailability: StoreAvailabilityConnection; + /** Whether tax is charged when the product variant is sold. */ + taxable: Scalars['Boolean']; /** The product variant’s title. */ title: Scalars['String']; /** The unit price value for the variant based on the variant's measurement. */ diff --git a/frontend/package.json b/frontend/package.json index 1e164ac9..3a16cf1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "playwright:debug": "DEBUG=pw:api playwright test --project=\"Desktop Chrome\" --debug" }, "dependencies": { + "@algolia/requester-fetch": "4.22.1", "@chakra-ui/react": "2.8.1", "@core-ds/primitives": "2.5.2", "@emotion/react": "11.10.5", diff --git a/frontend/templates/product-list/ProductListView.tsx b/frontend/templates/product-list/ProductListView.tsx index bd7519e7..9f44c822 100644 --- a/frontend/templates/product-list/ProductListView.tsx +++ b/frontend/templates/product-list/ProductListView.tsx @@ -1,94 +1,77 @@ +'use client'; + import { BannersSection } from '@components/sections/BannersSection'; import { FAQsSection } from '@components/sections/FAQsSection'; import { LifetimeWarrantySection } from '@components/sections/LifetimeWarrantySection'; import { QuoteGallerySection } from '@components/sections/QuoteGallerySection'; import { SplitWithImageContentSection } from '@components/sections/SplitWithImageSection'; -import { - computeProductListAlgoliaFilterPreset, - computeProductListAlgoliaOptionalFilters, -} from '@helpers/product-list-helpers'; import type { ProductList } from '@models/product-list'; +import { FilterableProductsSection } from '@templates/product-list/sections/index'; import { PressQuotesSection } from '@templates/page/sections/PressQuotesSection'; -import { Configure } from 'react-instantsearch'; import { useAvailableItemTypes } from './hooks/useAvailableItemTypes'; -import { useCurrentProductList } from './hooks/useCurrentProductList'; -import { MetaTags } from './MetaTags'; -import { SecondaryNavigation } from './SecondaryNavigation'; import { - FeaturedProductListsSection, - FilterableProductsSection, HeroSection, ProductListChildrenSection, RelatedPostsSection, } from './sections'; -import { AlgoliaAttributeSSRHack } from '@helpers/algolia-helpers'; - -const HITS_PER_PAGE = 24; export interface ProductListViewProps { productList: ProductList; - algoliaSSR?: boolean; } -export function ProductListView({ - productList, - algoliaSSR, -}: ProductListViewProps) { - AlgoliaAttributeSSRHack('facet_tags.Item Type'); +export function ProductListView({ productList }: ProductListViewProps) { + // useMenu({ attribute: 'facet_tags.Item Type' }); - const filters = computeProductListAlgoliaFilterPreset(productList); - const optionalFilters = - computeProductListAlgoliaOptionalFilters(productList); - const { currentProductList } = useCurrentProductList(); + // const filters = computeProductListAlgoliaFilterPreset(productList); + // const optionalFilters = + // computeProductListAlgoliaOptionalFilters(productList); + // const { currentProductList } = useCurrentProductList(); const availableItemTypes = useAvailableItemTypes(); - if (algoliaSSR) { - return ( - <> - - - - ); - } + // if (algoliaSSR) { + // return ( + // <> + // + // + // + // ); + // } return ( <> - - - + /> */} + {/* */} + {/* */}
- {currentProductList.sections.map((section) => { + {productList.sections.map((section) => { switch (section.type) { case 'Hero': { return ( ); } case 'ProductsListChildren': { if (productList.children.length === 0) return null; - return ( ); } - case 'FeaturedProductLists': { - const { title, productLists } = section; - if (productLists.length > 0) { - return ( - - ); - } - return null; - } + // case 'FeaturedProductLists': { + // const { title, productLists } = section; + // if (productLists.length > 0) { + // return ( + // + // ); + // } + // return null; + // } case 'Banners': { return ( { - const itemTypeFacets = results?.hierarchicalFacets.find( + const itemTypeFacets = facets.find( (facet) => facet.name === 'facet_tags.Item Type' ); if (!itemTypeFacets) return []; - return itemTypeFacets.data?.map((facet) => facet.name) ?? []; - }, [results]); + return ( + itemTypeFacets.options?.map((facetOption) => facetOption.value) ?? [] + ); + }, [facets]); } diff --git a/frontend/templates/product-list/hooks/useCurrentProductList.tsx b/frontend/templates/product-list/hooks/useCurrentProductList.tsx index d96476d9..e108954d 100644 --- a/frontend/templates/product-list/hooks/useCurrentProductList.tsx +++ b/frontend/templates/product-list/hooks/useCurrentProductList.tsx @@ -1,9 +1,9 @@ +import { SentryError } from '@ifixit/sentry'; import { ProductList } from '@models/product-list'; -import { useVariantProductList } from './useVariantProductList'; -import { useItemTypeProductList } from './useItemTypeProductList'; import React, { PropsWithChildren } from 'react'; -import { SentryError } from '@ifixit/sentry'; -import { AlgoliaAttributeSSRHack } from '@helpers/algolia-helpers'; +import { useMenu } from 'react-instantsearch'; +import { useItemTypeProductList } from './useItemTypeProductList'; +import { useVariantProductList } from './useVariantProductList'; /** * Used to modify product list data before rendering @@ -28,7 +28,7 @@ export const CurrentProductListProvider = ({ algoliaUrl, children, }: CurrentProductListProviderProps) => { - AlgoliaAttributeSSRHack('worksin'); + useMenu({ attribute: 'worksin' }); const variantUpdatedProductList = useVariantProductList( productList, diff --git a/frontend/templates/product-list/hooks/useDevicePartsItemType.ts b/frontend/templates/product-list/hooks/useDevicePartsItemType.ts index d553e209..f820c3d0 100644 --- a/frontend/templates/product-list/hooks/useDevicePartsItemType.ts +++ b/frontend/templates/product-list/hooks/useDevicePartsItemType.ts @@ -1,6 +1,6 @@ import { ProductListType } from '@models/product-list'; +import { useAlgoliaSearch } from 'app/_data/product-list/useAlgoliaSearch'; import * as React from 'react'; -import { useCurrentRefinements } from 'react-instantsearch'; type ProductListAttributes = { type?: ProductListType | null; @@ -9,14 +9,14 @@ type ProductListAttributes = { export function useDevicePartsItemType( productList: T ) { - const { items } = useCurrentRefinements(); + const { facets } = useAlgoliaSearch(); const algoliaItemType = React.useMemo(() => { - const itemTypeRefinement = items.find( - (refinementItem) => refinementItem.attribute === 'facet_tags.Item Type' + const itemTypeRefinement = facets.find( + (refinementItem) => refinementItem.name === 'facet_tags.Item Type' ); // `Item Type` is a single select, so just use the first value if it exists. - return itemTypeRefinement?.refinements[0]?.value; - }, [items]); + return itemTypeRefinement?.options[0]?.value; + }, [facets]); const itemType = productList.type === ProductListType.DeviceParts && algoliaItemType; return itemType ? String(itemType) : undefined; diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/facets/MenuFacet.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/facets/MenuFacet.tsx index 3018b174..0201ea28 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/facets/MenuFacet.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/facets/MenuFacet.tsx @@ -9,21 +9,19 @@ import { } from '@chakra-ui/react'; import { formatFacetName } from '@helpers/algolia-helpers'; import { invariant } from '@helpers/application-helpers'; +import { FacetOption } from 'app/_data/product-list/concerns/facets'; import React, { MouseEventHandler } from 'react'; import { ShowMoreButton } from './ShowMoreButton'; -import type { MenuFacetState } from './useMenuFacet'; - -type MenuItem = MenuFacetState['items'][0]; export type MenuFacetProps = { attribute: string; - items: MenuItem[]; + items: FacetOption[]; limit: number; - onItemClick?: (value: string) => void; - canToggleShowMore?: boolean; - isShowingMore?: boolean; - onShowMore?: () => void; - createItemURL?: (item: MenuItem) => string; + // onItemClick?: (value: string) => void; + // canToggleShowMore?: boolean; + // isShowingMore?: boolean; + // onShowMore?: () => void; + // createItemURL?: (item: MenuItem) => string; }; export function MenuFacet(props: MenuFacetProps) { @@ -48,13 +46,13 @@ export function MenuFacet(props: MenuFacetProps) { > {firstItems.map((item) => ( ))} {additionalItems.length > 0 && ( @@ -66,13 +64,13 @@ export function MenuFacet(props: MenuFacetProps) { > {additionalItems.map((item) => ( ))} @@ -89,7 +87,7 @@ export function MenuFacet(props: MenuFacetProps) { } type UseShowMoreProps = { - items: MenuItem[]; + items: FacetOption[]; limit: number; canToggleShowMore?: boolean; isShowingMore?: boolean; diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/facets/RefinementListFacet.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/facets/RefinementListFacet.tsx index bc3aeb2e..de7a353a 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/facets/RefinementListFacet.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/facets/RefinementListFacet.tsx @@ -1,17 +1,17 @@ import { Box, Checkbox, HStack, Text, VStack } from '@chakra-ui/react'; import { formatFacetName } from '@helpers/algolia-helpers'; import { useDecoupledState } from '@ifixit/ui'; +import { FacetOption } from 'app/_data/product-list/concerns/facets'; import * as React from 'react'; import { ShowMoreButton } from './ShowMoreButton'; import { RefinementListFacetState } from './useRefinementListFacet'; +const MAX_VISIBLE_OPTIONS = 10; + type RefinementListFacetProps = { attribute: string; - items: RefinementListItem[]; + items: FacetOption[]; refine: (value: string) => void; - canToggleShowMore?: boolean; - isShowingMore?: boolean; - onToggleShowMore?: () => void; }; type RefinementListItem = RefinementListFacetState['items'][0]; @@ -20,11 +20,11 @@ export function RefinementListFacet({ attribute, items, refine, - canToggleShowMore, - isShowingMore = false, - onToggleShowMore, }: RefinementListFacetProps) { const formattedFacetName = formatFacetName(attribute); + const visibleOptions = items.slice(0, MAX_VISIBLE_OPTIONS); + const hiddenOptions = items.slice(MAX_VISIBLE_OPTIONS); + const hasHiddenOptions = hiddenOptions.length > 0; return ( @@ -34,28 +34,25 @@ export function RefinementListFacet({ role="listbox" aria-label={`${formattedFacetName} options`} > - {items.map((item) => { + {visibleOptions.map((item) => { return ( ); })} - {canToggleShowMore && ( - + {hasHiddenOptions && ( + {}} /> )} ); } type RefinementListItemProps = { - item: RefinementListItem; + item: FacetOption; refine: (value: string) => void; }; @@ -63,10 +60,10 @@ const RefinementListItem = React.memo(function RefinementListItem({ item, refine, }: RefinementListItemProps) { - const [isRefined, setIsRefined] = useDecoupledState(item.isRefined); + const [isRefined, setIsRefined] = useDecoupledState(item.selected); return ( - + - {item.label} + {item.value} void; }; export function FacetMenuAccordionItem({ - attribute, productList, + facet, isExpanded, - refinedCount, - onFacetValueClick, }: FacetMenuAccordionItemProps) { - const isDevicePartsItemType = - attribute === 'facet_tags.Item Type' && - productList.type === ProductListType.DeviceParts; - - const { - items, - refine, - canLoadMore, - canToggleShowMore, - isShowingMore, - toggleShowMore, - hasApplicableRefinements, - } = useMenuFacet({ attribute, productList }); + // const isDevicePartsItemType = + // facet.name === 'facet_tags.Item Type' && + // productList.type === ProductListType.DeviceParts; const { isDisabled, isHidden } = useFacetAccordionItemState({ - attribute, - hasApplicableRefinements, + attribute: facet.name, + hasApplicableRefinements: facet.options.length > 0, productList, }); - const createItemURL = useCreateItemTypeURL(); + // const createItemURL = useCreateItemTypeURL(); - const handleItemClick = useCallback( - (value: string) => { - refine(value); - onFacetValueClick?.(value); - }, - [onFacetValueClick, refine] - ); + // const handleItemClick = useCallback( + // (value: string) => { + // refine(value); + // onFacetValueClick?.(value); + // }, + // [onFacetValueClick, refine] + // ); return ( ); diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/FacetRefinementListAccordionItem.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/FacetRefinementListAccordionItem.tsx index 457f1cfd..f81f8dbb 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/FacetRefinementListAccordionItem.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/FacetRefinementListAccordionItem.tsx @@ -1,52 +1,39 @@ import { ProductList } from '@models/product-list'; +import { Facet } from 'app/_data/product-list/concerns/facets'; import { RefinementListFacet } from '../RefinementListFacet'; -import { useRefinementListFacet } from '../useRefinementListFacet'; import { FacetAccordionItem } from './FacetAccordionItem'; import { useFacetAccordionItemState } from './useFacetAccordionItemState'; type FacetMenuAccordionItemProps = { - attribute: string; productList: ProductList; + facet: Facet; isExpanded: boolean; - refinedCount: number; }; export function FacetRefinementListAccordionItem({ - attribute, productList, + facet, isExpanded, - refinedCount, }: FacetMenuAccordionItemProps) { - const { - items, - refine, - canToggleShowMore, - isShowingMore, - toggleShowMore, - hasApplicableRefinements, - } = useRefinementListFacet({ attribute }); const { isDisabled, isHidden } = useFacetAccordionItemState({ - attribute, - hasApplicableRefinements, + attribute: facet.name, + hasApplicableRefinements: facet.options.length > 0, productList, }); return ( {}} /> ); diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/index.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/index.tsx index 31499df6..1f95d7d8 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/index.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/index.tsx @@ -1,12 +1,10 @@ import { Accordion } from '@chakra-ui/react'; -import { getFacetWidgetType } from '@helpers/product-list-helpers'; -import { assertNever } from '@ifixit/helpers'; -import { FacetWidgetType, ProductList } from '@models/product-list'; +import { ProductList } from '@models/product-list'; +import { Facet } from 'app/_data/product-list/concerns/facets'; import * as React from 'react'; -import { useCountRefinements } from '../useCountRefinements'; import { useFilteredFacets } from '../useFacets'; -import { FacetMenuAccordionItem } from './FacetMenuAccordionItem'; import { FacetRefinementListAccordionItem } from './FacetRefinementListAccordionItem'; +import { FacetMenuAccordionItem } from './FacetMenuAccordionItem'; type FacetsAccordianProps = { productList: ProductList; @@ -19,11 +17,10 @@ const initialExpandedFacets = [ export function FacetsAccordion({ productList }: FacetsAccordianProps) { const facets = useFilteredFacets(productList); - const countRefinements = useCountRefinements(); const [indexes, setIndexes] = React.useState(() => { return initialExpandedFacets .map((expandedFacet) => - facets.findIndex((facet) => facet === expandedFacet) + facets.findIndex((facet) => facet.name === expandedFacet) ) .filter((index) => index >= 0); }); @@ -54,16 +51,14 @@ export function FacetsAccordion({ productList }: FacetsAccordianProps) { }} > {facets.map((facet, facetIndex) => { - const facetAttributes = [facet]; - if (facet === 'price_range') { + const facetAttributes = [facet.name]; + if (facet.name === 'price_range') { facetAttributes.push('facet_tags.Price'); } - const refinedCount = countRefinements(facetAttributes); return ( @@ -74,42 +69,30 @@ export function FacetsAccordion({ productList }: FacetsAccordianProps) { } type AccordionItemProps = { - attribute: string; - refinedCount: number; productList: ProductList; + facet: Facet; isExpanded: boolean; }; export const AccordionItem = ({ - attribute, - refinedCount, productList, + facet, isExpanded, }: AccordionItemProps) => { - const widgetType = getFacetWidgetType(attribute); - - switch (widgetType) { - case FacetWidgetType.Menu: { - return ( - - ); - } - case FacetWidgetType.RefinementList: { - return ( - - ); - } - default: - return assertNever(widgetType); + if (facet.multiple) { + return ( + + ); } + return ( + + ); }; diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/useFacetAccordionItemState.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/useFacetAccordionItemState.tsx index 630194f7..e0baaf32 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/useFacetAccordionItemState.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/facets/accordion/useFacetAccordionItemState.tsx @@ -1,5 +1,5 @@ import { ProductList, ProductListType } from '@models/product-list'; -import { useHits } from 'react-instantsearch'; +import { useAlgoliaSearch } from 'app/_data/product-list/useAlgoliaSearch'; export type UseFacetAccordionItemProps = { attribute: string; @@ -12,7 +12,7 @@ export function useFacetAccordionItemState({ hasApplicableRefinements, productList, }: UseFacetAccordionItemProps) { - const { hits } = useHits(); + const { hits } = useAlgoliaSearch(); const isProductListEmpty = hits.length === 0; const isDisabled = isProductListEmpty || !hasApplicableRefinements; diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/facets/useCountRefinements.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/facets/useCountRefinements.tsx index 1ca94e58..0cae2ccc 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/facets/useCountRefinements.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/facets/useCountRefinements.tsx @@ -1,11 +1,13 @@ -import { useCurrentRefinements } from 'react-instantsearch'; +import { useAlgoliaSearch } from 'app/_data/product-list/useAlgoliaSearch'; export function useCountRefinements() { - const currentRefinements = useCurrentRefinements(); + const { facets } = useAlgoliaSearch(); + const currentRefinements = facets.filter((f) => f.selectedCount > 0); + return (attributes: string[]) => { - return currentRefinements.items.reduce((acc, item) => { - if (attributes.includes(item.attribute)) { - return acc + item.refinements.length; + return currentRefinements.reduce((acc, item) => { + if (attributes.includes(item.name)) { + return acc + item.selectedCount; } return acc; }, 0); diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/facets/useFacets.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/facets/useFacets.tsx index d0c99f23..8747fd73 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/facets/useFacets.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/facets/useFacets.tsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { ProductList, ProductListType } from '@models/product-list'; import { formatFacetName } from '@helpers/algolia-helpers'; +import { useAlgoliaSearch } from 'app/_data/product-list/useAlgoliaSearch'; +import { Facet } from 'app/_data/product-list/concerns/facets'; export function useFacets() { return [ @@ -19,7 +21,7 @@ export function useFacets() { } export function useFilteredFacets(productList: ProductList) { - const facets = useFacets(); + const { facets } = useAlgoliaSearch(); const infoNames = React.useMemo(() => { return new Set( @@ -35,7 +37,7 @@ export function useFilteredFacets(productList: ProductList) { const usefulFacets = facets .slice() .filter((facet) => { - return facet !== 'device' && !infoNames.has(facet); + return facet.name !== 'device' && !infoNames.has(facet.name); }) .sort(sortBy); return usefulFacets; @@ -56,12 +58,14 @@ export function useFilteredFacets(productList: ProductList) { 'facet_tags.OS', 'facet_tags.Part or Kit', ]; - return facets.filter((facet) => !excludedToolsFacets.includes(facet)); + return facets.filter( + (facet) => !excludedToolsFacets.includes(facet.name) + ); } const excludedPartsAndMarketingFacets = ['price_range']; return usefulFacets.filter( - (facet) => !excludedPartsAndMarketingFacets.includes(facet) + (facet) => !excludedPartsAndMarketingFacets.includes(facet.name) ); } @@ -91,14 +95,14 @@ function getFacetComparator(productListType: ProductListType) { } function sortFacetsWithRanking(ranking: Map) { - return (a: string, b: string): number => { - const aRank = ranking.get(a) || 0; - const bRank = ranking.get(b) || 0; + return (a: Facet, b: Facet): number => { + const aRank = ranking.get(a.name) || 0; + const bRank = ranking.get(b.name) || 0; return bRank - aRank || sortFacetsAlphabetically(a, b); }; } const enCollator = new Intl.Collator('en'); -function sortFacetsAlphabetically(a: string, b: string) { - return enCollator.compare(formatFacetName(a), formatFacetName(b)); +function sortFacetsAlphabetically(a: Facet, b: Facet) { + return enCollator.compare(formatFacetName(a.name), formatFacetName(b.name)); } diff --git a/frontend/templates/product-list/sections/FilterableProductsSection/index.tsx b/frontend/templates/product-list/sections/FilterableProductsSection/index.tsx index 9b7ac4b8..f63f6d4d 100644 --- a/frontend/templates/product-list/sections/FilterableProductsSection/index.tsx +++ b/frontend/templates/product-list/sections/FilterableProductsSection/index.tsx @@ -1,65 +1,42 @@ -import { - ProductListEmptyStateIllustration, - SearchEmptyStateIllustration, -} from '@assets/svg/files'; +import { ProductListEmptyStateIllustration } from '@assets/svg/files'; import { Box, BoxProps, - Button, - Collapse, - Divider, Flex, - forwardRef, Heading, Icon, Link, Text, VStack, + forwardRef, } from '@chakra-ui/react'; import { ProductGrid } from '@components/common/ProductGrid'; import { ProductGridItem } from '@components/common/ProductGridItem'; import { Card } from '@components/ui'; import { filterFalsyItems } from '@helpers/application-helpers'; import { productListPath } from '@helpers/path-helpers'; +import { debouncedTrackGA4ViewItemList } from '@ifixit/analytics/google'; import { useAppContext } from '@ifixit/app'; -import { useLocalPreference, Wrapper } from '@ifixit/ui'; +import { Wrapper, useLocalPreference } from '@ifixit/ui'; import { productPreviewFromAlgoliaHit } from '@models/components/product-preview'; -import { - ProductList as TProductList, - ProductSearchHit, -} from '@models/product-list'; -import { - SearchQueryProvider, - useSearchQuery, -} from '@templates/product-list/hooks/useSearchQuery'; +import { ProductList as TProductList } from '@models/product-list'; + +import { useAlgoliaSearch } from 'app/_data/product-list/useAlgoliaSearch'; import * as React from 'react'; -import { - useClearRefinements, - useCurrentRefinements, - useHits, - useSearchBox, -} from 'react-instantsearch'; -import { CurrentRefinements } from './CurrentRefinements'; -import { FacetsAccordion } from './facets/accordion'; -import { Pagination } from './Pagination'; import { ProductList, ProductListItem } from './ProductList'; -import { ProductViewType, Toolbar } from './Toolbar'; +import { ProductViewType } from './Toolbar'; +import { FacetsAccordion } from './facets/accordion'; import { useHasAnyVisibleFacet } from './useHasAnyVisibleFacet'; -import { debouncedTrackGA4ViewItemList } from '@ifixit/analytics/google'; -import { useCurrentProductList } from '@templates/product-list/hooks/useCurrentProductList'; const PRODUCT_VIEW_TYPE_STORAGE_KEY = 'productViewType'; type SectionProps = { productList: TProductList; - algoliaSSR?: boolean; }; -export function FilterableProductsSection({ - productList, - algoliaSSR, -}: SectionProps) { - const { hits } = useHits(); +export function FilterableProductsSection({ productList }: SectionProps) { + const { hits, facets } = useAlgoliaSearch(); + const hasAnyVisibleFacet = useHasAnyVisibleFacet(productList); const products = React.useMemo( () => filterFalsyItems(hits.map(productPreviewFromAlgoliaHit)), @@ -82,8 +59,9 @@ export function FilterableProductsSection({ } }, [products, productList.handle, productList.title]); - const currentRefinements = useCurrentRefinements(); - const hasCurrentRefinements = currentRefinements.items.length > 0; + const currentRefinements = facets.filter((f) => f.selectedCount > 0); + const hasCurrentRefinements = currentRefinements.length > 0; + const [viewType, setViewType] = useLocalPreference( PRODUCT_VIEW_TYPE_STORAGE_KEY, ProductViewType.List, @@ -97,14 +75,6 @@ export function FilterableProductsSection({ const isEmpty = hits.length === 0; - if (algoliaSSR) { - return ( - - - - - ); - } return ( - - - - - */} + + + + {/* - - - - - */} + + + + {/* */} + + - - + + + {/* */} ); } @@ -240,72 +210,72 @@ type EmptyStateProps = BoxProps & { const ProductListEmptyState = forwardRef( ({ productList, ...otherProps }, ref) => { - const { currentProductList } = useCurrentProductList(); - const { setSearchQuery } = useSearchQuery(); - const clearRefinements = useClearRefinements({ excludedAttributes: [] }); + // const { currentProductList } = useCurrentProductList(); + // const { setSearchQuery } = useSearchQuery(); + // const clearRefinements = useClearRefinements({ excludedAttributes: [] }); - const currentRefinements = useCurrentRefinements(); - const hasRefinements = currentRefinements.items.length > 0; + // const currentRefinements = useCurrentRefinements(); + // const hasRefinements = currentRefinements.items.length > 0; - const searchBox = useSearchBox(); - const hasSearchQuery = searchBox.query.length > 0; + // const searchBox = useSearchBox(); + // const hasSearchQuery = searchBox.query.length > 0; - const isFiltered = hasRefinements || hasSearchQuery; + // const isFiltered = hasRefinements || hasSearchQuery; - const encodedQuery = encodeURIComponent(searchBox.query); + // const encodedQuery = encodeURIComponent(searchBox.query); - const ancestors = currentProductList.ancestors; + const ancestors = productList.ancestors; const parentCategory = ancestors[ancestors.length - 1]; - const appContext = useAppContext(); + // const appContext = useAppContext(); - if (isFiltered) { - return ( - - - - No matching products found in {currentProductList.title} - - - Try adjusting your search or filter to find what you're - looking for. - - - Search all of iFixit for  - - {searchBox.query} - - - - - - - ); - } + // if (isFiltered) { + // return ( + // + // + // + // No matching products found in {productList.title} + // + // + // Try adjusting your search or filter to find what you're + // looking for. + // + // + // Search all of iFixit for  + // + // {searchBox.query} + // + // + // + // {/* */} + // + // + // ); + // } return ( ['facets'][number]; -type DisjunctiveFacet = - SearchResults['disjunctiveFacets'][number]; -type HierarchicalFacet = - SearchResults['hierarchicalFacets'][number]; - export function useHasAnyVisibleFacet(productList: TProductList): boolean { - const { results } = useHits(); + const { hits, hitsCount, facets } = useAlgoliaSearch(); + const activeFacetsName = useFilteredFacets(productList); - if (!results) { + if (!hits) { return false; } - const isFacetActive = (facet: Facet | DisjunctiveFacet) => - activeFacetsName.includes(facet.name) && - Object.values(facet.data).some( - (value) => value > 0 && value < results.nbHits + const isFacetActive = (facet: Facet) => + activeFacetsName.includes(facet) && + facet.options.some( + (facetOption) => facetOption.count > 0 && facetOption.count < hitsCount ); - const isHierarchicalFacetActive = (facet: HierarchicalFacet) => - activeFacetsName.includes(facet.name) && - facet.data?.some(({ count }) => count > 0 && count < results.nbHits); - - return ( - results.facets.some(isFacetActive) || - results.disjunctiveFacets.some(isFacetActive) || - results.hierarchicalFacets.some(isHierarchicalFacetActive) - ); + return facets.some(isFacetActive); } diff --git a/frontend/templates/product-list/sections/HeroSection.tsx b/frontend/templates/product-list/sections/HeroSection.tsx index 69e7ad90..2eefdefb 100644 --- a/frontend/templates/product-list/sections/HeroSection.tsx +++ b/frontend/templates/product-list/sections/HeroSection.tsx @@ -2,21 +2,21 @@ import { Box, BoxProps, Button, + Image as ChakraImage, Flex, - forwardRef, Heading, - Image as ChakraImage, Text, + forwardRef, useDisclosure, } from '@chakra-ui/react'; import { PrerenderedHTML } from '@components/common'; import { DEFAULT_ANIMATION_DURATION_MS } from '@config/constants'; import { markdownToHTML } from '@helpers/ui-helpers'; import { isPresent } from '@ifixit/helpers'; -import { ResponsiveImage, useIsMountedState, Wrapper } from '@ifixit/ui'; +import { ResponsiveImage, Wrapper, useIsMountedState } from '@ifixit/ui'; import type { Image } from '@models/components/image'; +import { useAlgoliaSearch } from 'app/_data/product-list/useAlgoliaSearch'; import * as React from 'react'; -import { usePagination } from 'react-instantsearch'; export interface HeroSectionProps { title: string; @@ -33,8 +33,7 @@ export function HeroSection({ backgroundImage, brandLogo, }: HeroSectionProps) { - const pagination = usePagination(); - const page = pagination.currentRefinement + 1; + const { page } = useAlgoliaSearch(); const isFirstPage = page === 1; return ( diff --git a/frontend/templates/product-list/sections/ProductListChildrenSection.tsx b/frontend/templates/product-list/sections/ProductListChildrenSection.tsx index 3f535c52..868d6a00 100644 --- a/frontend/templates/product-list/sections/ProductListChildrenSection.tsx +++ b/frontend/templates/product-list/sections/ProductListChildrenSection.tsx @@ -3,12 +3,8 @@ import { ProductListCard } from '@components/product-list/ProductListCard'; import { productListPath } from '@helpers/path-helpers'; import { Wrapper } from '@ifixit/ui'; import type { ProductList } from '@models/product-list'; +import { useAlgoliaSearch } from 'app/_data/product-list/useAlgoliaSearch'; import * as React from 'react'; -import { - useCurrentRefinements, - useHits, - useSearchBox, -} from 'react-instantsearch'; import { useDevicePartsItemType } from '../hooks/useDevicePartsItemType'; export type ProductListChildrenSectionProps = { @@ -23,20 +19,18 @@ export function ProductListChildrenSection({ const [showAll, setShowAll] = React.useState(false); - const { items } = useCurrentRefinements(); - const { query } = useSearchBox(); - const { hits } = useHits(); + const { hits, query, facets } = useAlgoliaSearch(); const itemType = useDevicePartsItemType(productList); const childrenCount = productListChildren.length; const isUnfilteredItemTypeWithNoHits = React.useMemo(() => { - const nonItemTypeRefinements = items.filter( - (item) => item.attribute !== 'facet_tags.Item Type' + const nonItemTypeRefinements = facets.filter( + (item) => item.name !== 'facet_tags.Item Type' ); return ( !hits.length && itemType && !nonItemTypeRefinements.length && !query ); - }, [items, itemType, hits, query]); + }, [facets, itemType, hits, query]); if (isUnfilteredItemTypeWithNoHits) return null; diff --git a/packages/helpers/generic-helpers.ts b/packages/helpers/generic-helpers.ts index 80460d19..80f8848f 100644 --- a/packages/helpers/generic-helpers.ts +++ b/packages/helpers/generic-helpers.ts @@ -88,6 +88,31 @@ const loggingTimer = (timerName: string) => { type Timer = (name: string) => () => void; const time: Timer = !isProduction || enableLogging ? loggingTimer : silentTimer; +export function isBlank(value: unknown): boolean { + return ( + value == null || + isBlankString(value) || + isBlankArray(value) || + isBlankObject(value) + ); +} + +function isBlankString(value: unknown): boolean { + return typeof value === 'string' && value.trim() === ''; +} + +function isBlankArray(value: unknown): boolean { + return Array.isArray(value) && value.length === 0; +} + +function isBlankObject(value: unknown): boolean { + return ( + typeof value === 'object' && + value != null && + Object.keys(value).length === 0 + ); +} + export function isPresent(text: string | null | undefined): text is string { return typeof text === 'string' && text.length > 0; } diff --git a/packages/helpers/index.ts b/packages/helpers/index.ts index f60e31c9..1c7b1c03 100644 --- a/packages/helpers/index.ts +++ b/packages/helpers/index.ts @@ -2,4 +2,5 @@ export * from './generic-helpers'; export * from './commerce-helpers'; export * from './product-helpers'; export * from './shopify-helpers'; +export * from './sort-helpers'; export * from './logger'; diff --git a/packages/helpers/sort-helpers.ts b/packages/helpers/sort-helpers.ts new file mode 100644 index 00000000..0354036c --- /dev/null +++ b/packages/helpers/sort-helpers.ts @@ -0,0 +1,50 @@ +export function compareAlphabetically(a: string, b: string) { + const enCollator = new Intl.Collator('en'); + return enCollator.compare(a, b); +} + +export function compareCapacity(a: string, b: string) { + return capacityToBytes(b) - capacityToBytes(a); +} + +export function comparePriceRangeFn(a: string, b: string) { + const aAvg = avg(a); + const bAvg = avg(b); + + if (aAvg == null && bAvg == null) { + return 0; + } + if (aAvg == null) { + return 1; + } + if (bAvg == null) { + return -1; + } + return aAvg - bAvg; +} + +function avg(range: string): number | null { + const nums = range.match(/\d+/g); + if (nums == null) { + return null; + } + return nums.reduce((x, y) => x + parseFloat(y), 0) / nums.length; +} + +const unitToBytes = { + B: 1, + KB: 1024, + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4, + PB: 1024 ** 5, +}; + +function capacityToBytes(text: string): number { + const size = parseFloat(text); + if (isNaN(size)) return 0; + + const unit = (text.match(/[KMGTP]?B/)?.[0] ?? + 'B') as keyof typeof unitToBytes; + return size * (unitToBytes[unit] ?? 1); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e2982d5..a5e2afcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: frontend: dependencies: + '@algolia/requester-fetch': + specifier: 4.22.1 + version: 4.22.1 '@chakra-ui/react': specifier: 2.8.1 version: 2.8.1(@emotion/react@11.10.5)(@emotion/styled@11.10.5)(@types/react@18.2.0)(framer-motion@10.12.0)(react-dom@18.2.0)(react@18.2.0) @@ -968,6 +971,16 @@ packages: resolution: {integrity: sha512-eGVf0ID84apfFEuXsaoSgIxbU3oFsIbz4XiotU3VS8qGCJAaLVUC5BUJEkiFENZIhon7hIB4d0RI13HY4RSA+w==} dev: false + /@algolia/requester-common@4.22.1: + resolution: {integrity: sha512-dgvhSAtg2MJnR+BxrIFqlLtkLlVVhas9HgYKMk2Uxiy5m6/8HZBL40JVAMb2LovoPFs9I/EWIoFVjOrFwzn5Qg==} + dev: false + + /@algolia/requester-fetch@4.22.1: + resolution: {integrity: sha512-fb5YfPms7lAYy4FdBmNc0LM872ITXbNEVGJUPhe+llbQscIzP0FM5WnRK7fQefAUFZR5zQ68J2tHkPCTDfeNLw==} + dependencies: + '@algolia/requester-common': 4.22.1 + dev: false + /@algolia/requester-node-http@4.13.1: resolution: {integrity: sha512-7C0skwtLdCz5heKTVe/vjvrqgL/eJxmiEjHqXdtypcE5GCQCYI15cb+wC4ytYioZDMiuDGeVYmCYImPoEgUGPw==} dependencies: