B2B portal · Next.js App Router
2025
A ventilation equipment portal where a buyer gets from “I need 8,000 m³/h” to a bill of materials and a quote without a sales engineer in the loop: an engineering-faceted catalogue, section-by-section air handling unit selection, a parametric duct fitting configurator, and a customer workspace — all sitting on top of an item catalogue that lives in 1C.
src/01 · The problem
A ventilation manufacturer sells two fundamentally different things. The first is stocked items — fans, grilles, fasteners — where an ordinary catalogue does the job. The second is product that does not exist until it is ordered: an air handling unit assembled from sections for a specific air flow, and duct fittings cut to the dimensions of a specific building.
The second half used to be done by people. The buyer sent an email or phoned, an engineer opened a legacy web configurator run by a subcontractor, picked a casing size, assembled the section train, manually added the controls — sensors, drives, control panel — and sent an answer back. One round trip took hours to a day, and every change of dimensions started it over.
The second source of pain was drift between the site and 1C. Prices, stock and the item catalogue itself are maintained in the accounting system, while the storefront lives its own life. As long as the link between them is a manual export, the storefront lies by construction, and a sales manager re-checks every line anyway.
The third was knowledge that was written down nowhere. Which sensors a water heater requires, when the control panel must be steel rather than plastic, why face velocity must not exceed 3.5 m/s once a humidifier is in the train — all of it lived in one engineer's head and in someone else's legacy script that could be neither read nor verified.
The portal had to turn those three things into code: the selection rules, the link to the accounting system, and the customer's path from air flow to document — with no intermediary.
02 · How it is built
The storefront, the customer workspace and the back office are three different products
with different layouts, different navigation and different permissions. In the App Router
they live as three route groups in one tree: (site), (workspace),
(admin). Each brings its own layout.tsx, but all three share one
data layer, one Prisma schema and one calculation core. Access to the admin zone is cut off
in middleware.ts by the role in the token, before a single component renders.
(site) · storefront
200 files, 100,877 lines. A catalogue faceted by application, by capacity and by equipment category; a five-step selection wizard; cart, quick order, checkout. Dedicated sections for fans, heaters, silencers, dampers, condensing units and mixing assemblies — each with its own parameter card.
(workspace) · customer zone
Orders and their statuses, quotes, customer sites, installations, helpdesk, documents, purchasing analytics, reorder templates. This zone is what turns a one-off purchase into a history: the next order for the same site is assembled from the previous one rather than from scratch.
(admin) · configurator
Fans, filters and filter inserts, water and electric heaters, coolers, evaporators, three types of heat recovery unit, dampers, flexible connectors, silencers, humidifiers, mixing sections, valve actuators, variable frequency drives, sensors, control panels, casing sizes and product series — plus pricing, bulk operations, import comparison and referential integrity checks.
The catalogue is split into family and variant: ProductFamily
holds the description, documentation and SEO metadata, ProductVariant holds
one concrete size with air flow, pressure, power, sound level, dimensions, price and stock.
Facets filter variants, not families: an engineer searches for “under 65 dB and above
3,000 m³/h”, not for a series name. Both tables carry ref1C and
code1C — the join keys to the accounting system.
Facets are routes rather than client state: /catalog/application/[type],
/catalog/capacity/[range], /catalog/equipment/[category], with
numeric ranges arriving in searchParams and collapsing into a single Prisma
where on the server. The reason is not technical: a filtered selection has to
be forwardable to a colleague as a link, and the page has to open with a list already in it
rather than with a spinner.
03 · The calculation layer
src/lib/engineering/ — 25 modules, 13,227 lines — knows nothing about React,
routing or sessions. Numbers and a section train go in; numbers plus two arrays come out:
warnings[] and errors[]. Next to it sits
src/lib/calculators/, another 12 modules: duct aerodynamics, psychrometrics,
climate data, code requirements, kitchen hoods, swimming pools, valve Kvs.
Three reasons to keep them separate. First, the same formulas are called from four places — server components in the storefront, API routes, the Telegram bot, and a standalone static calculator site with no database at all.
Second, pure functions can be checked without standing up infrastructure. Water heater selection is seven numbers in and fourteen out; it needs neither Postgres nor authentication.
Third, a calculation has to be calibratable against real hardware. An empirical pressure-drop formula gives an approximation, and a specific coil from the reference tables behaves differently. So every heat exchanger calculation accepts a set of correction factors stored alongside the database record. All default to 1.0 — the formula works uncalibrated.
// Limits recovered from the legacy configurator's logs and checked against its own results const VELOCITY_LIMITS = { DEFAULT_MIN: 1.5, // m/s — below this, coils work inefficiently DEFAULT_MAX: 8.0, // m/s — ceiling with no extra sections WITH_RECUPERATOR: 9.0, WITH_HEATER: 5.0, WITH_COOLER: 4.0, WITH_HUMIDIFIER: 3.5, // above this, droplets carry over OPTIMAL_MIN: 2.5, OPTIMAL_MAX: 3.5, }; let maxVelocity = VELOCITY_LIMITS.DEFAULT_MAX; if (hasRecuperator) maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_RECUPERATOR); if (hasHeater) maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_HEATER); if (hasCooler) maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_COOLER); if (hasHumidifier) maxVelocity = Math.min(maxVelocity, VELOCITY_LIMITS.WITH_HUMIDIFIER); for (const size of sizes) { const faceArea = (size.width / 1000) * (size.height / 1000); // m² const maxVel = Math.max( airFlowSupply / 3600 / faceArea, airFlowExhaust / 3600 / faceArea, ); if (maxVel > maxVelocity) continue; // casing too small if (maxVel < minVelocity) continue; // casing too large variants.push({ sizeId: size.id, width: size.width, height: size.height, faceArea, isOptimal: maxVel >= VELOCITY_LIMITS.OPTIMAL_MIN && maxVel <= VELOCITY_LIMITS.OPTIMAL_MAX, warnings, }); }
Trimmed: calculation logging and air property fill-in removed. The point of the fragment is a rule that only the engineer used to know — every added section lowers the velocity ceiling, so the casing is sized against the tightest constraint, not against the air flow. The “optimal” flag comes from the same place: landing inside 2.5–3.5 m/s. The customer is shown the whole shortlist rather than a single answer, with notes on why one option beats another.
export interface HeatExchangerCorrectionFactors { power?: number; // duty airPressure?: number; // air-side pressure drop waterPressure?: number; // water-side pressure drop velocity?: number; surface?: number; // surface margin } /** No factor supplied — the value passes through untouched */ function applyCorrection(value: number, factor?: number): number { return factor !== undefined && factor !== null ? value * factor : value; } // inside calculateWaterHeater(): physics first… const massFlowAir = (input.airflow / 3600) * airDensity(input.inletTemp); const thermalPower = massFlowAir * 1.005 * (input.desiredOutletTemp - input.inletTemp); // …then the checks a human used to make… if (waterVelocity < 0.5) warnings.push(`Water velocity ${waterVelocity.toFixed(2)} m/s is below minimum — freezing risk`); if (velocity > 5.0) warnings.push(`Face velocity ${velocity.toFixed(2)} m/s exceeds the limit for a heating coil`); // …and only at the end, calibration against a specific model from the reference tables const cf = input.correctionFactors; if (cf) { correctedPower = applyCorrection(thermalPower, cf.power); airPressureDrop = applyCorrection(airPressureDrop, cf.airPressure); waterPressureDrop = applyCorrection(waterPressureDrop, cf.waterPressure); surfaceReserve = applyCorrection(surfaceReserve, cf.surface); }
The ordering matters more than the formulas. Physics is computed first, warnings are raised against the raw quantities, and correction is applied last. That way a “freezing risk” warning cannot be silently tuned away by a coefficient, and the gap between calculation and datasheet stays visible: a factor that drifts far from 1.0 is a signal that the formula does not fit this product series — not an invitation to nudge it.
04 · Air handling unit configurator
An air handling unit is assembled from sections standing one after another inside a casing: damper, filter, heat recovery, heating coil, fan, silencer. It has two branches — supply and extract — and the order of sections has physical meaning: air leaving the heat recovery unit arrives at the coil in a different state. So the configurator is built as a pipeline where the outlet of one section becomes the inlet of the next.
selectSizeVariants() walks the casing sizes of a series from the database,
computes face velocity and keeps the ones inside the limits. It returns a shortlist with
“optimal” flags and warnings rather than a single answer.
SectionCalculators: filter, rotary and plate heat recovery,
water and electric heating, water and DX cooling, fan, silencer, mixing section, damper,
flexible connector. Each takes a SectionInput — air flow, cross-section,
inlet temperature and humidity — and returns a SectionResult with the outlet
air state. The psychrometrics are real: moisture content, enthalpy, dew point, and wet-bulb
temperature via the Stull approximation.
build-validation.ts check order rather than numbers:
at most one fan per branch, a filter must precede heat recovery and any coil, humidifiers
cannot sit just anywhere, glycol recovery coils only come in pairs, a gas-fired heater
requires an empty section behind it. Findings split into blocking errors, which stop the
calculation, and warnings, which simply sit next to the result.
BOMService adds what the customer never thinks about: differential pressure
switches, frost protection, valve actuators, variable frequency drives, the control panel.
The set is derived from the section train — each sensor in the reference tables is bound to
a section type and flagged either “one per unit” or “one per section”.
ahu-configurator-logic.ts answers the questions that used to depend on an
engineer's recall: control panel material follows motor power and phase count plus total
electric heating load; valve actuator type follows the presence of a water coil (which
forces spring return) and of recirculation; damper count follows cross-sectional area.
The selection rules were not invented from scratch. They were recovered from the legacy configurator: its client-side JavaScript and HTML templates were read through, every function mapped to a new module, and the result written up as a migration register — what was ported, exactly where to, and what was deliberately left behind. Without that register, “porting the business logic” becomes a set of guesses that surface six months later on a live order.
05 · Duct fittings
The other half of the product line is ducts and fittings made to size.
In src/lib/duct-3d/ every type is a TypeScript interface: a set of parameters
carrying the same letters as the shop drawing (A width, B height,
L length, R bend radius, α angle), plus a connection
type on each end — TDC, flange, slide-on rail, cap, mesh.
There are 62 of them: round and rectangular straight runs, bends, concentric and eccentric reducers, tees and trouser pieces, crosses, offsets, caps, eight kinds of saddle tap, cowls, deflectors, nipples, couplings, flanges, grease traps, and insulated variants of each shape.
The preview is built with React Three Fiber straight from those parameters: change a number
in a field and the geometry is rebuilt. A separate DimensionLabels layer
(693 lines) draws dimension lines and leaders, because what an engineer needs is not a
pretty render but a drawing that proves the right thing was ordered.
The same parametric record is reused downstream: it feeds sheet metal nesting and material take-off, the bill of materials, and 3D load planning for transport — because the fitting is represented as numbers rather than as a model file.
06 · Reference data and bulk operations
The configurator lives on equipment reference tables, and those tables arrive as exports: a supplier's spreadsheet, a price list, an update from 1C. Hence three tools in the back office that look dull and save the most time: bulk operations across 21 equipment tables, a diff of two imports broken down into added / removed / modified with a per-field list of changes, and an integrity check — which records ended up with no price, no GUID, or no link into the component price table.
// Flag absent means count only. You get to make this mistake once, // so it had better be the mistake where nothing happened. const dryRun = params.dryRun ?? true; async function autoLinkByGuid(tables?: string[], dryRun = true) { for (const table of tablesToProcess) { // rows that carry a 1C GUID but have no price linked to them const unlinked = await (prisma as any)[table].findMany({ where: { guid: { not: null }, componentPriceId: null }, select: { id: true, guid: true }, }); const prices = await prisma.componentPrice.findMany({ where: { guid: { in: unlinked.map((u) => u.guid) } }, select: { id: true, guid: true }, }); const priceMap = new Map(prices.map((p) => [p.guid, p.id])); for (const item of unlinked) { const priceId = priceMap.get(item.guid); if (!priceId) continue; if (!dryRun) await (prisma as any)[table].update({ where: { id: item.id }, data: { componentPriceId: priceId }, }); linkedCount++; // counted in both modes } details.push(`${table}: ${linkedCount} rows linked`); } }
The counter increments during the dry run too, so the administrator first sees an exact report — “412 rows in the fan table will be linked” — and only then decides whether to commit. Linking goes by the accounting system's GUID, not by name: names in price lists are written however the supplier feels that day, and the GUID is the only thing that survives a rename. Linking by name exists as a separate action, but it comes second and always asks for confirmation.
07 · Sync with 1C
The important decision here was ownership of the data. The answer: 1C owns it. The portal does not create items, does not assign codes and does not edit prices — it keeps a mirror and subscribes to changes.
Reads go over OData: first the folder hierarchy (folders flagged as groups unfold into
categories and subcategories), then the item catalogue, attached to categories through
Parent_Key. Items whose parent could not be resolved are not dropped — they
fall into a separate bucket category where they stay visible and can be sorted out by hand.
Writes come the other way, through webhooks from 1C: five event types (price, stock, item update, deletion, order status), up to 5,000 items per request, field validation before the first database call, bearer token authorisation.
The key detail is the level a price arrives at. In 1C, price and stock
belong to an item's characteristic — that is, to one concrete size — not to the item card.
In the portal's schema that is a ProductVariant, and the webhook updates
exactly that, keyed by the characteristic's GUID. An order status change travels the same
path and reaches the customer as a Telegram notification.
interface WebhookPayload { event_type: 'price_update' | 'stock_update' | 'product_update' | 'product_delete' | 'order_update'; timestamp: string; products: WebhookProduct[]; } // Validate before the first database call: an empty batch and a 50,000-item batch // are equally harmful, and both get rejected here. if (!Array.isArray(payload.products) || payload.products.length === 0) return { valid: false, error: 'products must be a non-empty array' }; if (payload.products.length > 5000) return { valid: false, error: 'Maximum 5000 products per request' }; if (!item.ref_key) return { valid: false, error: `Missing required field: ref_key at index ${i}` }; // Price arrives against an item characteristic, not against the item card: // in the portal's schema that is a ProductVariant, found by its 1C GUID. await prisma.productVariant.update({ where: { ref1C: p.characteristic_ref }, data: { price: p.price, priceOld: p.price_old }, }); // Stock lands in the same place, and the availability flag is derived from // the quantity so that "in stock" and "0 pcs" can never disagree. await prisma.productVariant.update({ where: { ref1C: p.characteristic_ref }, data: { stockQty: p.stock_qty, inStock: (p.stock_qty || 0) > 0 }, });
inStock never arrives from outside — it is computed from the quantity.
That is the only way to guarantee the storefront will not show “in stock” against zero
inventory: two fields updated independently drifting apart is the classic cause of
cancelled orders.
08 · What you can open
The portal itself does not start without a database, a search engine and 1C access — it cannot be shown as a link. But the calculation layer was written not to depend on infrastructure, and that had a direct consequence: two pieces detach from the portal and stand alone. These are live pages, not screen recordings.
Live demo · no server
Duct network aerodynamics, duct sizing, psychrometrics, heat loss, acoustics, smoke extraction, aspiration, swimming pools, kitchen hoods, valve Kvs, climate data, equivalent product lookup, bill of materials. The same calculation cores as in the portal, packaged as a static site: all the maths runs in the browser, with no database, no authentication and no server calls.
Open the tools↗Live demo · 3D
A bill of materials goes in, a load plan comes out. It accounts for nesting fittings inside one another, weight and stacking order, the stability of round sections, and the clearance that TDC flanges and slide-on rails need. Several packing strategies are computed in parallel and the best by utilisation is shown in 3D with real coordinates and rotations.
Open the packer↗Both pages are published out of this same project and may not be deployed yet at the moment you read this.
09 · Numbers
TypeScript files under src/
lines of code
App Router pages
API routes
Prisma models
equipment reference tables
| Layer | Files | Lines | What is inside |
|---|---|---|---|
Storefront (site) | 200 | 100,877 | catalogue, facets, selection wizard, cart, checkout |
Admin configurator (admin) | 85 | 15,712 | 21 sections, of which 44 equipment reference tables |
Workspace (workspace) | 26 | 2,968 | orders, quotes, sites, installations, helpdesk, analytics |
| API routes | 156 (154 route.ts) | 16,262 | catalogue, configurator, 1C webhooks, Telegram, PDF |
| Server actions | 60 | 4,743 | cart, orders, calculations, quick order |
| Components | 285 | 40,351 | catalogue, configurator, 3D, charts, forms |
Calculation core lib/engineering | 25 | 13,227 | sections, selection, validation, BOM, pricing |
Calculators lib/calculators | 12 | 2,391 | aerodynamics, psychrometrics, climate, code limits |
Duct fitting 3D lib/duct-3d | 9 | 5,341 | 62 fitting types, meshes, dimension lines |
| Data model | 1 | 2,802 | 125 models, 15 enums, 29 migrations |
Selecting an air handling unit no longer requires an engineer in the loop: the path “air flow → shortlist of casing sizes → section train → bill of materials with controls → quote” runs end to end in the browser. Two people and an email thread used to stand on that route.
Quick order removes manual entry: paste a list of part numbers straight out of an email, or drop an Excel/CSV file — lines are matched against the item catalogue and land in the cart as a batch. For repeat purchasing on a site that is the difference between twenty minutes and one paste.
Selection rules stopped being oral tradition. 18 topology checks and the controls-selection logic sit in two modules that can be read, argued with and changed — instead of being reconstructed from an engineer's memory or from someone else's script.
Updating reference data stopped being risky: a diff of two imports shows exactly what changed before anything is applied, and bulk operations run in counting mode unless told otherwise.
10 · What is off camera
lib/engineering there are four unit test files totalling 238 lines, and none of
them touches section calculation. The real coverage is nine Playwright scenarios that drive
the UI. The right move here is table-driven tests against reference results from the legacy
configurator: the inputs and expected outputs already exist in its exports, they have simply
never been turned into fixtures.
sync-scheduler.ts is a 26-line stub;
the regular exchange is triggered from outside by an OS-scheduled script. It works, but it
means the exchange state does not live in the application: the sync monitor in the back
office shows results, not a queue.
product_update returns a counter and does not rebuild the item
card — a full resync is a separate script. Updating a price by item code (without a
characteristic GUID) is not implemented: in the current schema price always belongs to a
size, and handling a “price for the whole family” would be guesswork.
as any — where a model has to be reached dynamically by table
name, or an include gets complicated. It is a deliberate trade for speed, but it
removes exactly the protection TypeScript was brought in for, and in those places only a test
will catch a mistake.
params and searchParams were
walked through across the route tree, but pages remain that nobody has opened by hand since —
their behaviour is attested only by the build and the type checker.
The fair summary is this: the engineering half — selection, validation, bill of materials, the 1C exchange — is built and working; the perimeter around it (tests, a unified design, observability of the exchange) trails a step behind. For a portal whose job is to replace an email thread with a sales manager that is the right order of priorities, but not an indefinite one: the next growth of the reference tables is where the perimeter gives first.