category changes
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTenantCategories } from '@/queries/hooks';
|
||||
import { CatalogueBrowser } from '@/features/catalogue/CatalogueBrowser';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { categoryOptionsFor } from '@/features/catalogue/tenantCategories';
|
||||
|
||||
/**
|
||||
* The Store Admin's half of the catalogue: browse it, add to your own products.
|
||||
@@ -26,35 +23,11 @@ export function CataloguePanel() {
|
||||
*/
|
||||
const locationid = current?.locationid ?? branches[0]?.locationid;
|
||||
|
||||
const categories = useTenantCategories(tenantid);
|
||||
|
||||
/**
|
||||
* No "Leave uncategorised" entry, and its absence is the fix.
|
||||
*
|
||||
* It sat first in this list, so it was what the picker showed until somebody
|
||||
* changed it — and it wrote `categoryid` 0, which the customer app cannot
|
||||
* return under any category it asks for. It was not an opt-out from filing a
|
||||
* product; it was an opt-out from selling it, offered as the default.
|
||||
*
|
||||
* A tenant with no categories of its own is not refused either, which was
|
||||
* the overcorrection: the refusal was right about `categoryid` 0 and wrong
|
||||
* about who it caught. `gettenantcategories` is derived from products a
|
||||
* tenant already has, so the shops it blocked were new ones importing their
|
||||
* first product — 37 of the 45 most recent tenants. See
|
||||
* `categoryOptionsFor` for the floor that resolves it.
|
||||
*/
|
||||
const categoryOptions = useMemo(
|
||||
() => categoryOptionsFor(categories.data, categories.isLoading),
|
||||
[categories.data, categories.isLoading],
|
||||
);
|
||||
|
||||
return (
|
||||
<CatalogueBrowser
|
||||
tenantid={tenantid || undefined}
|
||||
locationid={locationid}
|
||||
categoryOptions={categoryOptions}
|
||||
actionLabel="Add to my products"
|
||||
alignWithTabs
|
||||
{...(!tenantid || !locationid
|
||||
? { blockedReason: 'No outlet is selected, so there is nowhere to file an import.' }
|
||||
: {})}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BookOpen, EyeOff, Package, Ruler, Tag } from 'lucide-react';
|
||||
import { productsApi } from '@/api/products';
|
||||
import type { Product } from '@/api/types';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
import { useCatalogueProduct } from '@/queries/hooks';
|
||||
import { useAppAisles, useCatalogueProduct } from '@/queries/hooks';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { Drawer } from './Drawer';
|
||||
import {
|
||||
@@ -67,6 +67,13 @@ export function ProductDrawer({
|
||||
const [confirmHide, setConfirmHide] = useState(false);
|
||||
const [shot, setShot] = useState(0);
|
||||
|
||||
/* The aisle name for this product's subcategoryid, from the same list the
|
||||
customer app groups by. */
|
||||
const aisles = useAppAisles(tenantid || undefined);
|
||||
const aisleName =
|
||||
(aisles.data ?? []).find((row) => row.subcatid === (product.subcategoryid ?? 0))?.subcatname ??
|
||||
null;
|
||||
|
||||
const source = useCatalogueProduct(product.productbrand, product.productsku);
|
||||
const images = imagesOf(product);
|
||||
const state = stateOf(product);
|
||||
@@ -198,10 +205,17 @@ export function ProductDrawer({
|
||||
value={isPublished(product) ? 'Yes' : 'Not yet'}
|
||||
{...(isPublished(product) ? { tone: 'success' as const } : {})}
|
||||
/>
|
||||
{/* The AISLE, which is what a shopper sees.
|
||||
`categoryname` is empty on every row — the app's category 2 has no
|
||||
row in `productcategories` — and `getlocationproducts` joins no
|
||||
subcategory table, so the name is looked up from the platform list
|
||||
the customer app itself groups by. "Uncategorized" is not a
|
||||
cosmetic default here: it is the heading the app will print. */}
|
||||
<Row
|
||||
label="Category"
|
||||
value={product.categoryname || product.Subcategoryname || 'Uncategorised'}
|
||||
label="Shown in the app under"
|
||||
value={aisleName ?? 'Uncategorized'}
|
||||
icon={<Tag size={14} />}
|
||||
{...(aisleName ? {} : { tone: 'muted' as const })}
|
||||
/>
|
||||
<Row
|
||||
label="Unit"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ImageOff } from 'lucide-react';
|
||||
import type { Product } from '@/api/types';
|
||||
import { SectionHeader } from '@/components/SectionHeader';
|
||||
import { useLocationProducts, useTenantCategories } from '@/queries/hooks';
|
||||
import { useAppAisles, useLocationProducts } from '@/queries/hooks';
|
||||
import { useBranchScope } from './BranchScope';
|
||||
import { count, money } from './format';
|
||||
import { ProductDrawer } from './ProductDrawer';
|
||||
@@ -71,7 +71,23 @@ export function ProductsPanel({
|
||||
const products = useLocationProducts(tenantid || undefined, current?.locationid, 0, {
|
||||
allBranches: true,
|
||||
});
|
||||
const categories = useTenantCategories(tenantid || undefined);
|
||||
/**
|
||||
* The aisles, not the categories, because the aisle is what a shopper sees.
|
||||
*
|
||||
* `gettenantcategories` reports one entry for every tenant — category 2 —
|
||||
* so the filter it fed was a dropdown with a single option, and the column
|
||||
* beside it read "—" on every row. What actually separates products in the
|
||||
* customer app is `subcategoryid`, one of ten platform rows; see
|
||||
* `appAisle.ts`.
|
||||
*/
|
||||
const aisles = useAppAisles(tenantid || undefined);
|
||||
const aisleNames = useMemo(() => {
|
||||
const out = new Map<number, string>();
|
||||
for (const row of aisles.data ?? []) {
|
||||
if (row.subcatid > 0 && row.subcatname) out.set(row.subcatid, row.subcatname);
|
||||
}
|
||||
return out;
|
||||
}, [aisles.data]);
|
||||
|
||||
/**
|
||||
* One list, ordered by what needs doing.
|
||||
@@ -82,7 +98,7 @@ export function ProductsPanel({
|
||||
*/
|
||||
const rows = useMemo(() => {
|
||||
const filtered = (products.data ?? []).filter(
|
||||
(product) => !category || String(product.categoryid ?? '') === category,
|
||||
(product) => !category || String(product.subcategoryid ?? 0) === category,
|
||||
);
|
||||
const rank: Record<ProductState, number> = { unpriced: 0, unstocked: 1, live: 2 };
|
||||
return [...filtered].sort((a, b) => {
|
||||
@@ -94,13 +110,16 @@ export function ProductsPanel({
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'All categories' },
|
||||
...(categories.data ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
{ value: '', label: 'All aisles' },
|
||||
// Offered whether or not anything is in it — a shop wanting to know what
|
||||
// the app cannot place should not have to guess that the option exists.
|
||||
{ value: '0', label: 'Uncategorized' },
|
||||
...(aisles.data ?? []).map((entry) => ({
|
||||
value: String(entry.subcatid),
|
||||
label: entry.subcatname,
|
||||
})),
|
||||
],
|
||||
[categories.data],
|
||||
[aisles.data],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -124,6 +143,7 @@ export function ProductsPanel({
|
||||
|
||||
<ProductTable
|
||||
rows={rows}
|
||||
aisleNames={aisleNames}
|
||||
queryKey={category}
|
||||
isLoading={products.isLoading}
|
||||
onOpen={setOpen}
|
||||
@@ -159,12 +179,15 @@ export function ProductsPanel({
|
||||
|
||||
function ProductTable({
|
||||
rows,
|
||||
aisleNames,
|
||||
isLoading,
|
||||
onOpen,
|
||||
onPrice,
|
||||
queryKey,
|
||||
}: {
|
||||
rows: Product[];
|
||||
/** Subcategory id to the aisle name the customer app shows it under. */
|
||||
aisleNames: ReadonlyMap<number, string>;
|
||||
isLoading: boolean;
|
||||
onOpen: (product: Product) => void;
|
||||
onPrice: (product: Product) => void;
|
||||
@@ -251,6 +274,7 @@ function ProductTable({
|
||||
<Row
|
||||
key={product.productid}
|
||||
product={product}
|
||||
aisleNames={aisleNames}
|
||||
onOpen={() => onOpen(product)}
|
||||
onPrice={() => onPrice(product)}
|
||||
/>
|
||||
@@ -276,10 +300,12 @@ function ProductTable({
|
||||
*/
|
||||
function Row({
|
||||
product,
|
||||
aisleNames,
|
||||
onOpen,
|
||||
onPrice,
|
||||
}: {
|
||||
product: Product;
|
||||
aisleNames: ReadonlyMap<number, string>;
|
||||
onOpen: () => void;
|
||||
onPrice: () => void;
|
||||
}) {
|
||||
@@ -337,7 +363,13 @@ function Row({
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{product.categoryname || product.Subcategoryname || '—'}
|
||||
{/* The aisle id is on the row; the NAME is not — `getlocationproducts`
|
||||
selects `a.*` and joins no subcategory table — so it is looked up
|
||||
from the platform list the app itself groups by. */}
|
||||
{aisleNames.get(product.subcategoryid ?? 0) ??
|
||||
product.categoryname ??
|
||||
product.Subcategoryname ??
|
||||
'Uncategorized'}
|
||||
</span>
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
|
||||
@@ -296,9 +296,10 @@ export function TenantSheetImportPanel({ tenantid, locationid }: TenantSheetImpo
|
||||
/>
|
||||
|
||||
<Text type="body" size="sm" style={{ color: 'var(--color-ink-3)' }}>
|
||||
Required columns: productname, productsku, categoryid, retailprice, productcost. The
|
||||
catalogue's category names do not map to a tenant's own ids, so categoryid must be in
|
||||
the file.
|
||||
Required columns: productname, productsku, retailprice, productcost. A categoryid
|
||||
column is not one of them and is ignored if present — the Category column below shows
|
||||
what each row was classified as, and that name is exchanged for your own category id
|
||||
when the products are created.
|
||||
</Text>
|
||||
|
||||
{parseError ? (
|
||||
|
||||
91
src/features/store-admin/appAisle.test.ts
Normal file
91
src/features/store-admin/appAisle.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The fold from the catalogue's 31 categories into the app's ten aisles.
|
||||
*
|
||||
* The stakes here are not cosmetic. `subcategoryid` is the only thing the
|
||||
* customer app groups by, and a category that folds to nothing lands the
|
||||
* product in "Uncategorized" — which is the state the whole platform was in.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
APP_AISLES,
|
||||
FALLBACK_AISLE_IDS,
|
||||
aisleForCategory,
|
||||
aisleIdForCategory,
|
||||
aisleIdsFrom,
|
||||
} from './appAisle';
|
||||
import { CATEGORY_REGISTRY, UNKNOWN_CATEGORY } from './productCategory';
|
||||
|
||||
const ids = aisleIdsFrom(undefined);
|
||||
|
||||
test('every one of the 31 categories folds into an aisle', () => {
|
||||
const orphans = CATEGORY_REGISTRY.map((entry) => entry.category).filter(
|
||||
(category) => aisleForCategory(category) === null,
|
||||
);
|
||||
assert.deepEqual(orphans, [], 'a category with no aisle is a product the app cannot show');
|
||||
});
|
||||
|
||||
// "General" is the ladder saying it does not know. Inventing an aisle for it
|
||||
// would put an unidentified product on a real shelf, which is worse than the
|
||||
// app's own "Uncategorized" heading — that one at least tells the truth.
|
||||
test('the unknown bucket deliberately has no aisle', () => {
|
||||
assert.equal(aisleForCategory(UNKNOWN_CATEGORY), null);
|
||||
assert.equal(aisleIdForCategory(UNKNOWN_CATEGORY, ids), 0);
|
||||
});
|
||||
|
||||
test('a name that is not a category at all folds to nothing', () => {
|
||||
assert.equal(aisleForCategory(''), null);
|
||||
assert.equal(aisleForCategory(undefined), null);
|
||||
assert.equal(aisleForCategory('Category 2'), null, 'the old default is not an aisle');
|
||||
});
|
||||
|
||||
test('the things a shopper would look for land where they would look', () => {
|
||||
assert.equal(aisleForCategory('Spices & Masalas'), 'Foodgrains & Pulses');
|
||||
assert.equal(aisleForCategory('Dairy'), 'Dairy, Deli & Egg');
|
||||
assert.equal(aisleForCategory('Hair Care'), 'Beauty & Personal Care');
|
||||
assert.equal(aisleForCategory('Household Cleaning'), 'Hygiene Essentials');
|
||||
assert.equal(aisleForCategory('Cooking Oils'), 'Oil & Ghee');
|
||||
assert.equal(aisleForCategory('Biscuits & Cookies'), 'Bakes & Nuts');
|
||||
});
|
||||
|
||||
// The registry writes an en-dash; a sheet typed by hand will not. Two strings
|
||||
// that read the same must not reach different aisles.
|
||||
test('dash and spacing variants reach the same aisle', () => {
|
||||
assert.equal(aisleForCategory('Household – Agarbatti'), 'Hygiene Essentials');
|
||||
assert.equal(aisleForCategory('Household - Agarbatti'), 'Hygiene Essentials');
|
||||
assert.equal(aisleForCategory('household—agarbatti'), 'Hygiene Essentials');
|
||||
assert.equal(aisleForCategory(' HEALTH CARE – ANTISEPTIC '), 'Hygiene Essentials');
|
||||
});
|
||||
|
||||
/* ── Ids come from the platform's list, never from the wrong table ────────── */
|
||||
|
||||
// `productsubcategories` holds these ten names at 14-23; the app's utils list
|
||||
// holds the same ten at 9-18. Matching on the name is what keeps a product out
|
||||
// of the second set, which the grouping does not read.
|
||||
test('ids are taken from the rows the API returned, by name', () => {
|
||||
const live = aisleIdsFrom([
|
||||
{ subcatid: 15, subcatname: 'Dairy, Deli & Egg', categoryid: 2 },
|
||||
{ subcatid: 21, subcatname: 'Snacks & Drinks', categoryid: 2 },
|
||||
]);
|
||||
assert.equal(aisleIdForCategory('Dairy', live), 15);
|
||||
assert.equal(aisleIdForCategory('Chocolates', live), 21);
|
||||
});
|
||||
|
||||
// A name the API did not return has no id from it — but the aisle is still one
|
||||
// of the ten, so the last-known id is better than 0, which means "no aisle".
|
||||
test('a missing row falls back to the last known id rather than to zero', () => {
|
||||
const partial = aisleIdsFrom([{ subcatid: 15, subcatname: 'Dairy, Deli & Egg', categoryid: 2 }]);
|
||||
assert.equal(aisleIdForCategory('Cooking Oils', partial), FALLBACK_AISLE_IDS['Oil & Ghee']);
|
||||
});
|
||||
|
||||
test('an empty response falls back to the whole known list', () => {
|
||||
const empty = aisleIdsFrom([]);
|
||||
assert.equal(empty.size, APP_AISLES.length);
|
||||
assert.equal(aisleIdForCategory('Eggs', empty), FALLBACK_AISLE_IDS['Dairy, Deli & Egg']);
|
||||
});
|
||||
|
||||
test('every aisle has an id, and no two share one', () => {
|
||||
const values = APP_AISLES.map((aisle) => FALLBACK_AISLE_IDS[aisle]);
|
||||
assert.equal(new Set(values).size, values.length);
|
||||
assert.ok(values.every((id) => id > 0));
|
||||
});
|
||||
198
src/features/store-admin/appAisle.ts
Normal file
198
src/features/store-admin/appAisle.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The aisle the customer app actually displays, and how a category becomes one.
|
||||
*
|
||||
* ── What the app does ───────────────────────────────────────────────────────
|
||||
*
|
||||
* Its browse screen calls
|
||||
*
|
||||
* GET /mob/products/getproductsbysubcategory?categoryid=2&tenantid=…&locationid=…
|
||||
*
|
||||
* and that endpoint does two things (`services/productService.go:237`):
|
||||
*
|
||||
* 1. `GetProducts` filters on `a.categoryid = ?` — UNCONDITIONALLY, with the
|
||||
* 2 from the query string.
|
||||
* 2. It then buckets the result by `products.subcategoryid`, against the rows
|
||||
* of `productsubcategories` for category 2. Anything with subcategoryid 0
|
||||
* falls into a bucket literally named "Uncategorized".
|
||||
*
|
||||
* So the heading a shopper reads is the SUBCATEGORY, not the category — and
|
||||
* `categoryid` is not a label at all, it is the filter that decides whether the
|
||||
* product comes back. Measured on live tenant 1135/1166: every product returns
|
||||
* `categoryid: 2, subcategoryid: 0`, so the app renders a single heading,
|
||||
* "Uncategorized", holding the whole shop. That is exactly the report from the
|
||||
* app side, and it is why nothing displays category-wise.
|
||||
*
|
||||
* TWO CONSEQUENCES, both the opposite of what looks obvious:
|
||||
*
|
||||
* - `categoryid` must stay 2. Writing a per-product categoryid — 91 for
|
||||
* Dairy, 92 for Spices — does not label the product, it removes it: the app
|
||||
* asks for 2 and the row no longer matches.
|
||||
* - `subcategoryid` is what has to be written, and it can only be one of the
|
||||
* ten rows `productsubcategories` holds for category 2. Those are platform
|
||||
* rows shared by every tenant, not something a shop creates.
|
||||
*
|
||||
* ── Why the ids are looked up rather than hardcoded ─────────────────────────
|
||||
*
|
||||
* `productsubcategories` (subcatid 14-23) and the app's own utils list
|
||||
* (`/mob/utils/getsubcategories`, ids 9-18) carry the SAME TEN NAMES under
|
||||
* different ids. The grouping reads the first, so those are the ids to write —
|
||||
* but two tables one offset apart is precisely the shape that gets confused, so
|
||||
* the ids are read back by name from the same API the grouping uses, and
|
||||
* `FALLBACK_AISLE_IDS` is used only when that call cannot be made.
|
||||
*/
|
||||
|
||||
import type { ProductSubCategory } from '@/api/types';
|
||||
|
||||
/** The ten aisles, in the order the app lists them. */
|
||||
export const APP_AISLES = [
|
||||
'Vegetables & Fruits',
|
||||
'Dairy, Deli & Egg',
|
||||
'Meat, Chicken & Fish',
|
||||
'Bakes & Nuts',
|
||||
'Foodgrains & Pulses',
|
||||
'Oil & Ghee',
|
||||
'Rice & Cereals',
|
||||
'Snacks & Drinks',
|
||||
'Beauty & Personal Care',
|
||||
'Hygiene Essentials',
|
||||
] as const;
|
||||
|
||||
export type AppAisle = (typeof APP_AISLES)[number];
|
||||
|
||||
/**
|
||||
* The ids as `productsubcategories` holds them today, read from live on
|
||||
* 2026-09-08. Used only when the lookup cannot be made — see the header.
|
||||
*/
|
||||
export const FALLBACK_AISLE_IDS: Record<AppAisle, number> = {
|
||||
'Vegetables & Fruits': 14,
|
||||
'Dairy, Deli & Egg': 15,
|
||||
'Meat, Chicken & Fish': 16,
|
||||
'Bakes & Nuts': 17,
|
||||
'Foodgrains & Pulses': 18,
|
||||
'Oil & Ghee': 19,
|
||||
'Rice & Cereals': 20,
|
||||
'Snacks & Drinks': 21,
|
||||
'Beauty & Personal Care': 22,
|
||||
'Hygiene Essentials': 23,
|
||||
};
|
||||
|
||||
/**
|
||||
* The catalogue team's 31 categories, folded into the app's ten aisles.
|
||||
*
|
||||
* The 31 are a finer classification than the shop floor has room for, so this
|
||||
* is a real narrowing and some of it is judgement rather than fact: "Spices &
|
||||
* Masalas" has no aisle of its own and goes to Foodgrains & Pulses, the nearest
|
||||
* the app offers. Where a category could sit in two it goes to the one a
|
||||
* shopper would look in first — instant noodles to Snacks & Drinks rather than
|
||||
* Rice & Cereals, because that is where a shop stocks them.
|
||||
*
|
||||
* Anything not listed — including "General", the ladder's explicit "we do not
|
||||
* know" — has no aisle, and `aisleForCategory` returns null rather than
|
||||
* guessing. Those products land in the app's own "Uncategorized" bucket, which
|
||||
* is honest: it says the classification failed, instead of shelving bleach with
|
||||
* the butter.
|
||||
*/
|
||||
const AISLE_FOR_CATEGORY: Record<string, AppAisle> = {
|
||||
/* Bakes & Nuts */
|
||||
'biscuits & cookies': 'Bakes & Nuts',
|
||||
rusk: 'Bakes & Nuts',
|
||||
crackers: 'Bakes & Nuts',
|
||||
'cakes & muffins': 'Bakes & Nuts',
|
||||
'bakery & breads': 'Bakes & Nuts',
|
||||
|
||||
/* Snacks & Drinks */
|
||||
snacks: 'Snacks & Drinks',
|
||||
chocolates: 'Snacks & Drinks',
|
||||
'candy & confectionery': 'Snacks & Drinks',
|
||||
beverages: 'Snacks & Drinks',
|
||||
'noodles & instant food': 'Snacks & Drinks',
|
||||
|
||||
/* Foodgrains & Pulses */
|
||||
'pulses, grains & spices': 'Foodgrains & Pulses',
|
||||
'spices & masalas': 'Foodgrains & Pulses',
|
||||
'salt & staples': 'Foodgrains & Pulses',
|
||||
'sugar & jaggery': 'Foodgrains & Pulses',
|
||||
|
||||
/* Rice & Cereals */
|
||||
'atta & staples': 'Rice & Cereals',
|
||||
|
||||
/* Oil & Ghee */
|
||||
'cooking oils': 'Oil & Ghee',
|
||||
|
||||
/* Dairy, Deli & Egg */
|
||||
dairy: 'Dairy, Deli & Egg',
|
||||
eggs: 'Dairy, Deli & Egg',
|
||||
|
||||
/* Meat, Chicken & Fish */
|
||||
'fish & seafood': 'Meat, Chicken & Fish',
|
||||
|
||||
/* Vegetables & Fruits */
|
||||
'fruits & vegetables': 'Vegetables & Fruits',
|
||||
'fresh herbs & greens': 'Vegetables & Fruits',
|
||||
flowers: 'Vegetables & Fruits',
|
||||
|
||||
/* Beauty & Personal Care */
|
||||
'oral care': 'Beauty & Personal Care',
|
||||
'hair care': 'Beauty & Personal Care',
|
||||
'bath soap': 'Beauty & Personal Care',
|
||||
'skin & bath care': 'Beauty & Personal Care',
|
||||
'fragrance & deodorants': 'Beauty & Personal Care',
|
||||
|
||||
/* Hygiene Essentials */
|
||||
'household cleaning': 'Hygiene Essentials',
|
||||
'health care - antiseptic': 'Hygiene Essentials',
|
||||
'household - agarbatti': 'Hygiene Essentials',
|
||||
'household - lamp oil': 'Hygiene Essentials',
|
||||
};
|
||||
|
||||
/**
|
||||
* Every dash normalised to a plain hyphen before lookup.
|
||||
*
|
||||
* The registry writes "Household – Agarbatti" with an en-dash and a sheet typed
|
||||
* by hand will not. Two strings that read identically must not reach different
|
||||
* aisles because of which key somebody pressed.
|
||||
*/
|
||||
function keyOf(category: string | null | undefined): string {
|
||||
return (category ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[‐-―]/g, '-')
|
||||
.replace(/\s*-\s*/g, ' - ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** The aisle a classified category belongs in, or null when there is none. */
|
||||
export function aisleForCategory(category: string | null | undefined): AppAisle | null {
|
||||
return AISLE_FOR_CATEGORY[keyOf(category)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aisle name to the id the app groups on, from the platform's own list.
|
||||
*
|
||||
* Matched on the NAME, because the id is exactly what differs between the two
|
||||
* tables carrying these ten rows. A name the API does not return has no id, and
|
||||
* the caller writes 0 rather than a number out of the wrong table.
|
||||
*/
|
||||
export function aisleIdsFrom(
|
||||
subcategories: readonly ProductSubCategory[] | undefined,
|
||||
): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
for (const row of subcategories ?? []) {
|
||||
const name = (row.subcatname ?? '').trim();
|
||||
if (name && row.subcatid > 0) out.set(name.toLowerCase(), row.subcatid);
|
||||
}
|
||||
if (out.size === 0) {
|
||||
for (const [name, id] of Object.entries(FALLBACK_AISLE_IDS)) out.set(name.toLowerCase(), id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The subcategory id for a classified category — 0 when it has no aisle. */
|
||||
export function aisleIdForCategory(
|
||||
category: string | null | undefined,
|
||||
aisleIds: ReadonlyMap<string, number>,
|
||||
): number {
|
||||
const aisle = aisleForCategory(category);
|
||||
if (!aisle) return 0;
|
||||
return aisleIds.get(aisle.toLowerCase()) ?? FALLBACK_AISLE_IDS[aisle];
|
||||
}
|
||||
Reference in New Issue
Block a user