Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Catalog OS

Product and price catalog management for a computer hardware retailer, built directly from the store's existing Excel price list.

The source workbook is 38 sheets of real supplier data with no shared structure: eight different column layouts, headers that sometimes exist and sometimes don't, section titles used as grouping, two independent tables sitting side by side in one sheet, three currencies, and a third of the rows missing an article number. This application reads that workbook as it is and turns it into one catalog.

The original workbook is opened read-only and is never modified.


Running it

npm install
npm run dev

The app is at http://localhost:3000. The public catalog is at http://localhost:3000/catalog.

Regenerating the dataset

The shipped dataset in src/data/catalog.json is produced by the import script. To re-run it against the original workbook, or against a newer one:

npm run import:excel -- "path/to/workbook.xlsx"

With no argument it re-reads the copy kept in public/demo/, so the command is reproducible without anyone's local download folder in the source. The script writes:

Output What it is
src/data/catalog.json The normalized dataset the app loads
src/data/analysis.md Per-sheet report: row counts, detected column roles, findings
public/demo/price-update.xlsx The workbook's own Export sheet, kept aside as an update file
public/demo/Tima Labs price list.xlsx A pristine copy of the workbook, used as the default source on later runs

The pipeline

Excel workbook
      ↓
Workbook reader        xlsx → raw rows                     src/lib/pipeline/workbook.ts
      ↓
Layout detection       header labels, column roles,        src/lib/pipeline/layout.ts
                       side-by-side table blocks
      ↓
Row classification     product / section header /          src/lib/pipeline/normalize.ts
                       boilerplate / orphan
      ↓
Normalization          taxonomy, brands, prices,           src/lib/pipeline/taxonomy.ts
                       attributes, availability            src/lib/pipeline/specs.ts
      ↓
Quality analysis       gaps, duplicates, conflicts         src/lib/pipeline/quality.ts
      ↓
catalog.json → application

Every stage is plain TypeScript with no Node-only dependencies, so the same code runs in the browser. When a manager drops a workbook onto /import, it goes through the identical pipeline that produced the shipped dataset — the import screen is not a separate, simplified parser.

Layout detection

Rather than 37 hand-written column mappings, each sheet is profiled and its columns are inferred:

  1. Header labels win. наименование, артикул, опт, цена, характеристики, UZS БЕЗ Ндс and friends are matched to roles. Two or more labels in a row marks it a header row; a lone опт floating above one price column does not.
  2. Shape fills the gaps. Sequential integers are a row counter. A column that is 80% numeric is a price. A single repeated vendor name is the brand. Low-cardinality short text is an in-sheet category. Code-shaped, highly distinct text is an article number.
  3. Remaining prose is the product name. A sheet can contain more than one name column, which is how the two side-by-side tables in Экраны, проекторы, камеры are found and read as separate blocks.

The detected mapping for every sheet is visible in the app under Import → Current catalog source, and in src/data/analysis.md.

Rules that shape the data

These are conventions, not guesses, and each one is stated in the UI:

  • Missing values stay missing. No SKU, price, specification or supplier is ever generated. A gap is rendered as Not available and counted under Data Quality.
  • A blank price means the position is not orderable. That is what a blank price cell means in these supplier lists, and the catalog carries the meaning through instead of inventing stock numbers.
  • The price basis travels with the price. Sheets label price columns inconsistently, so each product records whether its figure came from a retail, wholesale, discount, purchase or unlabelled column. Where a sheet quotes both a plain Цена and an MSRP, the generic column is read as the dealer price.
  • Original names are preserved. Brand-organized sheets often name products by bare model code (KPH8K), so the vendor is prefixed for searchability. The untouched source name is shown on every product page.
  • Export is not a price list. That sheet is a flat re-export of the catalog (1,915 of its 1,946 names appear in other sheets). Importing it would double-count, so it is skipped — and reserved as a genuine update file, since its prices are a version behind.

What the application does

Route
/ Catalog overview — real figures computed from the imported data
/products Search, faceted filtering, sorting, pagination over ~2,500 products
/products/[id] Product detail, pricing by basis, parsed attributes, source row
/data-quality Seven finding types with drill-through to the affected rows
/import Drop a workbook, see the diff, apply it
/price-list Filter the catalog into a printable customer document
/activity Every change made this session
/settings Roles, permissions, dataset provenance
/catalog The public, customer-facing catalog

Search, filtering and sorting all run in memory against a pre-built token string per product — a keystroke costs one pass and no network call.

State

The imported dataset is treated as immutable. Edits, additions, archives and applied imports are stored as an overlay of patches on top of it, persisted to localStorage and synchronized across tabs. So a price changed in the admin updates the public catalog while it is open in another tab, and "what did the workbook actually say" stays answerable. The overlay shape maps onto a products table plus an activity table, which is the migration path to Postgres.

Use Settings → Reset session to return to the imported dataset.

Language and theme

The interface runs in English and Russian, and in light, dark or system theme. Both are in the sidebar footer and both persist across tabs, so the public catalog follows the operator's choice.

The Russian interface is not a veneer over English strings — the catalog data itself is Russian, so the terminology follows the source workbook (артикул, опт, розница, характеристики), category names are translated, numbers and dates use Russian grouping, and the assistant parses Russian questions («Какие видеокарты ASUS дешевле $700 и есть в наличии?»).

Two details worth knowing:

  • Activity events store a translation key plus parameters, not a finished sentence. A price change logged in English still reads correctly after switching to Russian.
  • Plurals go through Intl.PluralRules, because Russian needs three forms (1 товар / 2 товара / 5 товаров) where English needs two.

Adding a third language means adding one object to src/lib/i18n/dictionary.ts; Dict is derived from the English object, so a missing key is a compile error rather than a blank label at runtime.

The theme is applied by a tiny inline script in <head> before first paint, so there is no flash of the wrong theme. Printing always forces the light palette — a customer's price list should not depend on what the operator's screen was set to.

The assistant

Ask about catalog parses a question into the same Filters object the products page uses, then answers from memory — no model call, no data leaving the browser. Intent parsing and answering are separate, so connecting a real LLM means replacing parseQuestion in src/lib/assistant.ts and nothing else.


Before deploying

public/demo/ holds a copy of the original workbook and the update file, so the demo works offline and the "here is the Excel we started from" moment needs no setup. Both contain real supplier pricing and are served publicly — delete public/demo/ before deploying this anywhere but localhost.

Privacy

The workbook was scanned before anything was published: no email addresses, no personal names, no supplier contacts and no credentials are present. Phone-shaped matches turned out to be AMD part numbers. The only external link is a public Telegram channel in footer rows, which is excluded from product data along with payment-terms boilerplate. The public catalog additionally shows only products that carry a price — internal gaps are not published.

Stack

Next.js 15 (App Router), TypeScript, Tailwind CSS v4, lucide-react, SheetJS. No backend, no database, no auth — a real database can be added behind the same domain model without touching the UI.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages