@solumflow-app/product-configurator 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +165 -2
- package/dist/chunk-QVDJ45QE.js +1 -0
- package/dist/configurator-scene-JY66JQRL.js +1 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +112 -0
- package/package.json +53 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ascensie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,166 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @solumflow-app/product-configurator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Show a product's 3D configurator on your own website, with the price that
|
|
4
|
+
follows every choice, and send what a visitor put together back to the CRM as
|
|
5
|
+
a request for a quote or as an order.
|
|
6
|
+
|
|
7
|
+
The configurator is set up in the CRM: its shape and options in the object
|
|
8
|
+
builder, the measures and prices of each model on the product page. This
|
|
9
|
+
package draws it. It submits nothing itself and never holds an API key: your
|
|
10
|
+
own server does that, with `@solumflow-app/crm-client` 0.6.0 or later, the
|
|
11
|
+
first to carry a configuration.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @solumflow-app/product-configurator @solumflow-app/crm-client \
|
|
15
|
+
three @react-three/fiber @react-three/drei
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`react`, `react-dom`, `three`, `@react-three/fiber` and `@react-three/drei` are
|
|
19
|
+
peer dependencies: your site has one copy of each. three.js is only fetched when
|
|
20
|
+
the configurator scrolls into view; without WebGL the visitor gets a dimension
|
|
21
|
+
sketch, the choices and the price.
|
|
22
|
+
|
|
23
|
+
## Draw it
|
|
24
|
+
|
|
25
|
+
Fetch the product on the server with `getProduct` (its own page carries the
|
|
26
|
+
whole configurator; a listing only says `{ configurator: true }`) and hand it to
|
|
27
|
+
the component together with the slug of the configurator field.
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// app/haardmeubels/[slug]/page.tsx
|
|
31
|
+
import { createClient } from '@solumflow-app/crm-client';
|
|
32
|
+
|
|
33
|
+
import { Configurator } from './configurator';
|
|
34
|
+
|
|
35
|
+
const crm = createClient({ apiKey: process.env.CRM_API_KEY! });
|
|
36
|
+
|
|
37
|
+
export default async function ModelPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
38
|
+
const product = await crm.getProduct((await params).slug);
|
|
39
|
+
|
|
40
|
+
return <Configurator product={product!} />;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
// app/haardmeubels/[slug]/configurator.tsx
|
|
46
|
+
'use client';
|
|
47
|
+
|
|
48
|
+
import { useState, useTransition } from 'react';
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
type ConfigurationPayload,
|
|
52
|
+
ProductConfigurator,
|
|
53
|
+
} from '@solumflow-app/product-configurator';
|
|
54
|
+
|
|
55
|
+
import { askForQuote } from './actions';
|
|
56
|
+
|
|
57
|
+
export function Configurator({ product }: { product: { id: string; fields: Record<string, unknown> } }) {
|
|
58
|
+
const [pending, startTransition] = useTransition();
|
|
59
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<ProductConfigurator
|
|
64
|
+
product={product}
|
|
65
|
+
attribute="meubel"
|
|
66
|
+
submitLabel="Offerte aanvragen"
|
|
67
|
+
onSubmit={(configuration: ConfigurationPayload) =>
|
|
68
|
+
startTransition(async () => setMessage(await askForQuote(product.id, configuration)))
|
|
69
|
+
}
|
|
70
|
+
/>
|
|
71
|
+
{pending ? <p>Bezig…</p> : message ? <p>{message}</p> : null}
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pass `labels` for another language, `locale` and `currency` for amounts, and
|
|
78
|
+
style it through the CSS variables `--pc-bg`, `--pc-surface`, `--pc-fg`,
|
|
79
|
+
`--pc-accent` and their neighbours.
|
|
80
|
+
|
|
81
|
+
## Send a request (server action)
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// app/haardmeubels/[slug]/actions.ts
|
|
85
|
+
'use server';
|
|
86
|
+
|
|
87
|
+
import { CrmApiError, createClient } from '@solumflow-app/crm-client';
|
|
88
|
+
import type { ConfigurationPayload } from '@solumflow-app/product-configurator';
|
|
89
|
+
|
|
90
|
+
const crm = createClient({ apiKey: process.env.CRM_API_KEY! });
|
|
91
|
+
|
|
92
|
+
export async function askForQuote(productId: string, configuration: ConfigurationPayload) {
|
|
93
|
+
try {
|
|
94
|
+
await crm.submitRequest({
|
|
95
|
+
contact: { email: 'visitor@example.com' },
|
|
96
|
+
message: 'Graag een offerte voor deze samenstelling.',
|
|
97
|
+
productId,
|
|
98
|
+
configuration,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return 'Bedankt, we nemen contact op.';
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof CrmApiError && error.code === 'price_changed') {
|
|
104
|
+
// The price moved since the page was drawn. `error.details` holds the
|
|
105
|
+
// server's quote: show it, and let the visitor confirm again.
|
|
106
|
+
return 'De prijs is gewijzigd. Bekijk de nieuwe prijs en bevestig opnieuw.';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (error instanceof CrmApiError && error.code === 'structure_changed') {
|
|
110
|
+
// The configurator itself changed: fetch the product again (and let
|
|
111
|
+
// your cache go: the CRM sends `product.changed` for this).
|
|
112
|
+
return 'De configurator is bijgewerkt. Laad de pagina opnieuw.';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The server works the price out again from what is stored now and records the
|
|
121
|
+
request with the full configuration beside the contact. A product that is no
|
|
122
|
+
longer for sale still records the request (`configuration.recorded: false`), so
|
|
123
|
+
a lead is never lost.
|
|
124
|
+
|
|
125
|
+
## Place an order (server action and Stripe.js)
|
|
126
|
+
|
|
127
|
+
Only a fully priced configuration can be bought: one with a part "on request"
|
|
128
|
+
is refused with `configuration_not_priced` -- send a request instead. The body
|
|
129
|
+
never carries an amount; the server charges its own quote.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
'use server';
|
|
133
|
+
|
|
134
|
+
export async function orderConfiguration(productId: string, configuration: ConfigurationPayload) {
|
|
135
|
+
const order = await crm.submitOrder({
|
|
136
|
+
buyer: { email: 'visitor@example.com' },
|
|
137
|
+
lines: [{ productId, quantity: 1, configuration }],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Hand these to Stripe.js on the page: the payment is taken on the
|
|
141
|
+
// seller's own Stripe account.
|
|
142
|
+
return {
|
|
143
|
+
clientSecret: order.payment.clientSecret,
|
|
144
|
+
stripeAccount: order.payment.stripeAccount,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
On the page, initialise Stripe with the returned `stripeAccount`
|
|
150
|
+
(`loadStripe(publishableKey, { stripeAccount })`) and confirm the payment with
|
|
151
|
+
the `clientSecret`.
|
|
152
|
+
|
|
153
|
+
## What the refusals mean
|
|
154
|
+
|
|
155
|
+
| Code | Status | What to do |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| `price_changed` | 422 | Show the price in `error.details` and ask the visitor to confirm. Asking again with the same idempotency key works: this refusal does not hold it. |
|
|
158
|
+
| `structure_changed` | 409 | Fetch the product again and redraw. Does not hold the key either. |
|
|
159
|
+
| `configuration_not_priced` | 422 | Part of the choice is "on request": offer a quote request instead of an order. |
|
|
160
|
+
| `invalid_request` | 422 | A choice the model does not offer; `error.fields` names it. |
|
|
161
|
+
|
|
162
|
+
## Analytics
|
|
163
|
+
|
|
164
|
+
After a successful request, push the lead yourself:
|
|
165
|
+
`window.dataLayer.push({ event: 'generate_lead', lead_source: 'configurator' })`.
|
|
166
|
+
A paid order sends the usual ecommerce events from your checkout.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var s=Object.defineProperty;var i=(t,e)=>{for(var n in e)s(t,n,{get:e[n],enumerable:true});};function c(t){let e=t.material,n=e?.roughness??.7,r=e?.metalness??0,o=e?.emissive===true,a=e?.opacity??1;return {color:t.color,roughness:n,metalness:r,emissive:o,transparent:a<1,opacity:a,texture:e?.texture??null,textureSize:e?.textureSize??1e3}}function l(t){let[e,n]=t.size,r=Math.min(Math.max(t.params.niche_x??0,0),e),o=Math.min(Math.max(t.params.niche_y??0,0),n);return {x:r,y:o,width:Math.min(Math.max(t.params.niche_width??0,0),e-r),height:Math.min(Math.max(t.params.niche_height??0,0),n-o)}}export{i as a,l as b,c};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import {c,b}from'./chunk-QVDJ45QE.js';import {useState,useRef,useLayoutEffect,useMemo,useEffect}from'react';import {ContactShadows,OrbitControls}from'@react-three/drei';import {Canvas,useThree,useFrame}from'@react-three/fiber';import*as h from'three';import {jsxs,jsx,Fragment}from'react/jsx-runtime';var v=1/1e3,g=32;function m(t){return t*v}function Q(t){t.computeBoundingBox();let e=t.boundingBox?.getCenter(new h.Vector3);return e&&t.translate(-e.x,-e.y,-e.z),t.computeBoundingBox(),t}function $(t){let e=t.getAttribute("position"),s=t.getAttribute("normal");if(!e||!s)return t;let n=new Float32Array(e.count*2);for(let o=0;o<e.count;o+=1){let a=Math.abs(s.getX(o)),r=Math.abs(s.getY(o)),i=Math.abs(s.getZ(o));a>=r&&a>=i?(n[o*2]=e.getZ(o),n[o*2+1]=e.getY(o)):r>=i?(n[o*2]=e.getX(o),n[o*2+1]=e.getZ(o)):(n[o*2]=e.getX(o),n[o*2+1]=e.getY(o));}return t.setAttribute("uv",new h.BufferAttribute(n,2)),t}function E(t){return $(Q(t))}function x(t){return {depth:t,bevelEnabled:false,curveSegments:g,steps:1}}var O=35,ee=1.5,R=Math.PI/180;function _(t){let{bounds:e,azimuth:s=0,elevation:n=10,aspect:o=ee}=t,a=f=>(e.min[f]+e.max[f])/2*v,r=f=>(e.max[f]-e.min[f])*v,i=[a(0),a(1),a(2)],c=Math.max(Math.hypot(r(0),r(1),r(2))/2,.1),l=O*R/2,y=Math.atan(Math.tan(l)*o),p=c/Math.sin(Math.min(l,y)),u=[i[0]+p*Math.sin(s*R)*Math.cos(n*R),i[1]+p*Math.sin(n*R),i[2]+p*Math.cos(s*R)*Math.cos(n*R)];return {target:i,position:u,radius:c,distance:p}}function te(t){let[e,,s]=t.size,n=Math.max(0,Math.min(t.params.thickness??0,e,s));return {width:e,depth:s,thickness:n,mirrored:(t.params.mirror??0)>=.5}}function re(t){let{width:e,depth:s,thickness:n,mirrored:o}=te(t),a=[];for(let r=0;r<=g;r+=1){let i=r/g*(Math.PI/2);a.push([e*Math.cos(i),s*Math.sin(i)]);}for(let r=g;r>=0;r-=1){let i=r/g*(Math.PI/2);a.push([(e-n)*Math.cos(i),(s-n)*Math.sin(i)]);}return a.map(([r,i])=>[o?e-r:r,i])}function k(t){let[,e]=t.size,s=new h.Shape(re(t).map(([o,a])=>new h.Vector2(m(o),m(a)))),n=new h.ExtrudeGeometry(s,x(m(e)));return n.rotateX(Math.PI/2),E(n)}function j(t){let[e,s,n]=t.size;return E(new h.BoxGeometry(m(e),m(s),m(n)))}function X(t){let[e,s,n]=t.size;return E(new h.BoxGeometry(m(e),m(s),m(n)))}function Y(t){let[e,s,n]=t.size,o=b(t),a=new h.Shape;if(a.moveTo(0,0),a.lineTo(m(e),0),a.lineTo(m(e),m(s)),a.lineTo(0,m(s)),a.closePath(),o.width>0&&o.height>0){let r=new h.Path;r.moveTo(m(o.x),m(o.y)),r.lineTo(m(o.x+o.width),m(o.y)),r.lineTo(m(o.x+o.width),m(o.y+o.height)),r.lineTo(m(o.x),m(o.y+o.height)),r.closePath(),a.holes.push(r);}return E(new h.ExtrudeGeometry(a,x(m(n))))}function oe(t){let[,e,s]=t.size,n=Math.max(0,Math.min(t.params.radius??0,e,s));return {depth:s,height:e,radius:n}}function J(t){let[e]=t.size,{depth:s,height:n,radius:o}=oe(t),a=m(s),r=m(n),i=m(o),c=new h.Shape;c.moveTo(0,0),c.lineTo(a,0),c.lineTo(a,r-i),i>0&&c.absarc(a-i,r-i,i,0,Math.PI/2,false),c.lineTo(0,r),c.closePath();let l=new h.ExtrudeGeometry(c,x(m(e)));return l.rotateY(-Math.PI/2),E(l)}function ne(t){let[e,s]=t.size;return Math.min(e,s)/2}function W(t){let[e,s,n]=t.size,o=m(e),a=m(s),r=m(ne(t)),i=new h.Shape;return i.moveTo(r,0),i.lineTo(o-r,0),i.absarc(o-r,r,r,-Math.PI/2,0,false),i.lineTo(o,a-r),i.absarc(o-r,a-r,r,0,Math.PI/2,false),i.lineTo(r,a),i.absarc(r,a-r,r,Math.PI/2,Math.PI,false),i.lineTo(0,r),i.absarc(r,r,r,Math.PI,3*Math.PI/2,false),E(new h.ExtrudeGeometry(i,x(m(n))))}var ae={box:j,rounded_top_box:J,stadium_prism:W,arc_panel:k,led_strip:X,niche_panel:Y};function Z(t){return ae[t.type](t)}function K(t){return [m(t.at[0]+t.size[0]/2),m(t.at[1]+t.size[1]/2),m(t.at[2]+t.size[2]/2)]}var ue=.05,le=1e-4;function Ke(t){let{geometry:e,slots:s,camera:n,interactive:o=true,className:a}=t,r=useState(()=>_({bounds:e.bounds,azimuth:n?.azimuth,elevation:n?.elevation}))[0],i=e.bounds.min[1]/1e3-.001;return jsxs(Canvas,{className:a,frameloop:"demand",dpr:[1,2],camera:{fov:O,position:r.position,near:.05,far:100},gl:{antialias:true,alpha:true},children:[jsx("hemisphereLight",{args:["#ffffff","#8a857c",1.1]}),jsx("directionalLight",{position:[3,5,4],intensity:1.6}),jsx("directionalLight",{position:[-4,2,3],intensity:.5}),jsx(de,{parts:e.parts,slots:s}),jsx(ContactShadows,{position:[r.target[0],i,r.target[2]],opacity:.45,scale:Math.max(r.radius*4,2),blur:2.4,far:1.5}),jsx(fe,{geometry:e,camera:n,interactive:o})]})}function fe(t){let{geometry:e,camera:s,interactive:n}=t,{camera:o,invalidate:a}=useThree(),r=useRef(null),i=useRef(null),c=_({bounds:e.bounds,azimuth:s?.azimuth,elevation:s?.elevation}),[l,y,p]=c.target,{radius:u}=c;return useLayoutEffect(()=>{let f=new h.Vector3(l,y,p),d=i.current;if(d){let G=o.position.clone().sub(d.target);G.multiplyScalar(u/d.radius),o.position.copy(f).add(G);}r.current?.target.copy(f),r.current?.update(),o.lookAt(f),i.current={target:f,radius:u},a();},[l,y,p,u,o,a]),jsx(OrbitControls,{ref:r,makeDefault:true,enabled:n,enablePan:false,minDistance:u*.8,maxDistance:u*8,maxPolarAngle:Math.PI/2+.05})}function de(t){let e=Ee(t.slots);return jsx(Fragment,{children:t.parts.map(s=>{let n=e[s.slot];return n?jsx(he,{part:s,material:n},s.key):null})})}function he(t){let{part:e,material:s}=t,n=useRef(null),o=useRef(false),a=useRef(e.size),r=useThree(u=>u.invalidate),[i,c,l]=K(e),y=JSON.stringify(e.params),p=useMemo(()=>Z(e),[e.type,e.size[0],e.size[1],e.size[2],y]);return useEffect(()=>()=>p.dispose(),[p]),useLayoutEffect(()=>{let u=n.current;if(u){if(!o.current)u.position.set(i,c,l),o.current=true;else {let f=a.current,d=u.scale;d.set(d.x*A(f[0],e.size[0]),d.y*A(f[1],e.size[1]),d.z*A(f[2],e.size[2]));}a.current=e.size,r();}},[p,e.size,i,c,l,r]),useFrame((u,f)=>{let d=n.current;if(!d)return;let G=1-Math.exp(-f/ue),w=new h.Vector3(i,c,l),C=new h.Vector3(1,1,1),D=Math.max(d.position.distanceTo(w),d.scale.distanceTo(C));if(D<le){D>0&&(d.position.copy(w),d.scale.copy(C));return}d.position.lerp(w,G),d.scale.lerp(C,G),r();}),jsx("mesh",{ref:n,geometry:p,material:s})}function A(t,e){return t>0&&e>0?t/e:1}function Ee(t){let e=useThree(a=>a.invalidate),s=JSON.stringify(Object.entries(t).map(([a,r])=>[a,c(r)])),n=useMemo(()=>JSON.parse(s),[s]),o=useMemo(()=>Object.fromEntries(n.map(([a,r])=>[a,ye(r)])),[n]);return useEffect(()=>{let a=new h.TextureLoader;a.setCrossOrigin("anonymous");let r=false,i=[];for(let[c,l]of n){let y=o[c];!l.texture||!y||a.load(l.texture,p=>{if(r){p.dispose();return}p.colorSpace=h.SRGBColorSpace,p.wrapS=h.RepeatWrapping,p.wrapT=h.RepeatWrapping;let u=1e3/l.textureSize;p.repeat.set(u,u),y.map=p,y.color.set("#ffffff"),y.needsUpdate=true,i.push(p),e();},void 0,()=>{});}return ()=>{r=true;for(let c of i)c.dispose();}},[n,o,e]),useEffect(()=>()=>{for(let a of Object.values(o))a.dispose();},[o]),o}function ye(t){let e=new h.MeshStandardMaterial({color:t.color,roughness:t.roughness,metalness:t.metalness});return t.emissive&&(e.emissive.set(t.color),e.emissiveIntensity=1.4),t.transparent&&(e.transparent=true,e.opacity=t.opacity,e.depthWrite=false),e}export{Ke as ConfiguratorScene};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The public types of `@solumflow-app/product-configurator`, written by hand
|
|
5
|
+
* so the published declarations name nothing but React. `src/declarations-match.ts`
|
|
6
|
+
* fails the build when the implementation drifts from them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What a visitor chose: numbers, codes and switches only, never free text. */
|
|
10
|
+
export interface ConfigurationSelection {
|
|
11
|
+
values: Record<string, number | boolean | string>;
|
|
12
|
+
preset?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `priced`: everything has a price, and only this can be ordered.
|
|
17
|
+
* `partial`: something chosen is "price on request"; the totals cover the rest.
|
|
18
|
+
* `open`: the model has no price of its own; the totals are null.
|
|
19
|
+
*/
|
|
20
|
+
export type ConfigurationQuoteState = 'priced' | 'partial' | 'open';
|
|
21
|
+
|
|
22
|
+
export interface ConfigurationQuoteLine {
|
|
23
|
+
/** `base`, or the key of the price rule. */
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
/** In the account's own entry mode; null is "price on request". */
|
|
27
|
+
amountCents: number | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ConfigurationQuote {
|
|
31
|
+
engineVersion: number;
|
|
32
|
+
state: ConfigurationQuoteState;
|
|
33
|
+
lines: ConfigurationQuoteLine[];
|
|
34
|
+
netCents: number | null;
|
|
35
|
+
taxCents: number | null;
|
|
36
|
+
totalCents: number | null;
|
|
37
|
+
taxPercentage: number;
|
|
38
|
+
pricesIncludeTax: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What `submitRequest` and `submitOrder` take as `configuration`. */
|
|
42
|
+
export interface ConfigurationPayload {
|
|
43
|
+
attribute: string;
|
|
44
|
+
selection: ConfigurationSelection;
|
|
45
|
+
structureHash: string;
|
|
46
|
+
expected: { state: ConfigurationQuoteState; totalCents: number | null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A product as `getProduct` returns it; only `fields` is read. */
|
|
50
|
+
export interface ConfiguratorProduct {
|
|
51
|
+
fields: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every word the configurator shows that does not come from the product. Dutch by default. */
|
|
55
|
+
export interface ConfiguratorLabels {
|
|
56
|
+
tabsLabel: string;
|
|
57
|
+
presetNone: string;
|
|
58
|
+
switchOn: string;
|
|
59
|
+
switchOff: string;
|
|
60
|
+
unitMm: string;
|
|
61
|
+
unitCount: string;
|
|
62
|
+
surchargePrefix: string;
|
|
63
|
+
optionOnRequest: string;
|
|
64
|
+
priceHeading: string;
|
|
65
|
+
priceIncludingTax: string;
|
|
66
|
+
priceFrom: string;
|
|
67
|
+
priceInPartOnRequest: string;
|
|
68
|
+
priceOnRequest: string;
|
|
69
|
+
priceUnavailable: string;
|
|
70
|
+
sketchTitle: string;
|
|
71
|
+
sketchUnavailable: string;
|
|
72
|
+
sceneLoading: string;
|
|
73
|
+
sceneLabel: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ConfigurationChange {
|
|
77
|
+
selection: ConfigurationSelection;
|
|
78
|
+
/** Null while the selection cannot be priced. */
|
|
79
|
+
quote: ConfigurationQuote | null;
|
|
80
|
+
/** What to submit for this selection; null while it cannot be priced. */
|
|
81
|
+
payload: ConfigurationPayload | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ProductConfiguratorProps {
|
|
85
|
+
product: ConfiguratorProduct;
|
|
86
|
+
/** The slug of the configurator field. */
|
|
87
|
+
attribute: string;
|
|
88
|
+
initialSelection?: ConfigurationSelection;
|
|
89
|
+
onChange?: (change: ConfigurationChange) => void;
|
|
90
|
+
/** Draws a submit button that hands over the payload; the site submits it. */
|
|
91
|
+
onSubmit?: (payload: ConfigurationPayload) => void;
|
|
92
|
+
submitLabel?: string;
|
|
93
|
+
labels?: Partial<ConfiguratorLabels>;
|
|
94
|
+
/** For amounts: `nl-NL` by default. */
|
|
95
|
+
locale?: string;
|
|
96
|
+
/** `EUR` by default. */
|
|
97
|
+
currency?: string;
|
|
98
|
+
className?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ReadConfigurator {
|
|
102
|
+
structure: object;
|
|
103
|
+
value: object;
|
|
104
|
+
tax: { taxPercentage: number; pricesIncludeTax: boolean };
|
|
105
|
+
structureHash: string;
|
|
106
|
+
valueHash: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export declare class ConfiguratorFieldError extends Error {
|
|
110
|
+
readonly reason: 'missing' | 'listing' | 'invalid';
|
|
111
|
+
constructor(reason: 'missing' | 'listing' | 'invalid', message: string);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export declare function readConfigurator(
|
|
115
|
+
product: ConfiguratorProduct,
|
|
116
|
+
attribute: string,
|
|
117
|
+
): ReadConfigurator;
|
|
118
|
+
|
|
119
|
+
export declare function toConfigurationPayload(
|
|
120
|
+
attribute: string,
|
|
121
|
+
structureHash: string,
|
|
122
|
+
selection: ConfigurationSelection,
|
|
123
|
+
quote: ConfigurationQuote,
|
|
124
|
+
): ConfigurationPayload;
|
|
125
|
+
|
|
126
|
+
export declare const DEFAULT_CONFIGURATOR_LABELS: ConfiguratorLabels;
|
|
127
|
+
|
|
128
|
+
export declare function ProductConfigurator(
|
|
129
|
+
props: ProductConfiguratorProps,
|
|
130
|
+
): JSX.Element;
|