Skip to content
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
84 changes: 84 additions & 0 deletions __tests__/commerce/categories.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
33 changes: 32 additions & 1 deletion __tests__/commerce/food-trucks.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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'],
]);
});
});
115 changes: 115 additions & 0 deletions src/commerce/categories.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { 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<string>();
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<string, CategoryNode> {
const index = new Map<string, CategoryNode>();
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<string, CategoryNode>, 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<T>(top: CategoryNode, items: T[], categoryOf: (item: T) => string | null | undefined): CategoryGroup<T>[] {
const groups: CategoryGroup<T>[] = [];
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;
}
41 changes: 35 additions & 6 deletions src/commerce/food-trucks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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<string, TruckCategory>();
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 });
Expand All @@ -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(' › ') : '',
});
}
}
}
Expand Down
24 changes: 14 additions & 10 deletions src/screens/StoreCatalogScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>();
Expand All @@ -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;
Expand Down Expand Up @@ -138,7 +142,7 @@ const StoreCatalogScreen = () => {
{item.subcategories.map((sub) => (
<Pressable
key={sub.id || sub.name}
onPress={() => 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' }}
>
Expand Down
6 changes: 5 additions & 1 deletion src/screens/StoreSearchScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(() => {});
Expand Down
Loading