Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: hierarchical categories #40

Merged
merged 2 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading