@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 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
- # Temporary Holding Version
1
+ # @solumflow-app/product-configurator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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};
@@ -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;