@metron-labs/quote-react 0.1.46 → 0.1.47

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.
@@ -26,7 +26,6 @@ const CONFIG = {
26
26
  wastage: { straight: 0.05, diagonal: 0.15 },
27
27
  pictureFrame: { extraWastage: 0.08 },
28
28
  joist: {
29
- lengthM: 2.4,
30
29
  spacingOptions: [
31
30
  { id: "300", label: "300mm centres (heavy duty)", spacingM: 0.3 },
32
31
  { id: "400", label: "400mm centres (standard)", spacingM: 0.4 },
@@ -145,13 +144,15 @@ export default function DeckingEstimator({ onBack, onReview, submitLabel = "Add
145
144
  const screwBoxes = Math.ceil((area * 30 * (1 + wastageRate + pfExtra)) / 200);
146
145
  const spacing = CONFIG.joist.spacingOptions.find((option) => option.id === state.joistSpacing) ?? CONFIG.joist.spacingOptions[1];
147
146
  const joistRunTotalM = area / spacing.spacingM;
148
- const joistLengthsNeeded = Math.ceil(joistRunTotalM / CONFIG.joist.lengthM);
147
+ // Asked for in metres: the matched joist's own length turns it into lengths, so a merchant
148
+ // selling 2.4m and one selling 3.6m both get the right count.
149
+ const joistMetres = Math.ceil(joistRunTotalM);
149
150
  return {
150
151
  range,
151
152
  area,
152
153
  boardCount,
153
154
  screwBoxes,
154
- joistLengthsNeeded,
155
+ joistMetres,
155
156
  spacing,
156
157
  };
157
158
  };
@@ -159,7 +160,7 @@ export default function DeckingEstimator({ onBack, onReview, submitLabel = "Add
159
160
  const requirements = results
160
161
  ? [
161
162
  { key: `boards-${state.range ?? "meridian"}`, label: `Deck boards (${results.range.label})`, qty: results.boardCount, unit: "each", query: "timber decking board", category: "decking", calculatorWasteIncluded: true },
162
- { key: "joists", label: "Treated C24 joists", qty: results.joistLengthsNeeded, unit: "each", query: "treated joist", category: "timber", calculatorWasteIncluded: true },
163
+ { key: "joists", label: "Treated C24 joists", qty: results.joistMetres, unit: "m", query: "treated joist", category: "timber", calculatorWasteIncluded: true },
163
164
  { key: "screws", label: "Green decking screws (200/box)", qty: results.screwBoxes, unit: "box", query: "green decking screws", category: "fixings", calculatorWasteIncluded: true },
164
165
  ]
165
166
  : [];
@@ -1,6 +1,7 @@
1
1
  import type { EstimatorRequirement } from "../../lib/estimators/types";
2
+ import type { EstimatorToolId } from "./registry";
2
3
  interface EstimatorResultsProps {
3
- toolId: string;
4
+ toolId: EstimatorToolId;
4
5
  requirements: EstimatorRequirement[];
5
6
  /** Mirrors the estimator's own `calculated` flag - nothing is fetched before Calculate. */
6
7
  ready: boolean;
@@ -96,7 +96,7 @@ export default function EstimatorResults({ toolId, requirements, ready, onBack,
96
96
  (async () => {
97
97
  let next = null;
98
98
  try {
99
- const resolved = await resolveRequirements(requirements);
99
+ const resolved = await resolveRequirements(requirements, undefined, toolId);
100
100
  next = resolved.map(({ requirement, candidates }) => ({
101
101
  requirement,
102
102
  candidates,
@@ -1,6 +1,7 @@
1
+ import type { EstimatorToolId } from "./registry";
1
2
  import type { EstimatorRequirement } from "../../lib/estimators/types";
2
3
  interface EstimatorReviewStageProps {
3
- toolId: string;
4
+ toolId: EstimatorToolId;
4
5
  toolTitle: string;
5
6
  requirements: EstimatorRequirement[];
6
7
  onBack: () => void;
@@ -1,11 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
+ // byArea: ask for the area and let the matched product's own coverage turn it into packs, which
5
+ // is right whichever client's product it is. Floor and wall still ask for packs worked out from
6
+ // the coverage here.
7
+ // ponytail: floor/wall stay on packs because FORT's catalogue coverage for its floor roll and wall
8
+ // slab is wrong (0.06 and 0.546 m2); switch them to byArea once that data is fixed.
4
9
  const APPLICATIONS = {
5
10
  floor: { label: "Floor", product: "Knauf Acoustic Roll", thickness: "100mm", coverage: 12.36, salesUnit: "roll" },
6
11
  wall: { label: "Cavity wall", product: "Celotex CW4100", thickness: "100mm", coverage: 3.24, salesUnit: "pack" },
7
- roof: { label: "Pitched roof", product: "Kingspan K107", thickness: "100mm", coverage: 8.64, salesUnit: "pack" },
8
- loft: { label: "Loft", product: "Knauf Loft Roll 44", thickness: "100mm", coverage: 13.89, salesUnit: "roll" },
12
+ roof: { label: "Pitched roof", product: "Kingspan K107", thickness: "100mm", coverage: 8.64, salesUnit: "pack", byArea: true },
13
+ loft: { label: "Loft", product: "Knauf Loft Roll 44", thickness: "100mm", coverage: 13.89, salesUnit: "roll", byArea: true },
9
14
  };
10
15
  export default function InsulationEstimator({ onBack, onReview, submitLabel = "Add to basket" }) {
11
16
  const [application, setApplication] = useState("floor");
@@ -14,13 +19,12 @@ export default function InsulationEstimator({ onBack, onReview, submitLabel = "A
14
19
  const area = Number.parseFloat(length) * Number.parseFloat(width) || 0;
15
20
  const selected = APPLICATIONS[application];
16
21
  const packs = Math.ceil(area / selected.coverage);
17
- // Requested as area - pack coverage comes from the product, not PRODUCT_COVERAGE.
18
22
  const requirements = [
19
23
  {
20
24
  key: `insulation-${application}`,
21
25
  label: `${selected.product} ${selected.thickness}`,
22
- qty: packs,
23
- unit: selected.salesUnit,
26
+ qty: selected.byArea ? Math.round(area * 100) / 100 : packs,
27
+ unit: selected.byArea ? "m2" : selected.salesUnit,
24
28
  query: selected.product,
25
29
  category: "insulation",
26
30
  },
@@ -21,7 +21,7 @@ export default function SkirtingEstimator({ onBack, onReview, submitLabel = "Add
21
21
  const [rooms, setRooms] = useState([emptyRoom()]);
22
22
  const [openings, setOpenings] = useState("");
23
23
  const [profile, setProfile] = useState("chamfered");
24
- const boardLength = "4.2";
24
+ const boardLength = "4.4";
25
25
  const baseLengthM = mode === "total"
26
26
  ? toMetres(totalLength, unit)
27
27
  : rooms.reduce((total, room) => total + 2 * (toMetres(room.length, unit) + toMetres(room.width, unit)), 0);
@@ -34,5 +34,5 @@ export default function SkirtingEstimator({ onBack, onReview, submitLabel = "Add
34
34
  const updateRoom = (index, key, nextValue) => {
35
35
  setRooms((items) => items.map((room, roomIndex) => roomIndex === index ? { ...room, [key]: nextValue } : room));
36
36
  };
37
- return (_jsx(_Fragment, { children: _jsxs("div", { className: "truss-estimator", children: [_jsxs("div", { className: "quick-order-hero", children: [_jsx("div", { className: "quick-order-eyebrow", children: "Estimating Tools" }), _jsx("h1", { children: "Skirting Board Calculator" }), _jsx("p", { children: "Work out how many skirting board lengths you need for one room or a whole project." })] }), _jsxs("div", { className: "truss-estimator__card skirting-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Measure by room or total length" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Enter a single total or work room by room. The run is converted straight into board lengths." }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "How would you like to measure?" }), _jsxs("div", { className: "brick-calculator__unit-toggle skirting-calculator__mode-toggle", role: "group", "aria-label": "Calculation method", children: [_jsx("button", { type: "button", className: mode === "total" ? "is-selected" : "", onClick: () => setMode("total"), children: "Total length" }), _jsx("button", { type: "button", className: mode === "rooms" ? "is-selected" : "", onClick: () => setMode("rooms"), children: "Room by room" })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Measurements" }), _jsxs("div", { className: "skirting-calculator__unit-row", children: [_jsx("span", { children: "Units" }), _jsxs("div", { className: "brick-calculator__unit-toggle", role: "group", "aria-label": "Measurement units", children: [_jsx("button", { type: "button", className: unit === "m" ? "is-selected" : "", onClick: () => setUnit("m"), children: "Metres" }), _jsx("button", { type: "button", className: unit === "ft" ? "is-selected" : "", onClick: () => setUnit("ft"), children: "Feet" })] })] }), mode === "total" ? (_jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Total skirting length (", unit, ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: totalLength, onChange: (event) => setTotalLength(event.target.value) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "skirting-calculator__dimensions-header", children: [_jsx("span", { children: "Room" }), _jsxs("span", { children: ["Length (", unit, ")"] }), _jsxs("span", { children: ["Width (", unit, ")"] }), _jsx("span", {})] }), rooms.map((room, index) => (_jsxs("div", { className: "skirting-calculator__room-row", children: [_jsxs("span", { className: "skirting-calculator__room-label", children: ["Room ", index + 1] }), _jsx("input", { "aria-label": `Room ${index + 1} length in ${unit}`, type: "number", min: "0", step: "0.01", value: room.length, onChange: (event) => updateRoom(index, "length", event.target.value) }), _jsx("input", { "aria-label": `Room ${index + 1} width in ${unit}`, type: "number", min: "0", step: "0.01", value: room.width, onChange: (event) => updateRoom(index, "width", event.target.value) }), rooms.length > 1 ? _jsx("button", { type: "button", className: "paint-calculator__remove", onClick: () => setRooms((items) => items.filter((_, roomIndex) => roomIndex !== index)), children: "Remove" }) : null] }, `room-${index}`))), _jsx("button", { type: "button", className: "paint-calculator__add", onClick: () => setRooms((items) => [...items, emptyRoom()]), children: "+ Add another room" })] })), _jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Openings and doorways to deduct (", unit, ") ", _jsx("small", { children: "optional" })] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: openings, onChange: (event) => setOpenings(event.target.value) })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Profile" }), _jsx("p", { className: "skirting-calculator__hint", children: "Choose the finish you want for the fixed 4.2m white-primed MDF boards." }), _jsx("div", { className: "architrave-calculator__profiles", children: PROFILES.map((option) => _jsxs("button", { type: "button", className: `truss-estimator__option-card${profile === option.id ? " is-selected" : ""}`, onClick: () => setProfile(option.id), children: [_jsx(ProfileDiagram, { profile: option.id }), _jsx("div", { className: "truss-estimator__option-title", children: option.label }), _jsx("div", { className: "truss-estimator__option-copy", children: option.blurb })] }, option.id)) })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Board and waste" }), _jsx("div", { className: "truss-estimator__field-grid skirting-calculator__options-grid", children: _jsxs("label", { className: "truss-estimator__field", children: [_jsx("span", { children: "Board length" }), _jsx("input", { value: "4.20 m", disabled: true })] }) }), _jsx("p", { className: "skirting-calculator__hint", children: "Longer boards can reduce the number of joins, while 10% waste allows for cuts, corners and mistakes." })] }), _jsxs("div", { className: "skirting-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Totals" }), _jsxs("div", { className: "skirting-calculator__result-grid", children: [_jsxs("div", { children: [_jsx("span", { children: "Net skirting" }), _jsxs("strong", { children: [netLengthM.toFixed(2), " m"] })] }), _jsxs("div", { children: [_jsx("span", { children: "Board lengths" }), _jsx("strong", { children: boardCount })] })] }), _jsx("p", { className: "skirting-calculator__note", children: "Corners and joins vary by room layout. Measure each wall where possible for the most accurate result." })] }), _jsxs("div", { className: "skirting-calculator__actions", children: [_jsx("button", { type: "button", className: "cta-btn cta-btn-secondary estimator-actions__back", onClick: onBack, children: "Back" }), _jsx("button", { type: "button", className: "cta-btn estimator-actions__calculate", onClick: () => onReview?.(requirements), disabled: netLengthM <= 0, children: submitLabel })] })] })] }) }));
37
+ return (_jsx(_Fragment, { children: _jsxs("div", { className: "truss-estimator", children: [_jsxs("div", { className: "quick-order-hero", children: [_jsx("div", { className: "quick-order-eyebrow", children: "Estimating Tools" }), _jsx("h1", { children: "Skirting Board Calculator" }), _jsx("p", { children: "Work out how many skirting board lengths you need for one room or a whole project." })] }), _jsxs("div", { className: "truss-estimator__card skirting-calculator", children: [_jsx("h2", { className: "truss-estimator__step-title", children: "Measure by room or total length" }), _jsx("p", { className: "truss-estimator__step-sub", children: "Enter a single total or work room by room. The run is converted straight into board lengths." }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "How would you like to measure?" }), _jsxs("div", { className: "brick-calculator__unit-toggle skirting-calculator__mode-toggle", role: "group", "aria-label": "Calculation method", children: [_jsx("button", { type: "button", className: mode === "total" ? "is-selected" : "", onClick: () => setMode("total"), children: "Total length" }), _jsx("button", { type: "button", className: mode === "rooms" ? "is-selected" : "", onClick: () => setMode("rooms"), children: "Room by room" })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Measurements" }), _jsxs("div", { className: "skirting-calculator__unit-row", children: [_jsx("span", { children: "Units" }), _jsxs("div", { className: "brick-calculator__unit-toggle", role: "group", "aria-label": "Measurement units", children: [_jsx("button", { type: "button", className: unit === "m" ? "is-selected" : "", onClick: () => setUnit("m"), children: "Metres" }), _jsx("button", { type: "button", className: unit === "ft" ? "is-selected" : "", onClick: () => setUnit("ft"), children: "Feet" })] })] }), mode === "total" ? (_jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Total skirting length (", unit, ")"] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: totalLength, onChange: (event) => setTotalLength(event.target.value) })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "skirting-calculator__dimensions-header", children: [_jsx("span", { children: "Room" }), _jsxs("span", { children: ["Length (", unit, ")"] }), _jsxs("span", { children: ["Width (", unit, ")"] }), _jsx("span", {})] }), rooms.map((room, index) => (_jsxs("div", { className: "skirting-calculator__room-row", children: [_jsxs("span", { className: "skirting-calculator__room-label", children: ["Room ", index + 1] }), _jsx("input", { "aria-label": `Room ${index + 1} length in ${unit}`, type: "number", min: "0", step: "0.01", value: room.length, onChange: (event) => updateRoom(index, "length", event.target.value) }), _jsx("input", { "aria-label": `Room ${index + 1} width in ${unit}`, type: "number", min: "0", step: "0.01", value: room.width, onChange: (event) => updateRoom(index, "width", event.target.value) }), rooms.length > 1 ? _jsx("button", { type: "button", className: "paint-calculator__remove", onClick: () => setRooms((items) => items.filter((_, roomIndex) => roomIndex !== index)), children: "Remove" }) : null] }, `room-${index}`))), _jsx("button", { type: "button", className: "paint-calculator__add", onClick: () => setRooms((items) => [...items, emptyRoom()]), children: "+ Add another room" })] })), _jsxs("label", { className: "truss-estimator__field skirting-calculator__single-field", children: [_jsxs("span", { children: ["Openings and doorways to deduct (", unit, ") ", _jsx("small", { children: "optional" })] }), _jsx("input", { type: "number", min: "0", step: "0.01", value: openings, onChange: (event) => setOpenings(event.target.value) })] })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Profile" }), _jsx("p", { className: "skirting-calculator__hint", children: "Choose the finish you want for the fixed 4.4m white-primed MDF boards." }), _jsx("div", { className: "architrave-calculator__profiles", children: PROFILES.map((option) => _jsxs("button", { type: "button", className: `truss-estimator__option-card${profile === option.id ? " is-selected" : ""}`, onClick: () => setProfile(option.id), children: [_jsx(ProfileDiagram, { profile: option.id }), _jsx("div", { className: "truss-estimator__option-title", children: option.label }), _jsx("div", { className: "truss-estimator__option-copy", children: option.blurb })] }, option.id)) })] }), _jsxs("div", { className: "skirting-calculator__section", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Board and waste" }), _jsx("div", { className: "truss-estimator__field-grid skirting-calculator__options-grid", children: _jsxs("label", { className: "truss-estimator__field", children: [_jsx("span", { children: "Board length" }), _jsx("input", { value: "4.40 m", disabled: true })] }) }), _jsx("p", { className: "skirting-calculator__hint", children: "Longer boards can reduce the number of joins, while 10% waste allows for cuts, corners and mistakes." })] }), _jsxs("div", { className: "skirting-calculator__totals", children: [_jsx("div", { className: "truss-estimator__section-heading", children: "Totals" }), _jsxs("div", { className: "skirting-calculator__result-grid", children: [_jsxs("div", { children: [_jsx("span", { children: "Net skirting" }), _jsxs("strong", { children: [netLengthM.toFixed(2), " m"] })] }), _jsxs("div", { children: [_jsx("span", { children: "Board lengths" }), _jsx("strong", { children: boardCount })] })] }), _jsx("p", { className: "skirting-calculator__note", children: "Corners and joins vary by room layout. Measure each wall where possible for the most accurate result." })] }), _jsxs("div", { className: "skirting-calculator__actions", children: [_jsx("button", { type: "button", className: "cta-btn cta-btn-secondary estimator-actions__back", onClick: onBack, children: "Back" }), _jsx("button", { type: "button", className: "cta-btn estimator-actions__calculate", onClick: () => onReview?.(requirements), disabled: netLengthM <= 0, children: submitLabel })] })] })] }) }));
38
38
  }
@@ -1,6 +1,7 @@
1
1
  export type FixedBasketLine = {
2
2
  key: string;
3
3
  productId: string;
4
+ search: string;
4
5
  qty: number;
5
6
  unit?: string;
6
7
  };
@@ -4,7 +4,7 @@ import { useState } from "react";
4
4
  import BrickWall from "../BrickWall";
5
5
  import { useCart } from "../../lib/cart/CartContext";
6
6
  import { candidateToCartItem } from "../../lib/boq/toCartItem";
7
- import { searchCatalogue } from "../../lib/estimators/resolveProducts";
7
+ import { estimateRequestUnit, searchCatalogue } from "../../lib/estimators/resolveProducts";
8
8
  const MIN_BASKET_LOADING_MS = 1500;
9
9
  export function useFixedBasket(toolTitle) {
10
10
  const { addManyToCart, clearCart, openBasket } = useCart();
@@ -16,10 +16,10 @@ export function useFixedBasket(toolTitle) {
16
16
  setBasketError(null);
17
17
  try {
18
18
  const items = await Promise.all(lines.filter((line) => line.qty > 0).map(async (line) => {
19
- const candidate = (await searchCatalogue(line.productId)).find((item) => item.product_id === line.productId);
19
+ const candidate = (await searchCatalogue(line.search)).find((item) => item.product_id === line.productId);
20
20
  if (!candidate)
21
21
  throw new Error("A selected product is no longer available in this catalogue.");
22
- return candidateToCartItem(candidate, { lineId: `${toolTitle}:${line.key}`, description: candidate.description, unit: line.unit ?? "each", qty: line.qty, calculatorWasteIncluded: wasteIncluded });
22
+ return candidateToCartItem(candidate, { lineId: `${toolTitle}:${line.key}`, description: candidate.description, unit: estimateRequestUnit(line.unit ?? "each", candidate), qty: line.qty, calculatorWasteIncluded: wasteIncluded });
23
23
  }));
24
24
  const remaining = MIN_BASKET_LOADING_MS - (Date.now() - startedAt);
25
25
  if (remaining > 0)