@metron-labs/quote-react 0.1.74 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,31 +1,31 @@
1
- Copyright (c) 2026 Metron Labs Ltd. All rights reserved.
2
-
3
- This software and its accompanying files, including images and stylesheets (the
4
- "Software"), are proprietary to Metron Labs Ltd ("Metron Labs").
5
-
6
- Metron Labs grants you a non-exclusive, non-transferable licence to install the Software and
7
- include it in websites and applications you operate, solely to provide Metron Labs's services
8
- to your own customers, for as long as you hold a current agreement with Metron Labs that
9
- covers the Software. That agreement governs your use of the Software and takes
10
- precedence over this notice.
11
-
12
- Except as that agreement expressly permits, you may not:
13
-
14
- - copy, modify, merge or create derivative works of the Software;
15
- - distribute, publish, sublicense, sell or otherwise make the Software available to
16
- any third party, other than as part of your own website or application;
17
- - use the Software with any service other than Metron Labs'; or
18
- - reverse engineer the Software, except to the extent the law allows despite this
19
- restriction.
20
-
21
- Your licence ends automatically when your agreement with Metron Labs ends. You must then stop
22
- using the Software and remove it from your websites, applications and systems.
23
-
24
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
25
- INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
26
- PURPOSE AND NON-INFRINGEMENT, EXCEPT AS SET OUT IN YOUR AGREEMENT WITH Metron Labs. TO THE
27
- EXTENT PERMITTED BY LAW, METRON LABS IS NOT LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY
28
- ARISING FROM OR IN CONNECTION WITH THE SOFTWARE OR ITS USE, EXCEPT AS SET OUT IN THAT
29
- AGREEMENT.
30
-
31
- For licensing questions, contact your Metron Labs account manager.
1
+ Copyright (c) 2026 Metron Labs Ltd. All rights reserved.
2
+
3
+ This software and its accompanying files, including images and stylesheets (the
4
+ "Software"), are proprietary to Metron Labs Ltd ("Metron Labs").
5
+
6
+ Metron Labs grants you a non-exclusive, non-transferable licence to install the Software and
7
+ include it in websites and applications you operate, solely to provide Metron Labs's services
8
+ to your own customers, for as long as you hold a current agreement with Metron Labs that
9
+ covers the Software. That agreement governs your use of the Software and takes
10
+ precedence over this notice.
11
+
12
+ Except as that agreement expressly permits, you may not:
13
+
14
+ - copy, modify, merge or create derivative works of the Software;
15
+ - distribute, publish, sublicense, sell or otherwise make the Software available to
16
+ any third party, other than as part of your own website or application;
17
+ - use the Software with any service other than Metron Labs'; or
18
+ - reverse engineer the Software, except to the extent the law allows despite this
19
+ restriction.
20
+
21
+ Your licence ends automatically when your agreement with Metron Labs ends. You must then stop
22
+ using the Software and remove it from your websites, applications and systems.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
25
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
26
+ PURPOSE AND NON-INFRINGEMENT, EXCEPT AS SET OUT IN YOUR AGREEMENT WITH Metron Labs. TO THE
27
+ EXTENT PERMITTED BY LAW, METRON LABS IS NOT LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY
28
+ ARISING FROM OR IN CONNECTION WITH THE SOFTWARE OR ITS USE, EXCEPT AS SET OUT IN THAT
29
+ AGREEMENT.
30
+
31
+ For licensing questions, contact your Metron Labs account manager.
package/README.md CHANGED
@@ -1,253 +1,253 @@
1
- # @metron-labs/quote-react
2
-
3
- Metron's quoting tools as React components you place in your own website:
4
-
5
- - **Quick Quote** — your customers upload a schedule, paste a materials list or photograph
6
- a handwritten one. Metron matches each line to products in your catalogue, then shows
7
- prices, quantities and a basket they can download as a spreadsheet.
8
- - **Estimating Tools** — 12 calculators (fencing, decking, brick and block, paint, timber,
9
- paving and more) that turn a customer's measurements into a priced list of your products.
10
-
11
- Both widgets call Metron's API straight from the browser, so you don't need your own server.
12
-
13
- ## Before you start
14
-
15
- Your Metron account manager will give you:
16
-
17
- | What | Example | Used for |
18
- | ---------------- | ----------- | -------------------------------------------- |
19
- | Client code | `ABCD-1234` | Identifies your catalogue. Not secret. |
20
- | API key | `boq_...` | Authenticates the widgets with Metron's API. |
21
- | npm access token | `npm_...` | Installing this package, which is private. |
22
-
23
- Tell Metron every web address the widgets will run on: production, staging and local
24
- development (for example `http://localhost:3000`). The API key only works from addresses
25
- registered to your account (see [Security](#security)).
26
-
27
- ## Requirements
28
-
29
- - React 18.3 or later, or React 19.
30
- - A bundler that can import images and CSS, such as Vite, Next.js, webpack 5 or Parcel.
31
- The widget images ship inside the package, and your build serves them like your own.
32
-
33
- Not using React? Ask Metron for the script-tag version, which works on any website.
34
-
35
- ## Install
36
-
37
- The package is private, so npm needs your access token. Add a `.npmrc` file to your
38
- project root:
39
-
40
- ```ini
41
- @metron-labs:registry=https://registry.npmjs.org/
42
- //registry.npmjs.org/:_authToken=${NPM_TOKEN}
43
- ```
44
-
45
- Set `NPM_TOKEN` in your shell and CI, then install:
46
-
47
- ```sh
48
- npm install @metron-labs/quote-react
49
- ```
50
-
51
- Don't commit the token itself; `${NPM_TOKEN}` reads it from the environment.
52
-
53
- ## Quick start
54
-
55
- ```tsx
56
- import { QuoteProvider, QuickQuote } from "@metron-labs/quote-react";
57
- import "@metron-labs/quote-react/styles.css";
58
-
59
- const quoteConfig = {
60
- apiBaseUrl: "https://api.metronlabs.ai",
61
- client: "ABCD-1234",
62
- apiKey: "boq_...",
63
- theme: { "--accent": "#0a7d3b" },
64
- };
65
-
66
- export default function QuotePage() {
67
- return (
68
- <QuoteProvider config={quoteConfig}>
69
- <QuickQuote />
70
- </QuoteProvider>
71
- );
72
- }
73
- ```
74
-
75
- For the calculators, render `<EstimatingTools />` in the same way.
76
-
77
- Import `styles.css` once, anywhere in your app. Without it the widgets render unstyled.
78
-
79
- ### Both widgets on one site
80
-
81
- Use one `QuoteProvider` per page, and put every widget on that page inside it:
82
-
83
- ```tsx
84
- <QuoteProvider config={quoteConfig}>{tab === "quote" ? <QuickQuote hideHeader /> : <EstimatingTools hideHeader />}</QuoteProvider>
85
- ```
86
-
87
- The widgets don't include site navigation. To offer both, link between them from your own
88
- menu or tabs.
89
-
90
- ### Next.js (App Router)
91
-
92
- The widgets are client components and already marked `"use client"`, so you can render them
93
- from a server component page. Import `styles.css` in your root `layout.tsx` or in the page
94
- itself.
95
-
96
- ### Keeping settings per environment
97
-
98
- To use different API keys for staging and production, read the config from environment
99
- variables at build time. For example, with Vite:
100
-
101
- ```ts
102
- const quoteConfig = {
103
- apiBaseUrl: import.meta.env.VITE_METRON_API_URL,
104
- client: import.meta.env.VITE_METRON_CLIENT,
105
- apiKey: import.meta.env.VITE_METRON_API_KEY,
106
- };
107
- ```
108
-
109
- ## Configuration
110
-
111
- `QuoteProvider` takes a single `config` prop:
112
-
113
- | Option | Type | Required | Description |
114
- | ------------ | ------------------------ | -------- | ------------------------------------------------------- |
115
- | `apiBaseUrl` | `string` | Yes | Metron's API address, e.g. `https://api.metronlabs.ai`. |
116
- | `client` | `string` | Yes | Your client code. |
117
- | `apiKey` | `string` | Yes | Your API key. |
118
- | `theme` | `Record<string, string>` | No | Colour and style overrides; see [Theming](#theming). |
119
- | `onEvent` | `(event) => void` | No | Called with each funnel event; see [Tracking](#tracking). |
120
- | `analytics` | `"all" | "events-only" | "none"` | No | Limits tracking; see [Tracking](#tracking). Default `"all"`. |
121
-
122
- ## Widget props
123
-
124
- `QuickQuote` and `EstimatingTools` take the same optional props:
125
-
126
- | Prop | Default | Description |
127
- | ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------- |
128
- | `hideHeader` | `false` | Hides the widget's own heading, for pages that already show one. |
129
- | `fullPage` | `false` | Gives the widget its own background and full viewport height. Leave it off to place the widget inside your page layout. |
130
-
131
- ## Theming
132
-
133
- Pass CSS custom properties in `config.theme` to match your brand. Anything you leave out
134
- keeps the default shown here.
135
-
136
- ```ts
137
- theme: {
138
- "--accent": "#0a7d3b",
139
- "--accent-light": "#d3eddd",
140
- "--accent-dark": "#075e2c",
141
- "--accent-hover": "#eef8f2",
142
- "--font-display": '"Your Font", system-ui, sans-serif',
143
- "--radius": "8px",
144
- }
145
- ```
146
-
147
- | Variable | Default | Used for |
148
- | --------------------------- | ------------------------------------ | ---------------------------------- |
149
- | `--accent` | `#e8622c` | Buttons, highlights, active states |
150
- | `--accent-light` | `#fbe3d5` | Tinted accent backgrounds |
151
- | `--accent-dark` | `#c94f1f` | Accent text and pressed states |
152
- | `--accent-hover` | `#fff3ec` | Hover backgrounds |
153
- | `--on-accent` | `#fff` | Text on accent buttons (use a dark colour for light accents) |
154
- | `--link` | same as `--accent` | Links |
155
- | `--navy` | `#10213f` | Dark hero panels |
156
- | `--navy-light` | `#182c52` | Raised areas on dark panels |
157
- | `--navy-border` | `rgba(255, 255, 255, 0.12)` | Borders on dark panels |
158
- | `--bg` | `#ffffff` | Cards and raised panels |
159
- | `--bg2` | `#f9fafb` | Widget background |
160
- | `--bg3` | `#f3f4f6` | Subtle fills |
161
- | `--border` | `#e5e7eb` | Borders |
162
- | `--border-dark` | `#d1d5db` | Stronger borders |
163
- | `--text` | `#111827` | Main text |
164
- | `--text-mid` | `#6b7280` | Secondary text |
165
- | `--text-dim` | `#9ca3af` | Hints and placeholders |
166
- | `--green` / `--green-light` | `#16a34a` / `#dcfce7` | Matched and success states |
167
- | `--red` / `--red-light` | `#dc2626` / `#fee2e2` | Errors |
168
- | `--amber` / `--amber-light` | `#d97706` / `#fef3c7` | Warnings and items to review |
169
- | `--font-display` | Inter, then the system font | All widget text |
170
- | `--font-mono` | IBM Plex Mono, then system monospace | Product codes |
171
- | `--radius` / `--radius-sm` | `0` | Corner rounding |
172
- | `--shadow` / `--shadow-md` | subtle drop shadows | Cards and popovers |
173
-
174
- The widgets don't load any fonts. Inter is used only if your page already loads it;
175
- otherwise they fall back to the system font. Set `--font-display` to match your site.
176
-
177
- ### How the styles interact with your page
178
-
179
- Every rule in `styles.css` is scoped to the widget, so it won't restyle anything else on
180
- your page. The scoping only works one way, though: a broad rule on your site, such as one
181
- that targets every `button` or `input`, can still reach elements inside the widget. If a
182
- widget looks wrong on your site, check for global element selectors first.
183
-
184
- ## Tracking
185
-
186
- The widgets report each step a customer takes, so you can see where they drop off. They load
187
- no tracker and set no cookies; events contain counts and totals only, never customer input.
188
- Every event has `widget` (`quick-quote` or `estimating-tools`) and `step`:
189
-
190
- | `step` | When | Extra fields |
191
- | --- | --- | --- |
192
- | `viewed` | The widget appears | – |
193
- | `method_selected` | Customer opens paste or upload | `method`: `paste` or `upload` |
194
- | `list_submitted` | Customer sends a list to be matched | `method`: `paste`, `upload` or `photo`; `lines` for pasted lists |
195
- | `results_shown` | Matched products appear | `lines`, `matched`, `unmatched` |
196
- | `error` | Matching or photo reading failed | `stage`: `matching` or `photo` |
197
- | `calculator_selected` | Customer opens a calculator | `calculator`, e.g. `fencing` |
198
- | `estimate_completed` | A calculator produces its materials list | `calculator`, `lines` |
199
- | `basket_viewed` | The basket opens | `items`, `total` (inc. VAT) |
200
- | `downloaded` | Customer downloads a spreadsheet | `type`: `schedule` or `basket` |
201
- | `quote_requested` | A quote request is sent | `items`, `total` (inc. VAT) |
202
-
203
- Each event goes to:
204
-
205
- - `config.onEvent`, e.g. `onEvent: (event) => myAnalytics.track(event.step, event)`.
206
- - A `metron:step` DOM event (the `STEP_EVENT` export) that bubbles from the widget.
207
- - `window.dataLayer`, when the page already has one (Google Tag Manager), as
208
- `{ event: "metron_step", widget, step, ... }`.
209
-
210
- Set `analytics: "events-only"` to skip the `dataLayer`, or `"none"` to send nothing. The
211
- event types (`WidgetEvent`, `Step`) are exported.
212
-
213
- ## Security
214
-
215
- The API key is included in your page's JavaScript, so treat it as public. Metron protects
216
- your account by only accepting the key from the web addresses registered to it. A copied
217
- key won't work from any other site.
218
-
219
- - Register each environment's address with Metron before going live. If an address isn't
220
- registered, the widgets load but their requests are rejected.
221
- - If you think a key has been misused, ask Metron to issue a new one.
222
- - The npm access token is different: it's a real secret. Keep it in your CI secrets and
223
- local environment, never in source code.
224
-
225
- ## Troubleshooting
226
-
227
- **The widget renders unstyled.** Import `@metron-labs/quote-react/styles.css`.
228
-
229
- **Requests fail with 401 or a CORS error in the browser console.** The page's address isn't
230
- registered for your API key. Send Metron the exact origin, including protocol and port
231
- (e.g. `https://staging.example.com`).
232
-
233
- **`npm install` fails with 401, 403 or 404.** Check that `.npmrc` is in your project root and
234
- that `NPM_TOKEN` is set wherever you install, including CI.
235
-
236
- **The build fails on a `.webp` import.** Your bundler needs to handle image imports. Vite,
237
- Next.js and Parcel do this automatically. In webpack 5, add an `asset/resource` rule for
238
- `.webp`.
239
-
240
- ## Versioning
241
-
242
- Releases follow semantic versioning. While the version is `0.x`, a minor release
243
- (`0.1` → `0.2`) may include breaking changes, so allow patch updates only
244
- (`"~0.1.0"`) and upgrade minor versions deliberately.
245
-
246
- ## Support
247
-
248
- Contact your Metron account manager.
249
-
250
- ## Licence
251
-
252
- Proprietary. You may use this package only under a current agreement with Metron Labs Ltd;
253
- see [LICENSE](LICENSE) for the terms.
1
+ # @metron-labs/quote-react
2
+
3
+ Metron's quoting tools as React components you place in your own website:
4
+
5
+ - **Quick Quote** — your customers upload a schedule, paste a materials list or photograph
6
+ a handwritten one. Metron matches each line to products in your catalogue, then shows
7
+ prices, quantities and a basket they can download as a spreadsheet.
8
+ - **Estimating Tools** — 12 calculators (fencing, decking, brick and block, paint, timber,
9
+ paving and more) that turn a customer's measurements into a priced list of your products.
10
+
11
+ Both widgets call Metron's API straight from the browser, so you don't need your own server.
12
+
13
+ ## Before you start
14
+
15
+ Your Metron account manager will give you:
16
+
17
+ | What | Example | Used for |
18
+ | ---------------- | ----------- | -------------------------------------------- |
19
+ | Client code | `ABCD-1234` | Identifies your catalogue. Not secret. |
20
+ | API key | `boq_...` | Authenticates the widgets with Metron's API. |
21
+ | npm access token | `npm_...` | Installing this package, which is private. |
22
+
23
+ Tell Metron every web address the widgets will run on: production, staging and local
24
+ development (for example `http://localhost:3000`). The API key only works from addresses
25
+ registered to your account (see [Security](#security)).
26
+
27
+ ## Requirements
28
+
29
+ - React 18.3 or later, or React 19.
30
+ - A bundler that can import images and CSS, such as Vite, Next.js, webpack 5 or Parcel.
31
+ The widget images ship inside the package, and your build serves them like your own.
32
+
33
+ Not using React? Ask Metron for the script-tag version, which works on any website.
34
+
35
+ ## Install
36
+
37
+ The package is private, so npm needs your access token. Add a `.npmrc` file to your
38
+ project root:
39
+
40
+ ```ini
41
+ @metron-labs:registry=https://registry.npmjs.org/
42
+ //registry.npmjs.org/:_authToken=${NPM_TOKEN}
43
+ ```
44
+
45
+ Set `NPM_TOKEN` in your shell and CI, then install:
46
+
47
+ ```sh
48
+ npm install @metron-labs/quote-react
49
+ ```
50
+
51
+ Don't commit the token itself; `${NPM_TOKEN}` reads it from the environment.
52
+
53
+ ## Quick start
54
+
55
+ ```tsx
56
+ import { QuoteProvider, QuickQuote } from "@metron-labs/quote-react";
57
+ import "@metron-labs/quote-react/styles.css";
58
+
59
+ const quoteConfig = {
60
+ apiBaseUrl: "https://api.metronlabs.ai",
61
+ client: "ABCD-1234",
62
+ apiKey: "boq_...",
63
+ theme: { "--accent": "#0a7d3b" },
64
+ };
65
+
66
+ export default function QuotePage() {
67
+ return (
68
+ <QuoteProvider config={quoteConfig}>
69
+ <QuickQuote />
70
+ </QuoteProvider>
71
+ );
72
+ }
73
+ ```
74
+
75
+ For the calculators, render `<EstimatingTools />` in the same way.
76
+
77
+ Import `styles.css` once, anywhere in your app. Without it the widgets render unstyled.
78
+
79
+ ### Both widgets on one site
80
+
81
+ Use one `QuoteProvider` per page, and put every widget on that page inside it:
82
+
83
+ ```tsx
84
+ <QuoteProvider config={quoteConfig}>{tab === "quote" ? <QuickQuote hideHeader /> : <EstimatingTools hideHeader />}</QuoteProvider>
85
+ ```
86
+
87
+ The widgets don't include site navigation. To offer both, link between them from your own
88
+ menu or tabs.
89
+
90
+ ### Next.js (App Router)
91
+
92
+ The widgets are client components and already marked `"use client"`, so you can render them
93
+ from a server component page. Import `styles.css` in your root `layout.tsx` or in the page
94
+ itself.
95
+
96
+ ### Keeping settings per environment
97
+
98
+ To use different API keys for staging and production, read the config from environment
99
+ variables at build time. For example, with Vite:
100
+
101
+ ```ts
102
+ const quoteConfig = {
103
+ apiBaseUrl: import.meta.env.VITE_METRON_API_URL,
104
+ client: import.meta.env.VITE_METRON_CLIENT,
105
+ apiKey: import.meta.env.VITE_METRON_API_KEY,
106
+ };
107
+ ```
108
+
109
+ ## Configuration
110
+
111
+ `QuoteProvider` takes a single `config` prop:
112
+
113
+ | Option | Type | Required | Description |
114
+ | ------------ | ------------------------ | -------- | ------------------------------------------------------- |
115
+ | `apiBaseUrl` | `string` | Yes | Metron's API address, e.g. `https://api.metronlabs.ai`. |
116
+ | `client` | `string` | Yes | Your client code. |
117
+ | `apiKey` | `string` | Yes | Your API key. |
118
+ | `theme` | `Record<string, string>` | No | Colour and style overrides; see [Theming](#theming). |
119
+ | `onEvent` | `(event) => void` | No | Called with each funnel event; see [Tracking](#tracking). |
120
+ | `analytics` | `"all" | "events-only" | "none"` | No | Limits tracking; see [Tracking](#tracking). Default `"all"`. |
121
+
122
+ ## Widget props
123
+
124
+ `QuickQuote` and `EstimatingTools` take the same optional props:
125
+
126
+ | Prop | Default | Description |
127
+ | ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------- |
128
+ | `hideHeader` | `false` | Hides the widget's own heading, for pages that already show one. |
129
+ | `fullPage` | `false` | Gives the widget its own background and full viewport height. Leave it off to place the widget inside your page layout. |
130
+
131
+ ## Theming
132
+
133
+ Pass CSS custom properties in `config.theme` to match your brand. Anything you leave out
134
+ keeps the default shown here.
135
+
136
+ ```ts
137
+ theme: {
138
+ "--accent": "#0a7d3b",
139
+ "--accent-light": "#d3eddd",
140
+ "--accent-dark": "#075e2c",
141
+ "--accent-hover": "#eef8f2",
142
+ "--font-display": '"Your Font", system-ui, sans-serif',
143
+ "--radius": "8px",
144
+ }
145
+ ```
146
+
147
+ | Variable | Default | Used for |
148
+ | --------------------------- | ------------------------------------ | ---------------------------------- |
149
+ | `--accent` | `#e8622c` | Buttons, highlights, active states |
150
+ | `--accent-light` | `#fbe3d5` | Tinted accent backgrounds |
151
+ | `--accent-dark` | `#c94f1f` | Accent text and pressed states |
152
+ | `--accent-hover` | `#fff3ec` | Hover backgrounds |
153
+ | `--on-accent` | `#fff` | Text on accent buttons (use a dark colour for light accents) |
154
+ | `--link` | same as `--accent` | Links |
155
+ | `--navy` | `#10213f` | Dark hero panels |
156
+ | `--navy-light` | `#182c52` | Raised areas on dark panels |
157
+ | `--navy-border` | `rgba(255, 255, 255, 0.12)` | Borders on dark panels |
158
+ | `--bg` | `#ffffff` | Cards and raised panels |
159
+ | `--bg2` | `#f9fafb` | Widget background |
160
+ | `--bg3` | `#f3f4f6` | Subtle fills |
161
+ | `--border` | `#e5e7eb` | Borders |
162
+ | `--border-dark` | `#d1d5db` | Stronger borders |
163
+ | `--text` | `#111827` | Main text |
164
+ | `--text-mid` | `#6b7280` | Secondary text |
165
+ | `--text-dim` | `#9ca3af` | Hints and placeholders |
166
+ | `--green` / `--green-light` | `#16a34a` / `#dcfce7` | Matched and success states |
167
+ | `--red` / `--red-light` | `#dc2626` / `#fee2e2` | Errors |
168
+ | `--amber` / `--amber-light` | `#d97706` / `#fef3c7` | Warnings and items to review |
169
+ | `--font-display` | Inter, then the system font | All widget text |
170
+ | `--font-mono` | IBM Plex Mono, then system monospace | Product codes |
171
+ | `--radius` / `--radius-sm` | `0` | Corner rounding |
172
+ | `--shadow` / `--shadow-md` | subtle drop shadows | Cards and popovers |
173
+
174
+ The widgets don't load any fonts. Inter is used only if your page already loads it;
175
+ otherwise they fall back to the system font. Set `--font-display` to match your site.
176
+
177
+ ### How the styles interact with your page
178
+
179
+ Every rule in `styles.css` is scoped to the widget, so it won't restyle anything else on
180
+ your page. The scoping only works one way, though: a broad rule on your site, such as one
181
+ that targets every `button` or `input`, can still reach elements inside the widget. If a
182
+ widget looks wrong on your site, check for global element selectors first.
183
+
184
+ ## Tracking
185
+
186
+ The widgets report each step a customer takes, so you can see where they drop off. They load
187
+ no tracker and set no cookies; events contain counts and totals only, never customer input.
188
+ Every event has `widget` (`quick-quote` or `estimating-tools`) and `step`:
189
+
190
+ | `step` | When | Extra fields |
191
+ | --- | --- | --- |
192
+ | `viewed` | The widget appears | – |
193
+ | `method_selected` | Customer opens paste or upload | `method`: `paste` or `upload` |
194
+ | `list_submitted` | Customer sends a list to be matched | `method`: `paste`, `upload` or `photo`; `lines` for pasted lists |
195
+ | `results_shown` | Matched products appear | `lines`, `matched`, `unmatched` |
196
+ | `error` | Matching or photo reading failed | `stage`: `matching` or `photo` |
197
+ | `calculator_selected` | Customer opens a calculator | `calculator`, e.g. `fencing` |
198
+ | `estimate_completed` | A calculator produces its materials list | `calculator`, `lines` |
199
+ | `basket_viewed` | The basket opens | `items`, `total` (inc. VAT) |
200
+ | `downloaded` | Customer downloads a spreadsheet | `type`: `schedule` or `basket` |
201
+ | `quote_requested` | A quote request is sent | `items`, `total` (inc. VAT) |
202
+
203
+ Each event goes to:
204
+
205
+ - `config.onEvent`, e.g. `onEvent: (event) => myAnalytics.track(event.step, event)`.
206
+ - A `metron:step` DOM event (the `STEP_EVENT` export) that bubbles from the widget.
207
+ - `window.dataLayer`, when the page already has one (Google Tag Manager), as
208
+ `{ event: "metron_step", widget, step, ... }`.
209
+
210
+ Set `analytics: "events-only"` to skip the `dataLayer`, or `"none"` to send nothing. The
211
+ event types (`WidgetEvent`, `Step`) are exported.
212
+
213
+ ## Security
214
+
215
+ The API key is included in your page's JavaScript, so treat it as public. Metron protects
216
+ your account by only accepting the key from the web addresses registered to it. A copied
217
+ key won't work from any other site.
218
+
219
+ - Register each environment's address with Metron before going live. If an address isn't
220
+ registered, the widgets load but their requests are rejected.
221
+ - If you think a key has been misused, ask Metron to issue a new one.
222
+ - The npm access token is different: it's a real secret. Keep it in your CI secrets and
223
+ local environment, never in source code.
224
+
225
+ ## Troubleshooting
226
+
227
+ **The widget renders unstyled.** Import `@metron-labs/quote-react/styles.css`.
228
+
229
+ **Requests fail with 401 or a CORS error in the browser console.** The page's address isn't
230
+ registered for your API key. Send Metron the exact origin, including protocol and port
231
+ (e.g. `https://staging.example.com`).
232
+
233
+ **`npm install` fails with 401, 403 or 404.** Check that `.npmrc` is in your project root and
234
+ that `NPM_TOKEN` is set wherever you install, including CI.
235
+
236
+ **The build fails on a `.webp` import.** Your bundler needs to handle image imports. Vite,
237
+ Next.js and Parcel do this automatically. In webpack 5, add an `asset/resource` rule for
238
+ `.webp`.
239
+
240
+ ## Versioning
241
+
242
+ Releases follow semantic versioning. While the version is `0.x`, a minor release
243
+ (`0.1` → `0.2`) may include breaking changes, so allow patch updates only
244
+ (`"~0.1.0"`) and upgrade minor versions deliberately.
245
+
246
+ ## Support
247
+
248
+ Contact your Metron account manager.
249
+
250
+ ## Licence
251
+
252
+ Proprietary. You may use this package only under a current agreement with Metron Labs Ltd;
253
+ see [LICENSE](LICENSE) for the terms.
@@ -3,7 +3,7 @@ import { useEffect, useId, useRef, useState } from "react";
3
3
  import { apiUrl } from "../../util/config";
4
4
  import { useTrack } from "../../util/analytics";
5
5
  import { fmtCurrency } from "../../util/formatters";
6
- import { getCartSubtotal, getPricingBreakdown } from "../../util/pricing";
6
+ import { getCartSubtotal, getPricingBreakdown, needsQuantityConfirmation } from "../../util/pricing";
7
7
  import { exportBasketExcel } from "../../util/exportScheduleExcel";
8
8
  import { renderDisplayUnit, unitDisplayText } from "../../components/unit/UnitDisplay";
9
9
  import { clientHeaders, getBoqClientId, readJson } from "../../util/api/boqClient";
@@ -273,12 +273,13 @@ export default function BasketStage({ cart, onUpdateQty, onUpdateSellUnits, onRe
273
273
  ? "Check your items and quantities below, and we'll do our best to beat list prices."
274
274
  : "Review your quantities and order summary, then send us your basket." })] })] }), _jsx("div", { className: "quick-order-panel", style: { paddingTop: 16 }, children: waitingForConversions ? (_jsxs("div", { className: "basket-resolving", role: "status", "aria-label": "Calculating order quantities", children: [_jsx("span", { className: "basket-resolving-wheel", "aria-hidden": "true" }), _jsx("span", { children: "Calculating your quantities\u2026" })] })) : items.length === 0 ? (_jsxs("div", { className: "quick-order-history-empty", children: [_jsx("div", { className: "basket-empty-icon", "aria-hidden": "true", children: "\uD83D\uDCCB" }), _jsx("p", { children: "Your selection is empty." }), _jsx("p", { className: "basket-empty-hint", children: "Go back to your results and add quoted items." }), _jsx("div", { className: "btn-row", style: { marginTop: 20, justifyContent: "center" }, children: _jsx("button", { className: "cta-btn", style: { width: "auto", maxWidth: 280 }, onClick: onBack, "aria-label": "Back to results", children: _jsx("span", { children: "Back to Results" }) }) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "basket-layout", children: [_jsxs("div", { className: "basket-main", children: [_jsx("div", { className: "basket-list", "aria-label": internal ? "Selection items" : qzaBasketLayout ? "Quote request items" : "Basket items", children: pagedItems.map(({ item, pricing }) => {
275
275
  const requestedQtyValue = qtyDrafts[item.lineId] ?? String(pricing.requestedAmount);
276
- const requestedLocked = pricing.requestedUnit === pricing.sellUnit || pricing.costingMode === "review";
276
+ const needsConfirmation = needsQuantityConfirmation(item, pricing);
277
+ const requestedLocked = pricing.requestedUnit === pricing.sellUnit || needsConfirmation;
277
278
  const rawDiffersFromOrder = pricing.requiredSellUnits == null || pricing.requestedAmount !== pricing.requiredSellUnits || pricing.requestedUnit !== pricing.sellUnit;
278
279
  return (_jsxs("article", { className: "basket-card", children: [_jsxs("div", { className: "basket-card-top", children: [_jsx(ProductImage, { className: "basket-card-thumb", description: item.description, photoUrl: item.pdpPhotoUrl, width: 48, height: 48 }), _jsxs("div", { className: "basket-card-info", children: [_jsx("div", { className: "bom-component-name", children: item.description }), rawDiffersFromOrder && pricing.costingMode !== "review" && (_jsxs("button", { type: "button", className: "basket-calc-toggle", onClick: () => setCalcOpen((prev) => ({
279
280
  ...prev,
280
281
  [item.lineId]: !prev[item.lineId],
281
- })), "aria-expanded": !!calcOpen[item.lineId], children: ["How we calculated this", _jsx("span", { className: `basket-calc-toggle-chevron${calcOpen[item.lineId] ? " basket-calc-toggle-chevron--open" : ""}`, children: _jsx(ChevronIcon, {}) })] }))] }), _jsx("div", { className: "basket-card-actions", children: _jsx("button", { type: "button", className: "mf-remove-small-btn basket-card-remove", onClick: () => onRemove(item.lineId), "aria-label": `Remove ${item.description}`, children: _jsx(TrashIcon, {}) }) })] }), internal && (_jsxs("div", { className: "results-detail-block basket-internal-detail", children: [_jsxs("dl", { className: "results-detail-grid", children: [_jsxs("div", { children: [_jsx("dt", { children: "Conversion type" }), _jsx("dd", { children: item.conversionType ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Conversion confidence" }), _jsx("dd", { children: item.conversionConfidence ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Sales unit" }), _jsx("dd", { children: item.salesUnitSize != null ? (_jsxs(_Fragment, { children: [item.salesUnitSize, " ", renderDisplayUnit(item.salesUnitSizeUom, item.salesUnitSize)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Coverage" }), _jsx("dd", { children: item.coverageQty != null ? (_jsxs(_Fragment, { children: [item.coverageQty, " ", renderDisplayUnit(item.coverageUom, item.coverageQty)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Waste factor" }), _jsx("dd", { children: item.wasteFactorDefault ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Rounding rule" }), _jsx("dd", { children: item.roundingRule ?? "-" })] })] }), item.conversionNotes && _jsx("p", { className: "results-rerank-note", children: item.conversionNotes })] })), pricing.costingMode === "review" ? (_jsxs("p", { className: "basket-calc-text basket-calc-text--review", children: [_jsx("strong", { children: resolvingConversionInputs[item.lineId] === conversionInputKey(item) ? "Calculating order quantity…" : "Quantity needs confirmation." }), " ", reviewMessage(pricing)] })) : rawDiffersFromOrder && calcOpen[item.lineId] ? (_jsx("p", { className: "basket-calc-text", children: orderSummary(item, pricing) })) : null, _jsxs("div", { className: "basket-card-stats", children: [_jsxs("div", { className: "basket-stat", children: [_jsx("span", { className: "basket-stat-label", children: "Requested qty" }), _jsxs("div", { className: "basket-stat-value", children: [_jsxs("div", { className: "basket-qty-field", children: [_jsx("input", { className: "bom-qty-edit", type: "number", inputMode: "decimal", min: "0", placeholder: "-", value: requestedQtyValue, disabled: requestedLocked, onChange: (e) => setQtyDrafts((prev) => ({
282
+ })), "aria-expanded": !!calcOpen[item.lineId], children: ["How we calculated this", _jsx("span", { className: `basket-calc-toggle-chevron${calcOpen[item.lineId] ? " basket-calc-toggle-chevron--open" : ""}`, children: _jsx(ChevronIcon, {}) })] }))] }), _jsx("div", { className: "basket-card-actions", children: _jsx("button", { type: "button", className: "mf-remove-small-btn basket-card-remove", onClick: () => onRemove(item.lineId), "aria-label": `Remove ${item.description}`, children: _jsx(TrashIcon, {}) }) })] }), internal && (_jsxs("div", { className: "results-detail-block basket-internal-detail", children: [_jsxs("dl", { className: "results-detail-grid", children: [_jsxs("div", { children: [_jsx("dt", { children: "Conversion type" }), _jsx("dd", { children: item.conversionType ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Conversion confidence" }), _jsx("dd", { children: item.conversionConfidence ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Sales unit" }), _jsx("dd", { children: item.salesUnitSize != null ? (_jsxs(_Fragment, { children: [item.salesUnitSize, " ", renderDisplayUnit(item.salesUnitSizeUom, item.salesUnitSize)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Coverage" }), _jsx("dd", { children: item.coverageQty != null ? (_jsxs(_Fragment, { children: [item.coverageQty, " ", renderDisplayUnit(item.coverageUom, item.coverageQty)] })) : ("-") })] }), _jsxs("div", { children: [_jsx("dt", { children: "Waste factor" }), _jsx("dd", { children: item.wasteFactorDefault ?? "-" })] }), _jsxs("div", { children: [_jsx("dt", { children: "Rounding rule" }), _jsx("dd", { children: item.roundingRule ?? "-" })] })] }), item.conversionNotes && _jsx("p", { className: "results-rerank-note", children: item.conversionNotes })] })), needsConfirmation ? (_jsx("p", { className: "basket-calc-text basket-calc-text--review", children: resolvingConversionInputs[item.lineId] === conversionInputKey(item) ? (_jsx("strong", { children: "Calculating order quantity\u2026" })) : item.conversionNotes?.trim() ? (item.conversionNotes.trim()) : (_jsxs(_Fragment, { children: [_jsx("strong", { children: "Quantity needs confirmation." }), " ", reviewMessage(pricing)] })) })) : rawDiffersFromOrder && calcOpen[item.lineId] ? (_jsx("p", { className: "basket-calc-text", children: orderSummary(item, pricing) })) : null, _jsxs("div", { className: "basket-card-stats", children: [_jsxs("div", { className: "basket-stat", children: [_jsx("span", { className: "basket-stat-label", children: "Requested qty" }), _jsxs("div", { className: "basket-stat-value", children: [_jsxs("div", { className: "basket-qty-field", children: [_jsx("input", { className: "bom-qty-edit", type: "number", inputMode: "decimal", min: "0", placeholder: "-", value: requestedQtyValue, disabled: requestedLocked, onChange: (e) => setQtyDrafts((prev) => ({
282
283
  ...prev,
283
284
  [item.lineId]: e.target.value,
284
285
  })), onBlur: (e) => commitRequestedQty(item.lineId, e.target.value), onKeyDown: (e) => {