From 5287807d55122fccc816b225cc547647fd9a9f96 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 15 Oct 2026 19:48:24 +0800 Subject: [PATCH] feat(categories): nested store and menu categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storefront nests product and catalog categories up to three levels (Drinks › Coffee › Cold brew); each category carries its parent. - Store page: top-level categories are the tabs; each section loads its products once (the API rolls up the subcategories' products, replacing one query per category) and shows them in headings by subcategory, with chips to jump to a subcategory. A link to any category opens its heading. - Category list: top-level rows with their subcategories as pills; a pill opens that subcategory on the store page. - Search chips list top-level categories only. - Truck menus: tabs by top-level menu category, subcategory headings inside; the trucks map's category tiles are top-level only; truck search also matches the top-level category. Against an API without parents every category is at the top, as before. --- __tests__/commerce/categories.test.ts | 84 +++++++++++++ __tests__/commerce/food-trucks.test.ts | 33 ++++- src/commerce/categories.ts | 115 ++++++++++++++++++ src/commerce/food-trucks.ts | 41 ++++++- src/screens/StoreCatalogScreen.tsx | 24 ++-- src/screens/StoreSearchScreen.tsx | 6 +- .../foodtrucks/FoodTruckSearchScreen.tsx | 2 +- src/screens/foodtrucks/TruckMenuScreen.tsx | 33 +++-- src/screens/network/NetworkStoreScreen.tsx | 83 ++++++++++--- 9 files changed, 378 insertions(+), 43 deletions(-) create mode 100644 __tests__/commerce/categories.test.ts create mode 100644 src/commerce/categories.ts diff --git a/__tests__/commerce/categories.test.ts b/__tests__/commerce/categories.test.ts new file mode 100644 index 00000000..1ce9358b --- /dev/null +++ b/__tests__/commerce/categories.test.ts @@ -0,0 +1,84 @@ +import { ancestorAt, categoryIndex, categoryTree, descendantIds, groupByCategory } from '../../src/commerce/categories'; + +// Drinks › Coffee › Cold brew, Drinks › Tea, Food: flat, as the API lists them. +const flat = [ + { id: 'cold_brew', name: 'Cold brew', parent: 'coffee', icon_url: null }, + { id: 'drinks', name: 'Drinks', parent: null, icon_url: 'https://cdn.test/drinks.png' }, + { id: 'coffee', name: 'Coffee', parent: 'drinks' }, + { id: 'tea', name: 'Tea', parent: 'drinks' }, + { id: 'food', name: 'Food' }, +]; + +describe('categoryTree', () => { + it('nests categories under their parents with their level and path', () => { + const tree = categoryTree(flat); + const index = categoryIndex(tree); + + expect(tree.map((node) => node.id)).toEqual(['drinks', 'food']); + expect(index.get('drinks')?.children.map((node) => node.id)).toEqual(['coffee', 'tea']); + expect(index.get('cold_brew')).toMatchObject({ depth: 3, path: 'Drinks › Coffee › Cold brew', parentId: 'coffee' }); + expect(index.get('drinks')?.iconUrl).toBe('https://cdn.test/drinks.png'); + expect(descendantIds(index.get('drinks')!)).toEqual(['drinks', 'coffee', 'cold_brew', 'tea']); + }); + + it('reads SDK resources and object parents, and keeps categories without a known parent at the top', () => { + const resource = (attributes: any) => ({ id: attributes.id, getAttribute: (key: string) => attributes[key] }); + const tree = categoryTree([resource({ id: 'a', name: 'A' }), resource({ id: 'b', name: 'B', parent: { id: 'a' } }), { id: 'c', name: 'C', parent: 'gone' }, null]); + + expect(tree.map((node) => node.id)).toEqual(['a', 'c']); + expect(categoryIndex(tree).get('b')?.depth).toBe(2); + expect(categoryTree(undefined as any)).toEqual([]); + }); + + it('does not lose categories that point at each other', () => { + const tree = categoryTree([ + { id: 'a', name: 'A', parent: 'b' }, + { id: 'b', name: 'B', parent: 'a' }, + ]); + + expect(categoryIndex(tree).size).toBe(2); + }); +}); + +describe('ancestorAt', () => { + it('finds the category at a level above another, or the category itself', () => { + const index = categoryIndex(categoryTree(flat)); + + expect(ancestorAt(index, 'cold_brew', 1)?.id).toBe('drinks'); + expect(ancestorAt(index, 'cold_brew', 2)?.id).toBe('coffee'); + expect(ancestorAt(index, 'tea', 2)?.id).toBe('tea'); + expect(ancestorAt(index, 'drinks', 2)?.id).toBe('drinks'); + expect(ancestorAt(index, 'missing', 1)).toBeNull(); + expect(ancestorAt(index, null, 1)).toBeNull(); + }); +}); + +describe('groupByCategory', () => { + it('groups a top category\'s products under its subcategories, in tree order', () => { + const drinks = categoryIndex(categoryTree(flat)).get('drinks')!; + const products = [ + { name: 'Nitro', category: 'cold_brew' }, + { name: 'Water', category: 'drinks' }, + { name: 'Chai', category: 'tea' }, + { name: 'Flat white', category: 'coffee' }, + { name: 'Mystery', category: 'elsewhere' }, + { name: 'Loose', category: null }, + ]; + + const groups = groupByCategory(drinks, products, (product) => product.category); + + expect(groups.map((group) => [group.name, group.level, group.items.map((item) => item.name)])).toEqual([ + ['Drinks', 0, ['Water', 'Mystery', 'Loose']], + ['Coffee', 1, ['Flat white']], + ['Cold brew', 2, ['Nitro']], + ['Tea', 1, ['Chai']], + ]); + expect(groups[2].path).toBe('Drinks › Coffee › Cold brew'); + }); + + it('leaves out headings with nothing under them', () => { + const drinks = categoryIndex(categoryTree(flat)).get('drinks')!; + + expect(groupByCategory(drinks, [{ category: 'tea' }], (item) => item.category).map((group) => group.id)).toEqual(['tea']); + }); +}); diff --git a/__tests__/commerce/food-trucks.test.ts b/__tests__/commerce/food-trucks.test.ts index bf342f0b..275ac0a7 100644 --- a/__tests__/commerce/food-trucks.test.ts +++ b/__tests__/commerce/food-trucks.test.ts @@ -1,4 +1,4 @@ -import { summarizeTruck } from '../../src/commerce/food-trucks'; +import { summarizeTruck, truckCategories, truckProducts } from '../../src/commerce/food-trucks'; describe('summarizeTruck', () => { it('is live when the truck takes orders, whatever its vehicle reports', () => { @@ -11,3 +11,34 @@ describe('summarizeTruck', () => { expect(summarizeTruck({ id: 'truck_1', vehicle: { online: true } }).live).toBe(false); }); }); + +describe('nested menu categories', () => { + // Lunch › Tacos › Vegetarian, and Drinks. + const truck = { + id: 'truck_1', + catalogs: [ + { + name: 'Menu', + categories: [ + { id: 'lunch', name: 'Lunch', parent: null, products: [{ id: 'p_wrap', name: 'Wrap' }] }, + { id: 'tacos', name: 'Tacos', parent: 'lunch', products: [{ id: 'p_carnitas', name: 'Carnitas' }] }, + { id: 'veggie', name: 'Vegetarian', parent: 'tacos', products: [{ id: 'p_mushroom', name: 'Mushroom' }] }, + { id: 'drinks', name: 'Drinks', products: [{ id: 'p_cola', name: 'Cola' }] }, + ], + }, + ], + } as any; + + it('offers only a menu\'s top-level categories', () => { + expect(truckCategories([truck]).map((category) => category.name)).toEqual(['Lunch', 'Drinks']); + }); + + it('puts each product under its top-level category, with its place below it', () => { + expect(truckProducts(truck).map((item) => [item.product.name, item.topCategoryName, item.subPath, item.categoryName])).toEqual([ + ['Wrap', 'Lunch', '', 'Lunch'], + ['Carnitas', 'Lunch', 'Tacos', 'Tacos'], + ['Mushroom', 'Lunch', 'Tacos › Vegetarian', 'Vegetarian'], + ['Cola', 'Drinks', '', 'Drinks'], + ]); + }); +}); diff --git a/src/commerce/categories.ts b/src/commerce/categories.ts new file mode 100644 index 00000000..005e42c9 --- /dev/null +++ b/src/commerce/categories.ts @@ -0,0 +1,115 @@ +/** + * Nested categories, as the store page, the category list and truck menus show them. + * + * A store's categories come from `storefront/v1/categories` flat, each with its `parent` (the + * parent's id) and `depth`; a catalog's categories carry `parent` too. They nest up to three + * levels: Drinks › Coffee › Cold brew. Categories from an API without `parent` are all at the + * top, as before. + */ + +export const MAX_CATEGORY_DEPTH = 3; + +export type CategoryNode = { + id: string; + name: string; + iconUrl: string | null; + parentId: string | null; + /** 1 at the top. */ + depth: number; + /** "Drinks › Coffee". */ + path: string; + children: CategoryNode[]; + raw: any; +}; + +/** One heading's worth of a top-level section: the category's own products, then each subcategory's. */ +export type CategoryGroup = { id: string; name: string; path: string; level: number; items: T[] }; + +const attr = (resource: any, key: string) => (typeof resource?.getAttribute === 'function' ? resource.getAttribute(key) : resource?.[key]); + +/** The categories as a tree, keeping the order they came in. */ +export function categoryTree(categories: any[]): CategoryNode[] { + const list = Array.from(categories ?? []).filter(Boolean); + const ids = new Set(list.map((category) => String(attr(category, 'id') ?? category?.id ?? ''))); + const nodes = list.map((category) => { + const id = String(attr(category, 'id') ?? category?.id ?? ''); + const parent = attr(category, 'parent'); + const parentId = parent && typeof parent === 'object' ? String(parent.id ?? '') : parent ? String(parent) : null; + return { + id, + name: String(attr(category, 'name') ?? ''), + iconUrl: attr(category, 'icon_url') ?? null, + parentId: parentId && ids.has(parentId) && parentId !== id ? parentId : null, + depth: 1, + path: '', + children: [] as CategoryNode[], + raw: category, + }; + }); + const byId = new Map(nodes.map((node) => [node.id, node])); + const seen = new Set(); + const place = (node: CategoryNode, depth: number, trail: string[]) => { + if (seen.has(node.id)) return; + seen.add(node.id); + node.depth = depth; + node.path = [...trail, node.name].join(' › '); + node.children = nodes.filter((child) => child.parentId === node.id); + node.children.forEach((child) => place(child, depth + 1, [...trail, node.name])); + }; + const roots = nodes.filter((node) => !node.parentId || !byId.has(node.parentId)); + roots.forEach((root) => place(root, 1, [])); + // A loop in the data leaves nodes unplaced: show them at the top rather than lose them. + nodes.filter((node) => !seen.has(node.id)).forEach((node) => { + node.parentId = null; + roots.push(node); + place(node, 1, []); + }); + + return roots; +} + +/** Every node of the tree by id. */ +export function categoryIndex(tree: CategoryNode[]): Map { + const index = new Map(); + const walk = (node: CategoryNode) => { + index.set(node.id, node); + node.children.forEach(walk); + }; + tree.forEach(walk); + return index; +} + +/** A category and every category under it. */ +export function descendantIds(node: CategoryNode): string[] { + return [node.id, ...node.children.flatMap(descendantIds)]; +} + +/** The category's ancestor at a level (1 at the top), or the category itself when it is at or above it. */ +export function ancestorAt(index: Map, id: string | null | undefined, level: number): CategoryNode | null { + let node = id ? (index.get(String(id)) ?? null) : null; + while (node && node.depth > level && node.parentId) { + node = index.get(node.parentId) ?? null; + } + return node; +} + +/** + * A top-level category's items in headings: its own first, then each subcategory's, and under a + * subcategory its own subcategories', in tree order. Items whose category is not under `top` (or + * unknown) count as the top category's own. Empty headings are left out. + */ +export function groupByCategory(top: CategoryNode, items: T[], categoryOf: (item: T) => string | null | undefined): CategoryGroup[] { + const groups: CategoryGroup[] = []; + const under = new Set(descendantIds(top)); + const own = (node: CategoryNode) => items.filter((item) => { + const category = categoryOf(item); + return node === top ? !category || !under.has(String(category)) || String(category) === top.id : String(category) === node.id; + }); + const walk = (node: CategoryNode, level: number) => { + const mine = own(node); + if (mine.length) groups.push({ id: node.id, name: node.name, path: node.path, level, items: mine }); + node.children.forEach((child) => walk(child, level + 1)); + }; + walk(top, 0); + return groups; +} diff --git a/src/commerce/food-trucks.ts b/src/commerce/food-trucks.ts index e012f53b..39101c84 100644 --- a/src/commerce/food-trucks.ts +++ b/src/commerce/food-trucks.ts @@ -8,6 +8,8 @@ * `categories` → `products`, and `store` in a Network. */ +import { ancestorAt, categoryIndex, categoryTree } from './categories'; + export type LatLng = { latitude: number; longitude: number }; export type TruckCategory = { key: string; id: string | null; name: string; iconUrl: string | null; truckId: string }; @@ -102,12 +104,15 @@ export function customerZone(trucks: TruckSummary[], customer: LatLng | null): { return match ? { id: match.zoneId, name: match.zoneName } : null; } -/** Every category the given trucks sell, once each by name, keeping which truck sells it. */ +/** Every top-level category the given trucks sell, once each by name, keeping which truck sells it. */ export function truckCategories(trucks: TruckSummary[]): TruckCategory[] { const seen = new Map(); for (const truck of trucks) { for (const catalog of truck.catalogs) { + // A menu's subcategories (Lunch › Tacos) show under their category, not as categories of their own. + const tops = new Set(categoryTree(Array.isArray(catalog?.categories) ? catalog.categories : []).map((node) => node.id)); for (const category of Array.isArray(catalog?.categories) ? catalog.categories : []) { + if (category?.id && !tops.has(String(category.id))) continue; const name = String(category?.name ?? '').trim(); if (!name || seen.has(name.toLowerCase())) continue; seen.set(name.toLowerCase(), { key: name.toLowerCase(), id: category?.id ?? null, name, iconUrl: category?.icon_url ?? null, truckId: truck.id }); @@ -117,13 +122,37 @@ export function truckCategories(trucks: TruckSummary[]): TruckCategory[] { return [...seen.values()]; } -/** Products across a truck's catalogs, each with the category it is in. */ -export function truckProducts(truck: TruckSummary): Array<{ product: any; categoryId: string | null; categoryName: string; catalogName: string }> { - const items: Array<{ product: any; categoryId: string | null; categoryName: string; catalogName: string }> = []; +export type TruckProduct = { + product: any; + categoryId: string | null; + categoryName: string; + catalogName: string; + /** The menu's top-level category the product is under (its own category at the top). */ + topCategoryId: string | null; + topCategoryName: string; + /** Where it sits under that category: "Tacos › Vegetarian", or empty at the top. */ + subPath: string; +}; + +/** Products across a truck's catalogs, each with the category it is in and its place in the menu's tree. */ +export function truckProducts(truck: TruckSummary): TruckProduct[] { + const items: TruckProduct[] = []; for (const catalog of truck.catalogs) { - for (const category of Array.isArray(catalog?.categories) ? catalog.categories : []) { + const categories = Array.isArray(catalog?.categories) ? catalog.categories : []; + const index = categoryIndex(categoryTree(categories)); + for (const category of categories) { + const node = category?.id ? index.get(String(category.id)) : undefined; + const top = node ? ancestorAt(index, node.id, 1) : undefined; for (const product of Array.isArray(category?.products) ? category.products : []) { - items.push({ product, categoryId: category?.id ?? null, categoryName: String(category?.name ?? ''), catalogName: String(catalog?.name ?? '') }); + items.push({ + product, + categoryId: category?.id ?? null, + categoryName: String(category?.name ?? ''), + catalogName: String(catalog?.name ?? ''), + topCategoryId: top?.id ?? category?.id ?? null, + topCategoryName: top?.name ?? String(category?.name ?? ''), + subPath: node && top && node.id !== top.id ? node.path.split(' › ').slice(1).join(' › ') : '', + }); } } } diff --git a/src/screens/StoreCatalogScreen.tsx b/src/screens/StoreCatalogScreen.tsx index f1420617..57254774 100644 --- a/src/screens/StoreCatalogScreen.tsx +++ b/src/screens/StoreCatalogScreen.tsx @@ -9,23 +9,27 @@ import { useLanguage } from '../contexts/LanguageContext'; import useStorefront from '../hooks/use-storefront'; import useStorefrontInfo from '../hooks/use-storefront-info'; import { EmptyState, ErrorState, IconButton, MediaImage, Skeleton, UIText, radius, space } from '../ui'; +import { categoryTree, type CategoryNode } from '../commerce/categories'; type CategoryRow = { id: string; name: string; iconUrl: string | null; subcategories: Array<{ id: string; name: string }> }; -function toRow(category: any): CategoryRow { +/** A top-level category and the categories directly under it (from the tree, or listed with it by an older API). */ +export function toRow(node: CategoryNode): CategoryRow { + const category = node.raw; const attr = (key: string) => (typeof category?.getAttribute === 'function' ? category.getAttribute(key) : category?.[key]); - const subs = Array.isArray(attr('subcategories')) ? attr('subcategories') : []; + const listed = Array.isArray(attr('subcategories')) ? attr('subcategories') : []; + const subs = node.children.length ? node.children.map((child) => ({ id: child.id, name: child.name })) : listed.map((sub: any) => ({ id: String(sub?.id ?? sub?.public_id ?? ''), name: String(sub?.name ?? '') })); return { - id: String(category?.id ?? attr('id') ?? ''), - name: String(attr('name') ?? ''), - iconUrl: attr('icon_url') ?? null, - subcategories: subs.map((sub: any) => ({ id: String(sub?.id ?? sub?.public_id ?? ''), name: String(sub?.name ?? '') })).filter((sub: any) => sub.name), + id: node.id, + name: node.name, + iconUrl: node.iconUrl, + subcategories: subs.filter((sub: any) => sub.name), }; } /** - * Every category of the store, for large menus: each opens its section on the store page - * (subcategories open their parent's section). + * Every category of the store, for large menus: each top-level category opens its section on + * the store page, and each subcategory its own heading in that section. */ const StoreCatalogScreen = () => { const navigation = useNavigation(); @@ -44,7 +48,7 @@ const StoreCatalogScreen = () => { setFailed(false); storefront.categories .findAll() - .then((result: any) => active && setCategories((Array.from(result || []) as any[]).map(toRow).filter((row) => row.name))) + .then((result: any) => active && setCategories(categoryTree(Array.from(result || []) as any[]).map(toRow).filter((row) => row.name))) .catch(() => active && setFailed(true)); return () => { active = false; @@ -138,7 +142,7 @@ const StoreCatalogScreen = () => { {item.subcategories.map((sub) => ( open(item.id)} + onPress={() => open(sub.id || item.id)} accessibilityRole='link' style={{ height: 34, paddingHorizontal: 12, borderRadius: radius.pill, backgroundColor: theme.surface.val, justifyContent: 'center' }} > diff --git a/src/screens/StoreSearchScreen.tsx b/src/screens/StoreSearchScreen.tsx index 3d23055f..cc442aff 100644 --- a/src/screens/StoreSearchScreen.tsx +++ b/src/screens/StoreSearchScreen.tsx @@ -14,6 +14,7 @@ import { getScopedStorageKey } from '../network/network-runtime'; import { addRecentSearch, removeRecentSearch } from '../network/recent-searches'; import NearbyPlacesSection from './foodtrucks/NearbyPlacesSection'; import { Chip, EmptyState, ErrorState, IconButton, ProductRow, Skeleton, UIText, productSummary, radius, space } from '../ui'; +import { categoryTree } from '../commerce/categories'; const SEARCH_DELAY_MS = 300; @@ -47,7 +48,10 @@ const StoreSearchScreen = () => { .findAll() .then((result: any) => { if (!active) return; - const list = (Array.from(result || []) as any[]).map((category) => ({ id: category.id, name: category.getAttribute('name') })).filter((category) => category.name); + // Top-level categories only: a subcategory opens from its category. + const list = categoryTree(Array.from(result || []) as any[]) + .map((node) => ({ id: node.id, name: node.name })) + .filter((category) => category.name); setCategories(list.slice(0, 8)); }) .catch(() => {}); diff --git a/src/screens/foodtrucks/FoodTruckSearchScreen.tsx b/src/screens/foodtrucks/FoodTruckSearchScreen.tsx index 7bdce0df..e9638be0 100644 --- a/src/screens/foodtrucks/FoodTruckSearchScreen.tsx +++ b/src/screens/foodtrucks/FoodTruckSearchScreen.tsx @@ -50,7 +50,7 @@ const FoodTruckSearchScreen = () => { const needle = trimmed.toLowerCase(); return trucks.flatMap((truck) => truckProducts(truck) - .filter(({ product, categoryName }) => [product?.name, product?.description, categoryName, truck.name].some((text) => String(text ?? '').toLowerCase().includes(needle))) + .filter(({ product, categoryName, topCategoryName }) => [product?.name, product?.description, categoryName, topCategoryName, truck.name].some((text) => String(text ?? '').toLowerCase().includes(needle))) .map(({ product }) => { const orderable = truck.live && truck.inZone; const where = !truck.live ? t('FoodTrucks.offline') : !truck.inZone ? t('FoodTrucks.notHere', { zone: truck.zoneName ?? '' }) : truck.distance; diff --git a/src/screens/foodtrucks/TruckMenuScreen.tsx b/src/screens/foodtrucks/TruckMenuScreen.tsx index 32f81279..99fdae4a 100644 --- a/src/screens/foodtrucks/TruckMenuScreen.tsx +++ b/src/screens/foodtrucks/TruckMenuScreen.tsx @@ -19,7 +19,8 @@ const HERO_HEIGHT = 200; /** Room under the floating back button for the fixed category bar. */ const STICKY_CLEARANCE = 54; -type Section = { key: string; id: string | null; name: string; products: Array<{ product: any; summary: ReturnType }> }; +/** A top-level menu category, with every category under it: each product keeps its place there ("Tacos › Vegetarian"). */ +type Section = { key: string; id: string | null; name: string; ids: Set; products: Array<{ product: any; summary: ReturnType; subPath: string }> }; /** * One truck's products, laid out like a store page: a backdrop that stretches when pulled @@ -44,12 +45,15 @@ const TruckMenuScreen = () => { const truck = useMemo(() => trucks?.find((item) => item.id === params.foodTruckId) ?? (params.truck ? summarizeTruck(params.truck) : null), [params.foodTruckId, params.truck, trucks]); const sections = useMemo(() => { const groups = new Map(); - (truck ? truckProducts(truck) : []).forEach(({ product, categoryId, categoryName }) => { - const key = String(categoryId ?? categoryName); - if (!groups.has(key)) groups.set(key, { key, id: categoryId, name: categoryName || t('FoodTrucks.menu'), products: [] }); - groups.get(key)!.products.push({ product, summary: productSummary(product) }); + (truck ? truckProducts(truck) : []).forEach(({ product, categoryId, topCategoryId, topCategoryName, subPath }) => { + const key = String(topCategoryId ?? topCategoryName); + if (!groups.has(key)) groups.set(key, { key, id: topCategoryId, name: topCategoryName || t('FoodTrucks.menu'), ids: new Set([key]), products: [] }); + const section = groups.get(key)!; + if (categoryId) section.ids.add(String(categoryId)); + section.products.push({ product, summary: productSummary(product), subPath }); }); - return [...groups.values()]; + // Within a category, its own products first, then each subcategory's together. + return [...groups.values()].map((section) => ({ ...section, products: [...section.products].sort((a, b) => (a.subPath === b.subPath ? 0 : !a.subPath ? -1 : !b.subPath ? 1 : 0)) })); }, [t, truck]); // A product can sit in more than one category; recommend it once. const recommended = useMemo(() => { @@ -79,9 +83,11 @@ const TruckMenuScreen = () => { const handledJump = useRef(null); useEffect(() => { const request = params.categoryId ? String(params.categoryId) : null; - if (!request || handledJump.current === request || !sections.some((section) => section.key === request)) return; + // A subcategory opens the section of the category it is under. + const section = sections.find((candidate) => candidate.key === request) ?? sections.find((candidate) => candidate.ids.has(request)); + if (!request || handledJump.current === request || !section) return; handledJump.current = request; - const timer = setTimeout(() => jumpTo(request), 250); + const timer = setTimeout(() => jumpTo(section.key), 250); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [params.categoryId, sections]); @@ -307,8 +313,15 @@ const TruckMenuScreen = () => { {section.name} - {section.products.map((item) => ( - openProduct(item.product)} /> + {section.products.map((item, index) => ( + + {!!item.subPath && item.subPath !== section.products[index - 1]?.subPath && ( + + {item.subPath} + + )} + openProduct(item.product)} /> + ))} ))} diff --git a/src/screens/network/NetworkStoreScreen.tsx b/src/screens/network/NetworkStoreScreen.tsx index c4956382..4fef6e6c 100644 --- a/src/screens/network/NetworkStoreScreen.tsx +++ b/src/screens/network/NetworkStoreScreen.tsx @@ -16,9 +16,11 @@ import useCurrentLocation from '../../hooks/use-current-location'; import useSavedLocations from '../../hooks/use-saved-locations'; import useUnreadNotifications from '../../hooks/use-unread-notifications'; import { storefrontConfig } from '../../utils'; +import { ancestorAt, categoryIndex, categoryTree, groupByCategory, type CategoryGroup, type CategoryNode } from '../../commerce/categories'; import { Button, CartPill, + Chip, ErrorState, IconButton, LocationSheet, @@ -42,7 +44,9 @@ import { const HERO_HEIGHT = 230; /** Room above the sticky catalog bar for the floating back and search buttons. */ const STICKY_CLEARANCE = 54; -type Section = { id: string; name: string; iconUrl: string | null; products: Array<{ summary: ProductSummary; resource: any }> }; +type SectionProduct = { summary: ProductSummary; resource: any }; +/** A top-level category: its products, and the same products in headings by subcategory. */ +type Section = { id: string; name: string; iconUrl: string | null; products: SectionProduct[]; groups: CategoryGroup[] }; /** Categories beyond which the sticky bar also offers the full category list. */ const CATALOG_LINK_AFTER = 5; @@ -93,6 +97,9 @@ const NetworkStoreScreen = ({ route }: any) => { const scrollRef = useRef(null); const scrollY = useRef(new Animated.Value(0)).current; const offsets = useRef>({}); + // Where each subcategory heading sits inside its section, for the subcategory chips and links. + const groupOffsets = useRef>({}); + const tree = useRef>(new Map()); const catalogTop = useRef(0); const barTop = useRef(0); const barHeight = useRef(0); @@ -161,14 +168,20 @@ const NetworkStoreScreen = ({ route }: any) => { (async () => { try { const categories = Array.from((await (single ? storefront.categories.findAll() : storefront.categories.query({ store: currentStore.id }))) || []) as any[]; - const loaded = await Promise.all( - categories.map(async (category) => { - const products = Array.from((await storefront.products.query(single ? { category: category.id } : { category: category.id, store: currentStore.id })) || []) as any[]; + // Top-level categories are the sections; a section's query brings the products of + // every category under it too, and they are shown in headings by subcategory. + const tops = categoryTree(categories); + tree.current = categoryIndex(tops); + const loaded: Section[] = await Promise.all( + tops.map(async (top) => { + const products = Array.from((await storefront.products.query(single ? { category: top.id } : { category: top.id, store: currentStore.id })) || []) as any[]; + const items = products.map((resource) => ({ summary: productSummary(resource), resource })); return { - id: category.id, - name: category.getAttribute('name'), - iconUrl: category.getAttribute('icon_url') ?? null, - products: products.map((resource) => ({ summary: productSummary(resource), resource })), + id: top.id, + name: top.name, + iconUrl: top.iconUrl, + products: items, + groups: groupByCategory(top, items, (item) => item.resource?.getAttribute?.('category') ?? item.resource?.category ?? null), }; }) ); @@ -241,19 +254,34 @@ const NetworkStoreScreen = ({ route }: any) => { if (current !== activeTab) setActiveTab(current); }; - const jumpTo = (id: string) => { + const jumpTo = (id: string, groupId?: string | null) => { setActiveTab(id); - scrollRef.current?.scrollTo({ y: catalogTop.current + (offsets.current[id] ?? 0) - stickyTop - barHeight.current, animated: true }); + const within = groupId && groupId !== id ? (groupOffsets.current[groupId] ?? 0) : 0; + scrollRef.current?.scrollTo({ y: catalogTop.current + (offsets.current[id] ?? 0) + within - stickyTop - barHeight.current, animated: true }); }; + /** A category at any level: its top-level section, then its heading in it (or its nearest heading above). */ + const jumpToCategory = (categoryId: string) => { + const top = ancestorAt(tree.current, categoryId, 1); + const section = sections.find((candidate) => candidate.id === (top?.id ?? categoryId)); + if (!section) return false; + const heading = [3, 2].map((level) => ancestorAt(tree.current, categoryId, level)?.id).find((id) => id && section.groups.some((group) => group.id === id && group.level > 0)); + jumpTo(section.id, heading ?? null); + return true; + }; + + const activeSection = sections.find((section) => section.id === activeTab) ?? null; + const subcategories = activeSection?.groups.filter((group) => group.level === 1) ?? []; + // Opened for a category (from the category list, search or a link): jump to its section // once the catalog has laid out. const handledJump = useRef(null); useEffect(() => { if (!jumpRequest || catalogLoading || handledJump.current === jumpRequest) return; - if (!sections.some((section) => section.id === jumpRequest)) return; + const top = ancestorAt(tree.current, jumpRequest, 1); + if (!sections.some((section) => section.id === (top?.id ?? jumpRequest))) return; handledJump.current = jumpRequest; - const timer = setTimeout(() => jumpTo(jumpRequest), 250); + const timer = setTimeout(() => jumpToCategory(jumpRequest), 250); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [catalogLoading, jumpRequest, sections]); @@ -298,6 +326,14 @@ const NetworkStoreScreen = ({ route }: any) => { )} + {!catalogLoading && subcategories.length > 0 && ( + // The open section's subcategories: Coffee, Tea under Drinks. + + {subcategories.map((group) => ( + activeSection && jumpTo(activeSection.id, group.id)} testID='store-subcategory' /> + ))} + + )} ); @@ -542,8 +578,27 @@ const NetworkStoreScreen = ({ route }: any) => { {section.name} - {section.products.map((product) => ( - openProduct(product)} /> + {section.groups.map((group) => ( + { + groupOffsets.current[group.id] = event.nativeEvent.layout.y; + }} + > + {group.level > 0 && ( + + {group.level === 1 ? group.name : group.path.split(' › ').slice(1).join(' › ')} + + )} + {group.items.map((product) => ( + openProduct(product)} /> + ))} + ))} ))}