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.
npm install
npm run devThe app is at http://localhost:3000. The public catalog is at
http://localhost:3000/catalog.
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 |
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.
Rather than 37 hand-written column mappings, each sheet is profiled and its columns are inferred:
- 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. - 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.
- 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.
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 anMSRP, 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. Exportis 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.
| 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.
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.
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.
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.
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.
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.
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.