Skip to content

Commit

Permalink
feat: hierarchical categories (#40)
Browse files Browse the repository at this point in the history
  • Loading branch information
ddaoxuan committed Jul 5, 2024
1 parent ab11a58 commit 4c71840
Show file tree
Hide file tree
Showing 29 changed files with 454 additions and 283 deletions.
7 changes: 6 additions & 1 deletion apps/web/app/api/feed/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { storefrontClient } from "clients/storefrontClient"
import { env } from "env.mjs"
import type { FailedAttemptError } from "p-retry"
import { compareHmac } from "utils/compare-hmac"
import { enrichProduct } from "utils/enrich-product"

type SupportedTopic = "products/update" | "products/delete" | "products/create" | "collections/update" | "collections/delete" | "collections/create"

Expand Down Expand Up @@ -80,11 +81,15 @@ async function handleProductTopics(topic: SupportedTopic, { id }: Record<string,
case "products/update":
case "products/create":
const product = await storefrontClient.getProduct(makeShopifyId(`${id}`, "Product"))
const items = env.SHOPIFY_HIERARCHICAL_NAV_HANDLE ? (await storefrontClient.getHierarchicalCollections(env.SHOPIFY_HIERARCHICAL_NAV_HANDLE)).items : []

if (!product) {
console.error(`Product ${id} not found`)
return new Response(JSON.stringify({ message: "Product not found" }), { status: 404, headers: { "Content-Type": "application/json" } })
}
await index.updateDocuments([normalizeProduct(product, id)], {

const enrichedProduct = await enrichProduct(product, items)
await index.updateDocuments([normalizeProduct(enrichedProduct, id)], {
primaryKey: "id",
})

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export const metadata: Metadata = {
applicationName: "Next.js",
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
Expand Down
6 changes: 3 additions & 3 deletions apps/web/app/products/[slug]/draft/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,14 @@ export async function generateStaticParams() {
async function ProductView({ slug }: { slug: string }) {
const product = await getDraftAwareProduct(slug)

const { color, size } = getOptionsFromUrl(slug)
const hasInvalidOptions = !hasValidOption(product?.variants, "color", color) || !hasValidOption(product?.variants, "size", size)
const { color } = getOptionsFromUrl(slug)
const hasInvalidOptions = !hasValidOption(product?.variants, "color", color)

if (!product || hasInvalidOptions) {
return notFound()
}

const combination = getCombination(product as CommerceProduct, color, size)
const combination = getCombination(product as CommerceProduct, color)
const lastCollection = product?.collections?.findLast(Boolean)
const hasOnlyOneVariant = product.variants.length <= 1

Expand Down
12 changes: 7 additions & 5 deletions apps/web/app/products/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,19 @@ export default async function Product({ params: { slug } }: ProductProps) {
}

async function ProductView({ slug }: { slug: string }) {
const product = await getProduct(removeOptionsFromUrl(slug))
const { reviews, total: totalReviews } = await getProductReviews(removeOptionsFromUrl(slug), { limit: 4 })
const [product, { reviews, total: totalReviews }] = await Promise.all([
await getProduct(removeOptionsFromUrl(slug)),
await getProductReviews(removeOptionsFromUrl(slug), { limit: 4 }),
])

const { color, size } = getOptionsFromUrl(slug)
const hasInvalidOptions = !hasValidOption(product?.variants, "color", color) || !hasValidOption(product?.variants, "size", size)
const { color } = getOptionsFromUrl(slug)
const hasInvalidOptions = !hasValidOption(product?.variants, "color", color)

if (!product || hasInvalidOptions) {
return notFound()
}

const combination = getCombination(product, color, size)
const combination = getCombination(product, color)
const lastCollection = product?.collections?.findLast(Boolean)
const hasOnlyOneVariant = product.variants.length <= 1

Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/Pagination/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const PaginationLink = ({ className, isActive, children, href, disabled, "aria-l
buttonVariants({
variant: "ghost",
}),
"hidden size-9 items-center justify-center rounded-full border border-black bg-white px-0 py-0 text-[16px] text-slate-800 transition-colors hover:bg-black hover:text-white md:flex",
"size-9 items-center justify-center rounded-full border border-black bg-white px-0 py-0 text-[16px] text-slate-800 transition-colors hover:bg-black hover:text-white md:flex",
{ "bg-black font-bold text-white": isActive },
{ "pointer-events-none cursor-not-allowed opacity-50": disabled },
className
Expand Down
5 changes: 4 additions & 1 deletion apps/web/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ export const BUCKETS = {
HOME: ["a", "b"],
} as const

export const facetParams = ["q", "minPrice", "maxPrice", "sortBy", "categories", "vendors", "tags", "colors", "sizes", "rating"]
export const facetParams = ["q", "minPrice", "maxPrice", "sortBy", "categories", "vendors", "tags", "colors", "sizes", "rating"] as const
export const HIERARCHICAL_ATRIBUTES = ["hierarchicalCategories.lvl0", "hierarchicalCategories.lvl1", "hierarchicalCategories.lvl2"] as const
export const HIERARCHICAL_SEPARATOR = " > "
export const HITS_PER_PAGE = 24
2 changes: 2 additions & 0 deletions apps/web/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const env = createEnv({
SHOPIFY_STORE_DOMAIN: z.string(),
SHOPIFY_ADMIN_ACCESS_TOKEN: z.string().optional(),
SHOPIFY_APP_API_SECRET_KEY: z.string().optional(),
SHOPIFY_HIERARCHICAL_NAV_HANDLE: z.string().optional(),
MEILISEARCH_PRODUCTS_INDEX: z.string(),
MEILISEARCH_CATEGORIES_INDEX: z.string(),
MEILISEARCH_ADMIN_KEY: z.string().optional(),
Expand Down Expand Up @@ -36,6 +37,7 @@ export const env = createEnv({
SHOPIFY_ADMIN_ACCESS_TOKEN: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || "demo",
SHOPIFY_APP_API_SECRET_KEY: process.env.SHOPIFY_APP_API_SECRET_KEY || "demo",
SHOPIFY_STORE_DOMAIN: process.env.SHOPIFY_STORE_DOMAIN || "demo",
SHOPIFY_HIERARCHICAL_NAV_HANDLE: process.env.SHOPIFY_HIERARCHICAL_NAV_HANDLE,
MEILISEARCH_PRODUCTS_INDEX: process.env.MEILISEARCH_PRODUCTS_INDEX || "products",
MEILISEARCH_CATEGORIES_INDEX: process.env.MEILISEARCH_CATEGORIES_INDEX || "categories",
MEILISEARCH_ADMIN_KEY: process.env.MEILISEARCH_ADMIN_KEY || "demo",
Expand Down
89 changes: 89 additions & 0 deletions apps/web/utils/enrich-product.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { PlatformImage, PlatformMenu, PlatformProduct } from "@enterprise-commerce/core/platform/types"
import { replicate } from "clients/replicate"

/*
* Enrich product by attaching hierarchical categories to it
* Takes in all tags, and tries to find hierarchy against it
* Currently just shopify focused
*/

export const enrichProduct = async (product: PlatformProduct, collections: PlatformMenu["items"]) => {
const categoryMap = buildCategoryMap(collections)
const images = await generateProductAltTags(product)
const hierarchicalCategories = generateHierarchicalCategories(product.tags, categoryMap)

return {
...product,
images: images.filter(Boolean),
hierarchicalCategories,
}
}

function buildCategoryMap(categories: PlatformMenu["items"]) {
const categoryMap = new Map<string, string[]>()

function traverse(items: PlatformMenu["items"], path: string[]) {
for (const item of items) {
const newPath = [...path, item.resource!.handle]
categoryMap.set(item.resource!.handle, newPath)
if (item.items && item.items.length > 0) {
traverse(item.items, newPath)
}
}
}

traverse(categories, [])

return categoryMap
}

function generateHierarchicalCategories(tags: PlatformProduct["tags"], categoryMap: Map<string, string[]>) {
const hierarchicalCategories: Record<"lvl0" | "lvl1" | "lvl2", string[]> = { lvl0: [], lvl1: [], lvl2: [] }

if (!tags.length || !categoryMap.size) return hierarchicalCategories

tags.forEach((tag) => {
const path = categoryMap.get(tag)
if (path) {
if (path.length > 0 && !hierarchicalCategories.lvl0.includes(path[0])) {
hierarchicalCategories.lvl0.push(path[0])
}
if (path.length > 1) {
const lvl1Path = path.slice(0, 2).join(" > ")
if (!hierarchicalCategories.lvl1.includes(lvl1Path)) {
hierarchicalCategories.lvl1.push(lvl1Path)
}
}
if (path.length > 2) {
const lvl2Path = path.slice(0, 3).join(" > ")
if (!hierarchicalCategories.lvl2.includes(lvl2Path)) {
hierarchicalCategories.lvl2.push(lvl2Path)
}
}
}
})

return hierarchicalCategories
}

async function generateProductAltTags(product: PlatformProduct): Promise<(PlatformImage | undefined)[]> {
try {
const altTagAwareImages = await Promise.all(product?.images?.slice(0, 1).map(mapper).filter(Boolean))
return [...altTagAwareImages, ...product?.images?.slice(1)?.filter(Boolean)] || []
} catch (e) {
console.error(e)
return product.images // graceful exit
}

async function mapper(image: PlatformImage) {
if (!replicate) return
const output = (await replicate.run("salesforce/blip:2e1dddc8621f72155f24cf2e0adbde548458d3cab9f00c0139eea840d0ac4746", {
input: {
task: "image_captioning",
image: image.url,
},
})) as unknown as string

return { ...image, altText: output?.replace("Caption:", "") || "" }
}
}
22 changes: 6 additions & 16 deletions apps/web/utils/productOptionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ export interface Combination {
quantityAvailable?: number | null | undefined
price: PlatformVariant["price"] | undefined
title: string
size?: string
color?: string
}

type Option = keyof Pick<Combination, "color" | "size">
type Option = keyof Pick<Combination, "color">

export function getAllCombinations(variants: PlatformVariant[]): Combination[] {
return variants?.map((variant) => ({
Expand All @@ -24,15 +23,12 @@ export function getAllCombinations(variants: PlatformVariant[]): Combination[] {
}))
}

export function getCombination(product: CommerceProduct, color: string | null, size: string | null) {
export function getCombination(product: CommerceProduct, color: string | null) {
const hasOnlyOneVariant = product.variants.length <= 1

const defaultColor = product.flatOptions?.["Color"]?.find(Boolean)?.toLowerCase() ?? undefined
const defaultSize = product.flatOptions?.["Size"]?.find(Boolean)?.toLowerCase() ?? undefined

return hasOnlyOneVariant
? product.variants.find(Boolean)
: getAllCombinations(product.variants).find((combination) => combination.size === (size ?? defaultSize) && combination.color === (color ?? defaultColor))
return hasOnlyOneVariant ? product.variants.find(Boolean) : getAllCombinations(product.variants).find((combination) => combination.color === (color ?? defaultColor))
}

export function hasValidOption(variants: PlatformVariant[] | null | undefined, optionName: Option, optionValue: string | null): boolean {
Expand All @@ -43,37 +39,31 @@ export function hasValidOption(variants: PlatformVariant[] | null | undefined, o
return !optionValue || combinations.includes(optionValue)
}

export function createOptionfulUrl(originalUrl: string, size: string | null | undefined, color: string | null | undefined) {
export function createOptionfulUrl(originalUrl: string, color: string | null | undefined) {
let urlWithoutParams = removeOptionsFromUrl(originalUrl)

const newSizeParam = size ? `-size_${size}` : ""
const newColorParam = color ? `-color_${color}` : ""

return `${urlWithoutParams}${newSizeParam}${newColorParam}`
return `${urlWithoutParams}${newColorParam}`
}

export function removeOptionsFromUrl(pathname: string) {
const sizePattern = /-size_([0-9a-zA-Z\s]+)/
const colorPattern = /-color_([0-9a-zA-Z\s]+)/

return decodeURIComponent(pathname).replace(sizePattern, "").replace(colorPattern, "")
return decodeURIComponent(pathname).replace(colorPattern, "")
}

export function getOptionsFromUrl(pathname: string) {
const result: Record<Option, null | string> = {
size: null,
color: null,
}

const sizePattern = /-size_([0-9a-zA-Z\s]+)/
const colorPattern = /-color_([0-9a-zA-Z\s]+)/

const decodedPathname = decodeURIComponent(pathname)

const sizeMatch = decodedPathname.match(sizePattern)
const colorMatch = decodedPathname.match(colorPattern)

if (sizeMatch) result.size = sizeMatch[1].toLowerCase()
if (colorMatch) result.color = colorMatch[1].toLowerCase()

return result
Expand Down
60 changes: 60 additions & 0 deletions apps/web/utils/useHierarchicalMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { CategoriesDistribution } from "meilisearch"
import { useParams, useSearchParams } from "next/navigation"

interface HierarchicalMenuOptions {
attributes: readonly string[]
distribution: Record<string, CategoriesDistribution>
separator: string
}

type HierarchicalMenuItem = Record<string, number>

export const useHierarchicalMenu = ({ attributes, distribution, separator }: HierarchicalMenuOptions) => {
const { slug } = useParams()
const searchParams = useSearchParams()
const normalizedSlug = Array.isArray(slug) ? slug.join(separator) : slug || searchParams.get("categories")?.split(",").join(separator) || ""

const initialPath = findInitialPath(separator, normalizedSlug, attributes, distribution) || []

const getCategoryChildren = (path: string[]) => {
const level = path.length
const key = attributes[level]
const prefix = path.join(separator)

return Object.keys(distribution[key] || {})
.filter((item) => item.startsWith(prefix))
.reduce((acc: HierarchicalMenuItem, item) => {
const category = item.split(separator)[level]
if (category && !acc[category]) {
acc[category] = distribution[key][item]
}
return acc
}, {})
}

const items: HierarchicalMenuItem = initialPath.length === 0 ? distribution[attributes[0]] : getCategoryChildren(initialPath)
const parent = initialPath.length > 0 ? initialPath.slice(0, -1).pop() : null
const current = initialPath.length > 0 ? initialPath.pop() : null

return {
items,
current,
parent,
}
}

const findInitialPath = (separator: string, activeCategory: string, attributes: readonly string[], distribution: Record<string, CategoriesDistribution>): string[] => {
if (activeCategory === "") {
return []
}

for (let level = 0; level < attributes.length; level++) {
const key = attributes[level]
const categoriesAtLevel = Object.keys(distribution[key] || {})
const path = categoriesAtLevel.find((item) => item.endsWith(activeCategory))
if (path) {
return path.split(separator)
}
}
return []
}
1 change: 0 additions & 1 deletion apps/web/views/Category/CategoryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export async function CategoryView({ params, searchParams = {} }: CategoryViewPr
<SearchView
searchParams={searchParams}
params={params}
disabledFacets={["category", "tags"]}
collection={collection}
intro={<HeroSection handle={collection.handle} title={collection.title} description={collection.description} image={collection.image} />}
/>
Expand Down
7 changes: 4 additions & 3 deletions apps/web/views/Homepage/CategoriesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { meilisearch } from "clients/meilisearch"
import { Skeleton } from "components/Skeleton/Skeleton"
import { env } from "env.mjs"
import { unstable_cache } from "next/cache"
import Image from "next/image"
import Link from "next/link"
import { getDemoCategories, isDemoMode } from "utils/demoUtils"

Expand All @@ -17,9 +18,9 @@ export async function CategoriesSection() {
</div>
<div className="group mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{categories.map((singleCategory, index) => (
<Link className="relative h-[260px] w-full overflow-hidden rounded-2xl" key={singleCategory.handle + index} href={`/category/${singleCategory.handle}`}>
<div className="absolute inset-0 size-full bg-neutral-100 transition-all hover:bg-neutral-50 hover:blur">
<img alt="" src={`/category-placeholder-${index + 1}.svg`} className="absolute -top-8 right-0 h-full" />
<Link className="group/bcl relative h-[260px] w-full overflow-hidden rounded-2xl" key={singleCategory.handle + index} href={`/category/${singleCategory.handle}`}>
<div className="absolute inset-0 -z-10 size-full bg-neutral-100 transition-all group-hover/bcl:bg-neutral-50 group-hover/bcl:blur">
<Image fill alt="" src={`/category-placeholder-${index + 1}.svg`} className="absolute -top-8 right-0 h-full" />
</div>
<h3 className="absolute bottom-8 left-8 text-[29px]/[18px] tracking-tight text-black">{singleCategory.title}</h3>
</Link>
Expand Down
Loading

0 comments on commit 4c71840

Please sign in to comment.