@holoscript/plugin-retail-ecommerce 2.0.1 → 2.0.2
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/package.json +3 -3
- package/src/__tests__/retailsolver.test.ts +13 -13
- package/src/__tests__/runtime-integration.test.ts +2 -6
- package/src/index.ts +48 -7
- package/src/retailsolver.ts +83 -31
- package/src/runtime.ts +3 -3
- package/src/traits/CartTrait.ts +44 -11
- package/src/traits/CheckoutTrait.ts +67 -12
- package/src/traits/ProductCatalogTrait.ts +44 -10
- package/src/traits/ReturnTrait.ts +49 -11
- package/src/traits/ShippingRateTrait.ts +37 -6
- package/src/traits/types.ts +25 -4
- package/tsconfig.json +10 -1
- package/vitest.config.ts +0 -7
- package/LICENSE +0 -21
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holoscript/plugin-retail-ecommerce",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "HoloScript domain plugin for retail-ecommerce",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"peerDependencies": {
|
|
7
|
-
"@holoscript/core": "8.0.
|
|
7
|
+
"@holoscript/core": ">=8.0.0"
|
|
8
8
|
},
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "vitest run --passWithNoTests",
|
|
12
12
|
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
13
13
|
}
|
|
14
|
-
}
|
|
14
|
+
}
|
|
@@ -30,7 +30,7 @@ describe('economicOrderQuantity', () => {
|
|
|
30
30
|
*/
|
|
31
31
|
it('EOQ = √(2DS/H)', () => {
|
|
32
32
|
const r = economicOrderQuantity(1000, 10, 2.5);
|
|
33
|
-
expect(r.eoq).toBeCloseTo(Math.sqrt(2 * 1000 * 10 / 2.5), 1);
|
|
33
|
+
expect(r.eoq).toBeCloseTo(Math.sqrt((2 * 1000 * 10) / 2.5), 1);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
it('ordersPerYear = demand / EOQ', () => {
|
|
@@ -150,12 +150,12 @@ describe('customerLifetimeValue', () => {
|
|
|
150
150
|
* Discounted CLV = Σ_{t=1}^{5} 400 / 1.1^t ≈ 400 × 3.791 ≈ $1516
|
|
151
151
|
*/
|
|
152
152
|
it('simpleCLV = AOV × frequency × lifespan', () => {
|
|
153
|
-
const r = customerLifetimeValue(100, 4, 5, 0.
|
|
153
|
+
const r = customerLifetimeValue(100, 4, 5, 0.1);
|
|
154
154
|
expect(r.simpleCLV).toBeCloseTo(2000, 4);
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it('discountedCLV < simpleCLV for positive discount rate', () => {
|
|
158
|
-
const r = customerLifetimeValue(100, 4, 5, 0.
|
|
158
|
+
const r = customerLifetimeValue(100, 4, 5, 0.1);
|
|
159
159
|
expect(r.discountedCLV).toBeLessThan(r.simpleCLV);
|
|
160
160
|
});
|
|
161
161
|
|
|
@@ -165,18 +165,18 @@ describe('customerLifetimeValue', () => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it('CLV:CAC ratio computed when CAC provided', () => {
|
|
168
|
-
const r = customerLifetimeValue(100, 4, 5, 0.
|
|
168
|
+
const r = customerLifetimeValue(100, 4, 5, 0.1, 200);
|
|
169
169
|
expect(r.clvToCac).not.toBeNull();
|
|
170
170
|
expect(r.clvToCac!).toBeCloseTo(r.discountedCLV / 200, 4);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
173
|
it('CLV:CAC null when CAC not provided', () => {
|
|
174
|
-
const r = customerLifetimeValue(100, 4, 5, 0.
|
|
174
|
+
const r = customerLifetimeValue(100, 4, 5, 0.1);
|
|
175
175
|
expect(r.clvToCac).toBeNull();
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
it('throws for zero AOV', () => {
|
|
179
|
-
expect(() => customerLifetimeValue(0, 4, 5, 0.
|
|
179
|
+
expect(() => customerLifetimeValue(0, 4, 5, 0.1)).toThrow();
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
182
|
|
|
@@ -194,9 +194,9 @@ describe('conversionFunnelAnalysis', () => {
|
|
|
194
194
|
|
|
195
195
|
it('step conversions correct', () => {
|
|
196
196
|
const r = conversionFunnelAnalysis(stages, counts, 75);
|
|
197
|
-
expect(r.stepConversions[0]).toBeCloseTo(0.
|
|
197
|
+
expect(r.stepConversions[0]).toBeCloseTo(0.2, 4);
|
|
198
198
|
expect(r.stepConversions[1]).toBeCloseTo(0.25, 4);
|
|
199
|
-
expect(r.stepConversions[2]).toBeCloseTo(0.
|
|
199
|
+
expect(r.stepConversions[2]).toBeCloseTo(0.5, 4);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
202
|
it('overall conversion = final / first', () => {
|
|
@@ -222,9 +222,9 @@ describe('conversionFunnelAnalysis', () => {
|
|
|
222
222
|
|
|
223
223
|
describe('abcClassification', () => {
|
|
224
224
|
const items = [
|
|
225
|
-
{ id: 'SKU-1', annualVolume: 100, unitCost: 500 },
|
|
226
|
-
{ id: 'SKU-2', annualVolume: 500, unitCost: 20
|
|
227
|
-
{ id: 'SKU-3', annualVolume: 1000, unitCost: 5
|
|
225
|
+
{ id: 'SKU-1', annualVolume: 100, unitCost: 500 }, // $50k — A
|
|
226
|
+
{ id: 'SKU-2', annualVolume: 500, unitCost: 20 }, // $10k — A/B
|
|
227
|
+
{ id: 'SKU-3', annualVolume: 1000, unitCost: 5 }, // $5k — B
|
|
228
228
|
{ id: 'SKU-4', annualVolume: 5000, unitCost: 0.5 }, // $2.5k — C
|
|
229
229
|
{ id: 'SKU-5', annualVolume: 2000, unitCost: 0.2 }, // $400 — C
|
|
230
230
|
];
|
|
@@ -303,13 +303,13 @@ describe('buildRetailReceipt', () => {
|
|
|
303
303
|
});
|
|
304
304
|
|
|
305
305
|
it('accepted=true for healthy metrics', () => {
|
|
306
|
-
const clv = customerLifetimeValue(200, 5, 3, 0.
|
|
306
|
+
const clv = customerLifetimeValue(200, 5, 3, 0.1, 150); // CLV:CAC ≈ 3+
|
|
307
307
|
const receipt = buildRetailReceipt({ clv, converged: true });
|
|
308
308
|
expect(receipt.acceptance.accepted).toBe(true);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
311
|
it('accepted=false when CLV:CAC < 3', () => {
|
|
312
|
-
const clv = customerLifetimeValue(50, 1, 1, 0.
|
|
312
|
+
const clv = customerLifetimeValue(50, 1, 1, 0.1, 500); // poor CLV:CAC
|
|
313
313
|
const receipt = buildRetailReceipt({ clv, converged: true });
|
|
314
314
|
if (clv.clvToCac! < 3) {
|
|
315
315
|
expect(receipt.acceptance.accepted).toBe(false);
|
|
@@ -79,9 +79,7 @@ describe('retail-ecommerce -> HoloScript runtime integration (eoq)', () => {
|
|
|
79
79
|
await flush();
|
|
80
80
|
|
|
81
81
|
const state = runtime.getState() as Record<string, unknown>;
|
|
82
|
-
const persisted = state['eoq:retail'] as
|
|
83
|
-
| { eoq?: number; annualDemand?: number }
|
|
84
|
-
| undefined;
|
|
82
|
+
const persisted = state['eoq:retail'] as { eoq?: number; annualDemand?: number } | undefined;
|
|
85
83
|
expect(persisted).toBeDefined();
|
|
86
84
|
expect(persisted?.eoq).toBeCloseTo(EXPECTED_EOQ, 5);
|
|
87
85
|
expect(persisted?.annualDemand).toBe(1000);
|
|
@@ -97,9 +95,7 @@ describe('retail-ecommerce -> HoloScript runtime integration (eoq)', () => {
|
|
|
97
95
|
});
|
|
98
96
|
|
|
99
97
|
// holdingCostPerUnit omitted -> the handler emits eoq_error, never throws.
|
|
100
|
-
await runtime.executeNode(
|
|
101
|
-
eoqOrb({ annualDemand: 1000, orderingCost: 10 }) as never,
|
|
102
|
-
);
|
|
98
|
+
await runtime.executeNode(eoqOrb({ annualDemand: 1000, orderingCost: 10 }) as never);
|
|
103
99
|
await flush();
|
|
104
100
|
|
|
105
101
|
expect(errors).toHaveLength(1);
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export {
|
|
2
|
+
createCartHandler,
|
|
3
|
+
type CartConfig,
|
|
4
|
+
type CartItem,
|
|
5
|
+
type CartState,
|
|
6
|
+
} from './traits/CartTrait';
|
|
7
|
+
export {
|
|
8
|
+
createCheckoutHandler,
|
|
9
|
+
type CheckoutConfig,
|
|
10
|
+
type CheckoutStep,
|
|
11
|
+
type PaymentMethod,
|
|
12
|
+
type Address,
|
|
13
|
+
} from './traits/CheckoutTrait';
|
|
14
|
+
export {
|
|
15
|
+
createProductCatalogHandler,
|
|
16
|
+
type ProductCatalogConfig,
|
|
17
|
+
type Product,
|
|
18
|
+
} from './traits/ProductCatalogTrait';
|
|
19
|
+
export {
|
|
20
|
+
createShippingRateHandler,
|
|
21
|
+
type ShippingRateConfig,
|
|
22
|
+
type ShippingMethod,
|
|
23
|
+
} from './traits/ShippingRateTrait';
|
|
5
24
|
export { createReturnHandler, type ReturnConfig, type ReturnStatus } from './traits/ReturnTrait';
|
|
6
25
|
export * from './traits/types';
|
|
7
26
|
|
|
@@ -12,7 +31,29 @@ import { createShippingRateHandler } from './traits/ShippingRateTrait';
|
|
|
12
31
|
import { createReturnHandler } from './traits/ReturnTrait';
|
|
13
32
|
|
|
14
33
|
export * from './retailsolver';
|
|
15
|
-
export * from './runtime';
|
|
16
34
|
|
|
17
|
-
export const pluginMeta = {
|
|
18
|
-
|
|
35
|
+
export const pluginMeta = {
|
|
36
|
+
name: '@holoscript/plugin-retail-ecommerce',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
traits: [
|
|
39
|
+
'cart',
|
|
40
|
+
'checkout',
|
|
41
|
+
'product_catalog',
|
|
42
|
+
'shipping_rate',
|
|
43
|
+
'return',
|
|
44
|
+
'eoq',
|
|
45
|
+
'price_optimization',
|
|
46
|
+
'markdown',
|
|
47
|
+
'clv',
|
|
48
|
+
'conversion_funnel',
|
|
49
|
+
'abc_classification',
|
|
50
|
+
'inventory_metrics',
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
export const traitHandlers = [
|
|
54
|
+
createCartHandler(),
|
|
55
|
+
createCheckoutHandler(),
|
|
56
|
+
createProductCatalogHandler(),
|
|
57
|
+
createShippingRateHandler(),
|
|
58
|
+
createReturnHandler(),
|
|
59
|
+
];
|
package/src/retailsolver.ts
CHANGED
|
@@ -62,7 +62,12 @@ export interface MarkdownResult {
|
|
|
62
62
|
/** Days until season end */
|
|
63
63
|
daysRemaining: number;
|
|
64
64
|
/** Optimal markdown schedule */
|
|
65
|
-
schedule: Array<{
|
|
65
|
+
schedule: Array<{
|
|
66
|
+
dayOfMarkdown: number;
|
|
67
|
+
discountPct: number;
|
|
68
|
+
price: number;
|
|
69
|
+
expectedUnits: number;
|
|
70
|
+
}>;
|
|
66
71
|
/** Expected total revenue */
|
|
67
72
|
expectedRevenue: number;
|
|
68
73
|
/** Expected unsold units */
|
|
@@ -126,7 +131,7 @@ export function economicOrderQuantity(
|
|
|
126
131
|
orderingCost: number,
|
|
127
132
|
holdingCostPerUnit: number,
|
|
128
133
|
leadTimeDays = 0,
|
|
129
|
-
workingDaysPerYear = 250
|
|
134
|
+
workingDaysPerYear = 250
|
|
130
135
|
): EOQResult {
|
|
131
136
|
if (annualDemand <= 0) throw new Error('annualDemand must be positive');
|
|
132
137
|
if (orderingCost <= 0) throw new Error('orderingCost must be positive');
|
|
@@ -138,7 +143,15 @@ export function economicOrderQuantity(
|
|
|
138
143
|
const dailyDemand = annualDemand / workingDaysPerYear;
|
|
139
144
|
const reorderPoint = dailyDemand * leadTimeDays;
|
|
140
145
|
|
|
141
|
-
return {
|
|
146
|
+
return {
|
|
147
|
+
annualDemand,
|
|
148
|
+
orderingCost,
|
|
149
|
+
holdingCostPerUnit,
|
|
150
|
+
eoq,
|
|
151
|
+
ordersPerYear,
|
|
152
|
+
totalAnnualCost,
|
|
153
|
+
reorderPoint,
|
|
154
|
+
};
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
// ─── Price optimization via demand elasticity ─────────────────────────────────
|
|
@@ -159,7 +172,7 @@ export function priceOptimization(
|
|
|
159
172
|
currentDemand: number,
|
|
160
173
|
elasticity: number,
|
|
161
174
|
variableCost = 0,
|
|
162
|
-
priceRange?: [number, number]
|
|
175
|
+
priceRange?: [number, number]
|
|
163
176
|
): PriceOptimizationResult {
|
|
164
177
|
if (currentPrice <= 0) throw new Error('currentPrice must be positive');
|
|
165
178
|
if (currentDemand <= 0) throw new Error('currentDemand must be positive');
|
|
@@ -167,22 +180,24 @@ export function priceOptimization(
|
|
|
167
180
|
|
|
168
181
|
const demandAt = (p: number) => currentDemand * Math.pow(p / currentPrice, elasticity);
|
|
169
182
|
const revenueAt = (p: number) => p * demandAt(p);
|
|
170
|
-
const profitAt
|
|
183
|
+
const profitAt = (p: number) => (p - variableCost) * demandAt(p);
|
|
171
184
|
|
|
172
185
|
// Profit-maximizing price via ternary search
|
|
173
186
|
const pLo = priceRange ? priceRange[0] : variableCost * 1.01;
|
|
174
187
|
const pHi = priceRange ? priceRange[1] : currentPrice * 3;
|
|
175
188
|
|
|
176
|
-
let lo = Math.max(pLo, variableCost + 0.01),
|
|
189
|
+
let lo = Math.max(pLo, variableCost + 0.01),
|
|
190
|
+
hi = pHi;
|
|
177
191
|
for (let i = 0; i < 100; i++) {
|
|
178
192
|
const m1 = lo + (hi - lo) / 3;
|
|
179
193
|
const m2 = hi - (hi - lo) / 3;
|
|
180
|
-
if (profitAt(m1) < profitAt(m2)) lo = m1;
|
|
194
|
+
if (profitAt(m1) < profitAt(m2)) lo = m1;
|
|
195
|
+
else hi = m2;
|
|
181
196
|
}
|
|
182
197
|
const optimalPrice = (lo + hi) / 2;
|
|
183
198
|
|
|
184
|
-
const currentRevenue
|
|
185
|
-
const optimalRevenue
|
|
199
|
+
const currentRevenue = revenueAt(currentPrice);
|
|
200
|
+
const optimalRevenue = revenueAt(optimalPrice);
|
|
186
201
|
const revenueUpliftPct = ((optimalRevenue - currentRevenue) / currentRevenue) * 100;
|
|
187
202
|
|
|
188
203
|
// Price-demand curve: 20 points from 50% to 200% of current price
|
|
@@ -191,7 +206,15 @@ export function priceOptimization(
|
|
|
191
206
|
return { price: p, demand: demandAt(p), revenue: revenueAt(p) };
|
|
192
207
|
});
|
|
193
208
|
|
|
194
|
-
return {
|
|
209
|
+
return {
|
|
210
|
+
currentPrice,
|
|
211
|
+
elasticity,
|
|
212
|
+
optimalPrice,
|
|
213
|
+
currentRevenue,
|
|
214
|
+
optimalRevenue,
|
|
215
|
+
revenueUpliftPct,
|
|
216
|
+
priceDemandCurve,
|
|
217
|
+
};
|
|
195
218
|
}
|
|
196
219
|
|
|
197
220
|
// ─── Markdown optimization ────────────────────────────────────────────────────
|
|
@@ -206,7 +229,7 @@ export function markdownOptimization(
|
|
|
206
229
|
daysRemaining: number,
|
|
207
230
|
baseDailySalesRate: number,
|
|
208
231
|
elasticity: number,
|
|
209
|
-
markdownSteps: number[] = [10, 20, 30, 50]
|
|
232
|
+
markdownSteps: number[] = [10, 20, 30, 50]
|
|
210
233
|
): MarkdownResult {
|
|
211
234
|
if (originalPrice <= 0) throw new Error('originalPrice must be positive');
|
|
212
235
|
if (inventory <= 0) throw new Error('inventory must be positive');
|
|
@@ -236,7 +259,12 @@ export function markdownOptimization(
|
|
|
236
259
|
totalRevenue += unitsSold * currentPrice;
|
|
237
260
|
remaining = Math.max(0, remaining - unitsSold);
|
|
238
261
|
|
|
239
|
-
schedule.push({
|
|
262
|
+
schedule.push({
|
|
263
|
+
dayOfMarkdown,
|
|
264
|
+
discountPct,
|
|
265
|
+
price: newPrice,
|
|
266
|
+
expectedUnits: Math.round(newRate * stepSize),
|
|
267
|
+
});
|
|
240
268
|
|
|
241
269
|
currentPrice = newPrice;
|
|
242
270
|
currentRate = newRate;
|
|
@@ -249,7 +277,14 @@ export function markdownOptimization(
|
|
|
249
277
|
totalRevenue += finalUnits * currentPrice;
|
|
250
278
|
remaining = Math.max(0, remaining - finalUnits);
|
|
251
279
|
|
|
252
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
originalPrice,
|
|
282
|
+
inventory,
|
|
283
|
+
daysRemaining,
|
|
284
|
+
schedule,
|
|
285
|
+
expectedRevenue: totalRevenue,
|
|
286
|
+
expectedUnsold: remaining,
|
|
287
|
+
};
|
|
253
288
|
}
|
|
254
289
|
|
|
255
290
|
// ─── Customer Lifetime Value ──────────────────────────────────────────────────
|
|
@@ -264,7 +299,7 @@ export function customerLifetimeValue(
|
|
|
264
299
|
purchaseFrequency: number,
|
|
265
300
|
lifespanYears: number,
|
|
266
301
|
discountRate: number,
|
|
267
|
-
cac?: number
|
|
302
|
+
cac?: number
|
|
268
303
|
): CLVResult {
|
|
269
304
|
if (avgOrderValue <= 0) throw new Error('avgOrderValue must be positive');
|
|
270
305
|
if (purchaseFrequency <= 0) throw new Error('purchaseFrequency must be positive');
|
|
@@ -283,7 +318,15 @@ export function customerLifetimeValue(
|
|
|
283
318
|
|
|
284
319
|
const clvToCac = cac != null && cac > 0 ? discountedCLV / cac : null;
|
|
285
320
|
|
|
286
|
-
return {
|
|
321
|
+
return {
|
|
322
|
+
avgOrderValue,
|
|
323
|
+
purchaseFrequency,
|
|
324
|
+
lifespanYears,
|
|
325
|
+
discountRate,
|
|
326
|
+
simpleCLV,
|
|
327
|
+
discountedCLV,
|
|
328
|
+
clvToCac,
|
|
329
|
+
};
|
|
287
330
|
}
|
|
288
331
|
|
|
289
332
|
// ─── Conversion funnel analysis ───────────────────────────────────────────────
|
|
@@ -294,25 +337,25 @@ export function customerLifetimeValue(
|
|
|
294
337
|
export function conversionFunnelAnalysis(
|
|
295
338
|
stages: string[],
|
|
296
339
|
counts: number[],
|
|
297
|
-
avgOrderValue: number
|
|
340
|
+
avgOrderValue: number
|
|
298
341
|
): FunnelAnalysisResult {
|
|
299
342
|
if (stages.length !== counts.length) throw new Error('stages and counts must have same length');
|
|
300
343
|
if (stages.length < 2) throw new Error('Funnel needs at least 2 stages');
|
|
301
|
-
if (counts.some(c => c < 0)) throw new Error('counts must be non-negative');
|
|
344
|
+
if (counts.some((c) => c < 0)) throw new Error('counts must be non-negative');
|
|
302
345
|
if (avgOrderValue <= 0) throw new Error('avgOrderValue must be positive');
|
|
303
346
|
|
|
304
|
-
const stepConversions = counts.slice(1).map((c, i) => counts[i] > 0 ? c / counts[i] : 0);
|
|
347
|
+
const stepConversions = counts.slice(1).map((c, i) => (counts[i] > 0 ? c / counts[i] : 0));
|
|
305
348
|
const overallConversion = counts[0] > 0 ? counts[counts.length - 1] / counts[0] : 0;
|
|
306
349
|
const currentRevenue = counts[counts.length - 1] * avgOrderValue;
|
|
307
350
|
|
|
308
351
|
// Sensitivity: if step i conversion improves by 10pp, how much extra revenue?
|
|
309
352
|
const sensitivityRevenue = stepConversions.map((conv, i) => {
|
|
310
|
-
const newConv = Math.min(1, conv + 0.
|
|
353
|
+
const newConv = Math.min(1, conv + 0.1);
|
|
311
354
|
const upliftFactor = newConv / (conv || 1e-9);
|
|
312
355
|
// Downstream: all subsequent stages are multiplied by this factor
|
|
313
356
|
let newFinal = counts[i + 1] * upliftFactor;
|
|
314
357
|
for (let j = i + 2; j < counts.length; j++) {
|
|
315
|
-
newFinal *=
|
|
358
|
+
newFinal *= counts[j - 1] > 0 ? counts[j] / counts[j - 1] : 1;
|
|
316
359
|
}
|
|
317
360
|
return (newFinal - counts[counts.length - 1]) * avgOrderValue;
|
|
318
361
|
});
|
|
@@ -329,21 +372,23 @@ export function conversionFunnelAnalysis(
|
|
|
329
372
|
* C: remaining 5% of value
|
|
330
373
|
*/
|
|
331
374
|
export function abcClassification(
|
|
332
|
-
items: Array<{ id: string; annualVolume: number; unitCost: number }
|
|
375
|
+
items: Array<{ id: string; annualVolume: number; unitCost: number }>
|
|
333
376
|
): ABCClassification {
|
|
334
377
|
if (items.length === 0) throw new Error('No items provided');
|
|
335
378
|
|
|
336
|
-
const withValues = items
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
379
|
+
const withValues = items
|
|
380
|
+
.map((item) => ({
|
|
381
|
+
id: item.id,
|
|
382
|
+
annualValue: item.annualVolume * item.unitCost,
|
|
383
|
+
}))
|
|
384
|
+
.sort((a, b) => b.annualValue - a.annualValue);
|
|
340
385
|
|
|
341
386
|
const totalValue = withValues.reduce((a, b) => a + b.annualValue, 0);
|
|
342
387
|
let cumulative = 0;
|
|
343
388
|
const classCounts = { A: 0, B: 0, C: 0 };
|
|
344
|
-
const classValue
|
|
389
|
+
const classValue = { A: 0, B: 0, C: 0 };
|
|
345
390
|
|
|
346
|
-
const classified = withValues.map(item => {
|
|
391
|
+
const classified = withValues.map((item) => {
|
|
347
392
|
cumulative += item.annualValue;
|
|
348
393
|
const cumulativePct = (cumulative / totalValue) * 100;
|
|
349
394
|
const cls: 'A' | 'B' | 'C' = cumulativePct <= 80 ? 'A' : cumulativePct <= 95 ? 'B' : 'C';
|
|
@@ -381,7 +426,7 @@ export interface InventoryMetrics {
|
|
|
381
426
|
export function inventoryMetrics(
|
|
382
427
|
cogs: number,
|
|
383
428
|
avgInventory: number,
|
|
384
|
-
targetDsi?: number
|
|
429
|
+
targetDsi?: number
|
|
385
430
|
): InventoryMetrics {
|
|
386
431
|
if (cogs <= 0) throw new Error('COGS must be positive');
|
|
387
432
|
if (avgInventory <= 0) throw new Error('avgInventory must be positive');
|
|
@@ -390,7 +435,10 @@ export function inventoryMetrics(
|
|
|
390
435
|
const dsi = 365 / inventoryTurnover;
|
|
391
436
|
|
|
392
437
|
return {
|
|
393
|
-
cogs,
|
|
438
|
+
cogs,
|
|
439
|
+
avgInventory,
|
|
440
|
+
inventoryTurnover,
|
|
441
|
+
dsi,
|
|
394
442
|
targetDsi,
|
|
395
443
|
withinTarget: targetDsi != null ? dsi <= targetDsi : undefined,
|
|
396
444
|
};
|
|
@@ -411,7 +459,7 @@ export interface RetailAnalysisResult {
|
|
|
411
459
|
|
|
412
460
|
export function buildRetailReceipt(
|
|
413
461
|
result: RetailAnalysisResult,
|
|
414
|
-
options?: RetailReceiptOptions
|
|
462
|
+
options?: RetailReceiptOptions
|
|
415
463
|
): DomainSimulationReceipt {
|
|
416
464
|
const violations: Array<{ criterion: string; message: string }> = [];
|
|
417
465
|
|
|
@@ -440,7 +488,11 @@ export function buildRetailReceipt(
|
|
|
440
488
|
clv: result.clv?.discountedCLV ?? null,
|
|
441
489
|
overallConversion: result.funnel?.overallConversion ?? null,
|
|
442
490
|
},
|
|
443
|
-
cael: {
|
|
491
|
+
cael: {
|
|
492
|
+
version: 'cael.v1',
|
|
493
|
+
event: 'retail_ecommerce.retail_analysis',
|
|
494
|
+
solverType: 'retail-ecommerce.analytics',
|
|
495
|
+
},
|
|
444
496
|
acceptance: { accepted: violations.length === 0, violations },
|
|
445
497
|
});
|
|
446
498
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface RuntimeTraitHandler {
|
|
|
63
63
|
node: unknown,
|
|
64
64
|
config: EoqTraitConfig,
|
|
65
65
|
context: TraitDispatchContext,
|
|
66
|
-
delta: number
|
|
66
|
+
delta: number
|
|
67
67
|
) => void;
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -78,7 +78,7 @@ interface EoqNode {
|
|
|
78
78
|
function solveOntoNode(
|
|
79
79
|
node: unknown,
|
|
80
80
|
config: EoqTraitConfig | undefined,
|
|
81
|
-
context: TraitDispatchContext
|
|
81
|
+
context: TraitDispatchContext
|
|
82
82
|
): void {
|
|
83
83
|
const carrier = node as EoqNode;
|
|
84
84
|
const nodeId = carrier.id ?? carrier.name ?? 'unknown';
|
|
@@ -106,7 +106,7 @@ function solveOntoNode(
|
|
|
106
106
|
orderingCost,
|
|
107
107
|
holdingCostPerUnit,
|
|
108
108
|
config?.leadTimeDays ?? 0,
|
|
109
|
-
config?.workingDaysPerYear ?? 250
|
|
109
|
+
config?.workingDaysPerYear ?? 250
|
|
110
110
|
);
|
|
111
111
|
carrier.__eoqResult = result;
|
|
112
112
|
carrier.properties = {
|
package/src/traits/CartTrait.ts
CHANGED
|
@@ -1,39 +1,72 @@
|
|
|
1
1
|
/** @cart Trait — Shopping cart management. @trait cart */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface CartItem {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export interface CartItem {
|
|
5
|
+
productId: string;
|
|
6
|
+
quantity: number;
|
|
7
|
+
price: number;
|
|
8
|
+
variant?: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export interface CartConfig {
|
|
12
|
+
currency: string;
|
|
13
|
+
items: CartItem[];
|
|
14
|
+
maxItems: number;
|
|
15
|
+
taxRate: number;
|
|
16
|
+
}
|
|
17
|
+
export interface CartState {
|
|
18
|
+
items: CartItem[];
|
|
19
|
+
subtotal: number;
|
|
20
|
+
tax: number;
|
|
21
|
+
total: number;
|
|
22
|
+
itemCount: number;
|
|
23
|
+
}
|
|
7
24
|
|
|
8
25
|
const defaultConfig: CartConfig = { currency: 'USD', items: [], maxItems: 99, taxRate: 0 };
|
|
9
26
|
|
|
10
|
-
function computeTotals(
|
|
27
|
+
function computeTotals(
|
|
28
|
+
items: CartItem[],
|
|
29
|
+
taxRate: number
|
|
30
|
+
): Pick<CartState, 'subtotal' | 'tax' | 'total' | 'itemCount'> {
|
|
11
31
|
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
|
|
12
32
|
const tax = subtotal * taxRate;
|
|
13
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
subtotal,
|
|
35
|
+
tax,
|
|
36
|
+
total: subtotal + tax,
|
|
37
|
+
itemCount: items.reduce((sum, i) => sum + i.quantity, 0),
|
|
38
|
+
};
|
|
14
39
|
}
|
|
15
40
|
|
|
16
41
|
export function createCartHandler(): TraitHandler<CartConfig> {
|
|
17
42
|
return {
|
|
18
|
-
name: 'cart',
|
|
43
|
+
name: 'cart',
|
|
44
|
+
defaultConfig,
|
|
19
45
|
onAttach(node: HSPlusNode, config: CartConfig, ctx: TraitContext) {
|
|
20
46
|
const totals = computeTotals(config.items, config.taxRate);
|
|
21
47
|
node.__cartState = { items: [...config.items], ...totals };
|
|
22
48
|
ctx.emit?.('cart:created', { itemCount: totals.itemCount });
|
|
23
49
|
},
|
|
24
|
-
onDetach(node: HSPlusNode, _c: CartConfig, ctx: TraitContext) {
|
|
50
|
+
onDetach(node: HSPlusNode, _c: CartConfig, ctx: TraitContext) {
|
|
51
|
+
delete node.__cartState;
|
|
52
|
+
ctx.emit?.('cart:cleared');
|
|
53
|
+
},
|
|
25
54
|
onUpdate() {},
|
|
26
55
|
onEvent(node: HSPlusNode, config: CartConfig, ctx: TraitContext, event: TraitEvent) {
|
|
27
|
-
const s = node.__cartState as CartState | undefined;
|
|
56
|
+
const s = node.__cartState as CartState | undefined;
|
|
57
|
+
if (!s) return;
|
|
28
58
|
if (event.type === 'cart:add_item') {
|
|
29
59
|
const item = event.payload as unknown as CartItem;
|
|
30
|
-
const existing = s.items.find(
|
|
31
|
-
|
|
60
|
+
const existing = s.items.find(
|
|
61
|
+
(i) => i.productId === item.productId && i.variant === item.variant
|
|
62
|
+
);
|
|
63
|
+
if (existing) existing.quantity += item.quantity;
|
|
64
|
+
else s.items.push({ ...item });
|
|
32
65
|
Object.assign(s, computeTotals(s.items, config.taxRate));
|
|
33
66
|
ctx.emit?.('cart:updated', { itemCount: s.itemCount, total: s.total });
|
|
34
67
|
}
|
|
35
68
|
if (event.type === 'cart:remove_item') {
|
|
36
|
-
s.items = s.items.filter(i => i.productId !== (event.payload?.productId as string));
|
|
69
|
+
s.items = s.items.filter((i) => i.productId !== (event.payload?.productId as string));
|
|
37
70
|
Object.assign(s, computeTotals(s.items, config.taxRate));
|
|
38
71
|
ctx.emit?.('cart:updated', { itemCount: s.itemCount, total: s.total });
|
|
39
72
|
}
|
|
@@ -2,28 +2,83 @@
|
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
4
|
export type CheckoutStep = 'shipping' | 'payment' | 'review' | 'confirm' | 'complete';
|
|
5
|
-
export type PaymentMethod =
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
export type PaymentMethod =
|
|
6
|
+
| 'credit_card'
|
|
7
|
+
| 'debit_card'
|
|
8
|
+
| 'paypal'
|
|
9
|
+
| 'crypto'
|
|
10
|
+
| 'bank_transfer'
|
|
11
|
+
| 'apple_pay';
|
|
12
|
+
export interface Address {
|
|
13
|
+
line1: string;
|
|
14
|
+
line2?: string;
|
|
15
|
+
city: string;
|
|
16
|
+
state: string;
|
|
17
|
+
postalCode: string;
|
|
18
|
+
country: string;
|
|
19
|
+
}
|
|
20
|
+
export interface CheckoutConfig {
|
|
21
|
+
step: CheckoutStep;
|
|
22
|
+
paymentMethod: PaymentMethod;
|
|
23
|
+
shippingAddress: Address | null;
|
|
24
|
+
billingAddress: Address | null;
|
|
25
|
+
orderTotal: number;
|
|
26
|
+
tax: number;
|
|
27
|
+
}
|
|
28
|
+
export interface CheckoutState {
|
|
29
|
+
currentStep: CheckoutStep;
|
|
30
|
+
isProcessing: boolean;
|
|
31
|
+
orderId: string | null;
|
|
32
|
+
error: string | null;
|
|
33
|
+
}
|
|
9
34
|
|
|
10
|
-
const defaultConfig: CheckoutConfig = {
|
|
35
|
+
const defaultConfig: CheckoutConfig = {
|
|
36
|
+
step: 'shipping',
|
|
37
|
+
paymentMethod: 'credit_card',
|
|
38
|
+
shippingAddress: null,
|
|
39
|
+
billingAddress: null,
|
|
40
|
+
orderTotal: 0,
|
|
41
|
+
tax: 0,
|
|
42
|
+
};
|
|
11
43
|
|
|
12
44
|
export function createCheckoutHandler(): TraitHandler<CheckoutConfig> {
|
|
13
45
|
return {
|
|
14
|
-
name: 'checkout',
|
|
15
|
-
|
|
16
|
-
|
|
46
|
+
name: 'checkout',
|
|
47
|
+
defaultConfig,
|
|
48
|
+
onAttach(node: HSPlusNode, config: CheckoutConfig, ctx: TraitContext) {
|
|
49
|
+
node.__checkoutState = {
|
|
50
|
+
currentStep: config.step,
|
|
51
|
+
isProcessing: false,
|
|
52
|
+
orderId: null,
|
|
53
|
+
error: null,
|
|
54
|
+
};
|
|
55
|
+
ctx.emit?.('checkout:started');
|
|
56
|
+
},
|
|
57
|
+
onDetach(node: HSPlusNode, _c: CheckoutConfig, ctx: TraitContext) {
|
|
58
|
+
delete node.__checkoutState;
|
|
59
|
+
ctx.emit?.('checkout:abandoned');
|
|
60
|
+
},
|
|
17
61
|
onUpdate() {},
|
|
18
62
|
onEvent(node: HSPlusNode, _c: CheckoutConfig, ctx: TraitContext, event: TraitEvent) {
|
|
19
|
-
const s = node.__checkoutState as CheckoutState | undefined;
|
|
63
|
+
const s = node.__checkoutState as CheckoutState | undefined;
|
|
64
|
+
if (!s) return;
|
|
20
65
|
if (event.type === 'checkout:next_step') {
|
|
21
66
|
const steps: CheckoutStep[] = ['shipping', 'payment', 'review', 'confirm', 'complete'];
|
|
22
67
|
const idx = steps.indexOf(s.currentStep);
|
|
23
|
-
if (idx < steps.length - 1) {
|
|
68
|
+
if (idx < steps.length - 1) {
|
|
69
|
+
s.currentStep = steps[idx + 1];
|
|
70
|
+
ctx.emit?.('checkout:step_changed', { step: s.currentStep });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (event.type === 'checkout:process_payment') {
|
|
74
|
+
s.isProcessing = true;
|
|
75
|
+
ctx.emit?.('checkout:processing');
|
|
76
|
+
}
|
|
77
|
+
if (event.type === 'checkout:complete') {
|
|
78
|
+
s.currentStep = 'complete';
|
|
79
|
+
s.orderId = event.payload?.orderId as string;
|
|
80
|
+
ctx.emit?.('checkout:completed', { orderId: s.orderId });
|
|
24
81
|
}
|
|
25
|
-
if (event.type === 'checkout:process_payment') { s.isProcessing = true; ctx.emit?.('checkout:processing'); }
|
|
26
|
-
if (event.type === 'checkout:complete') { s.currentStep = 'complete'; s.orderId = event.payload?.orderId as string; ctx.emit?.('checkout:completed', { orderId: s.orderId }); }
|
|
27
82
|
},
|
|
28
83
|
};
|
|
29
84
|
}
|
|
@@ -1,26 +1,60 @@
|
|
|
1
1
|
/** @product_catalog Trait — Product catalog with search and filtering. @trait product_catalog */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface Product {
|
|
5
|
-
|
|
4
|
+
export interface Product {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
price: number;
|
|
8
|
+
category: string;
|
|
9
|
+
brand?: string;
|
|
10
|
+
inStock: boolean;
|
|
11
|
+
imageUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ProductCatalogConfig {
|
|
14
|
+
products: Product[];
|
|
15
|
+
categories: string[];
|
|
16
|
+
sortBy: 'price_asc' | 'price_desc' | 'name' | 'newest';
|
|
17
|
+
pageSize: number;
|
|
18
|
+
}
|
|
6
19
|
|
|
7
|
-
const defaultConfig: ProductCatalogConfig = {
|
|
20
|
+
const defaultConfig: ProductCatalogConfig = {
|
|
21
|
+
products: [],
|
|
22
|
+
categories: [],
|
|
23
|
+
sortBy: 'name',
|
|
24
|
+
pageSize: 20,
|
|
25
|
+
};
|
|
8
26
|
|
|
9
27
|
export function createProductCatalogHandler(): TraitHandler<ProductCatalogConfig> {
|
|
10
28
|
return {
|
|
11
|
-
name: 'product_catalog',
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
name: 'product_catalog',
|
|
30
|
+
defaultConfig,
|
|
31
|
+
onAttach(node: HSPlusNode, config: ProductCatalogConfig, ctx: TraitContext) {
|
|
32
|
+
node.__catalogState = {
|
|
33
|
+
currentPage: 0,
|
|
34
|
+
filteredCount: config.products.length,
|
|
35
|
+
activeFilters: {},
|
|
36
|
+
};
|
|
37
|
+
ctx.emit?.('catalog:loaded', { productCount: config.products.length });
|
|
38
|
+
},
|
|
39
|
+
onDetach(node: HSPlusNode, _c: ProductCatalogConfig, ctx: TraitContext) {
|
|
40
|
+
delete node.__catalogState;
|
|
41
|
+
ctx.emit?.('catalog:unloaded');
|
|
42
|
+
},
|
|
14
43
|
onUpdate() {},
|
|
15
44
|
onEvent(node: HSPlusNode, config: ProductCatalogConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
45
|
if (event.type === 'catalog:search') {
|
|
17
|
-
const query = (event.payload?.query as string || '').toLowerCase();
|
|
18
|
-
const results = config.products.filter(p => p.name.toLowerCase().includes(query));
|
|
19
|
-
ctx.emit?.('catalog:results', {
|
|
46
|
+
const query = ((event.payload?.query as string) || '').toLowerCase();
|
|
47
|
+
const results = config.products.filter((p) => p.name.toLowerCase().includes(query));
|
|
48
|
+
ctx.emit?.('catalog:results', {
|
|
49
|
+
count: results.length,
|
|
50
|
+
products: results.slice(0, config.pageSize),
|
|
51
|
+
});
|
|
20
52
|
}
|
|
21
53
|
if (event.type === 'catalog:filter') {
|
|
22
54
|
const category = event.payload?.category as string;
|
|
23
|
-
const results = category
|
|
55
|
+
const results = category
|
|
56
|
+
? config.products.filter((p) => p.category === category)
|
|
57
|
+
: config.products;
|
|
24
58
|
ctx.emit?.('catalog:filtered', { count: results.length, category });
|
|
25
59
|
}
|
|
26
60
|
},
|
|
@@ -1,23 +1,61 @@
|
|
|
1
1
|
/** @return Trait — Return and refund management. @trait return */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export type ReturnStatus =
|
|
5
|
-
|
|
4
|
+
export type ReturnStatus =
|
|
5
|
+
| 'requested'
|
|
6
|
+
| 'approved'
|
|
7
|
+
| 'shipped'
|
|
8
|
+
| 'received'
|
|
9
|
+
| 'refunded'
|
|
10
|
+
| 'denied';
|
|
11
|
+
export interface ReturnConfig {
|
|
12
|
+
orderId: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
status: ReturnStatus;
|
|
15
|
+
refundAmount: number;
|
|
16
|
+
returnWindowDays: number;
|
|
17
|
+
}
|
|
6
18
|
|
|
7
|
-
const defaultConfig: ReturnConfig = {
|
|
19
|
+
const defaultConfig: ReturnConfig = {
|
|
20
|
+
orderId: '',
|
|
21
|
+
reason: '',
|
|
22
|
+
status: 'requested',
|
|
23
|
+
refundAmount: 0,
|
|
24
|
+
returnWindowDays: 30,
|
|
25
|
+
};
|
|
8
26
|
|
|
9
27
|
export function createReturnHandler(): TraitHandler<ReturnConfig> {
|
|
10
28
|
return {
|
|
11
|
-
name: 'return',
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
name: 'return',
|
|
30
|
+
defaultConfig,
|
|
31
|
+
onAttach(node: HSPlusNode, config: ReturnConfig, ctx: TraitContext) {
|
|
32
|
+
node.__returnState = { ...config, requestedAt: Date.now() };
|
|
33
|
+
ctx.emit?.('return:created', { orderId: config.orderId });
|
|
34
|
+
},
|
|
35
|
+
onDetach(node: HSPlusNode, _c: ReturnConfig, ctx: TraitContext) {
|
|
36
|
+
delete node.__returnState;
|
|
37
|
+
ctx.emit?.('return:cancelled');
|
|
38
|
+
},
|
|
14
39
|
onUpdate() {},
|
|
15
40
|
onEvent(node: HSPlusNode, _c: ReturnConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
-
const s = node.__returnState as Record<string, unknown> | undefined;
|
|
17
|
-
if (
|
|
18
|
-
if (event.type === 'return:
|
|
19
|
-
|
|
20
|
-
|
|
41
|
+
const s = node.__returnState as Record<string, unknown> | undefined;
|
|
42
|
+
if (!s) return;
|
|
43
|
+
if (event.type === 'return:approve') {
|
|
44
|
+
s.status = 'approved';
|
|
45
|
+
ctx.emit?.('return:approved');
|
|
46
|
+
}
|
|
47
|
+
if (event.type === 'return:ship') {
|
|
48
|
+
s.status = 'shipped';
|
|
49
|
+
ctx.emit?.('return:shipped');
|
|
50
|
+
}
|
|
51
|
+
if (event.type === 'return:receive') {
|
|
52
|
+
s.status = 'received';
|
|
53
|
+
ctx.emit?.('return:received');
|
|
54
|
+
}
|
|
55
|
+
if (event.type === 'return:refund') {
|
|
56
|
+
s.status = 'refunded';
|
|
57
|
+
ctx.emit?.('return:refunded', { amount: s.refundAmount });
|
|
58
|
+
}
|
|
21
59
|
},
|
|
22
60
|
};
|
|
23
61
|
}
|
|
@@ -2,18 +2,49 @@
|
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
4
|
export type ShippingMethod = 'standard' | 'express' | 'overnight' | 'freight' | 'pickup';
|
|
5
|
-
export interface ShippingRateConfig {
|
|
5
|
+
export interface ShippingRateConfig {
|
|
6
|
+
carrier: string;
|
|
7
|
+
method: ShippingMethod;
|
|
8
|
+
weightKg: number;
|
|
9
|
+
dimensions: { lengthCm: number; widthCm: number; heightCm: number };
|
|
10
|
+
origin: string;
|
|
11
|
+
destination: string;
|
|
12
|
+
rate: number;
|
|
13
|
+
estimatedDays: number;
|
|
14
|
+
}
|
|
6
15
|
|
|
7
|
-
const defaultConfig: ShippingRateConfig = {
|
|
16
|
+
const defaultConfig: ShippingRateConfig = {
|
|
17
|
+
carrier: '',
|
|
18
|
+
method: 'standard',
|
|
19
|
+
weightKg: 0,
|
|
20
|
+
dimensions: { lengthCm: 0, widthCm: 0, heightCm: 0 },
|
|
21
|
+
origin: '',
|
|
22
|
+
destination: '',
|
|
23
|
+
rate: 0,
|
|
24
|
+
estimatedDays: 5,
|
|
25
|
+
};
|
|
8
26
|
|
|
9
27
|
export function createShippingRateHandler(): TraitHandler<ShippingRateConfig> {
|
|
10
28
|
return {
|
|
11
|
-
name: 'shipping_rate',
|
|
12
|
-
|
|
13
|
-
|
|
29
|
+
name: 'shipping_rate',
|
|
30
|
+
defaultConfig,
|
|
31
|
+
onAttach(node: HSPlusNode, config: ShippingRateConfig, ctx: TraitContext) {
|
|
32
|
+
node.__shippingState = { calculatedRate: config.rate, carrier: config.carrier };
|
|
33
|
+
ctx.emit?.('shipping:rate_set', { rate: config.rate, method: config.method });
|
|
34
|
+
},
|
|
35
|
+
onDetach(node: HSPlusNode, _c: ShippingRateConfig, ctx: TraitContext) {
|
|
36
|
+
delete node.__shippingState;
|
|
37
|
+
ctx.emit?.('shipping:cleared');
|
|
38
|
+
},
|
|
14
39
|
onUpdate() {},
|
|
15
40
|
onEvent(_n: HSPlusNode, config: ShippingRateConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
-
if (event.type === 'shipping:calculate') {
|
|
41
|
+
if (event.type === 'shipping:calculate') {
|
|
42
|
+
ctx.emit?.('shipping:calculated', {
|
|
43
|
+
rate: config.rate,
|
|
44
|
+
estimatedDays: config.estimatedDays,
|
|
45
|
+
carrier: config.carrier,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
17
48
|
},
|
|
18
49
|
};
|
|
19
50
|
}
|
package/src/traits/types.ts
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
export interface HSPlusNode {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export interface HSPlusNode {
|
|
2
|
+
id?: string;
|
|
3
|
+
properties?: Record<string, unknown>;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface TraitContext {
|
|
7
|
+
emit?: (event: string, payload?: unknown) => void;
|
|
8
|
+
getState?: () => Record<string, unknown>;
|
|
9
|
+
setState?: (updates: Record<string, unknown>) => void;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface TraitEvent {
|
|
13
|
+
type: string;
|
|
14
|
+
source?: string;
|
|
15
|
+
payload?: Record<string, unknown>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface TraitHandler<TConfig = unknown> {
|
|
19
|
+
name: string;
|
|
20
|
+
defaultConfig: TConfig;
|
|
21
|
+
onAttach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void;
|
|
22
|
+
onDetach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void;
|
|
23
|
+
onUpdate(node: HSPlusNode, config: TConfig, ctx: TraitContext, delta: number): void;
|
|
24
|
+
onEvent(node: HSPlusNode, config: TConfig, ctx: TraitContext, event: TraitEvent): void;
|
|
25
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true
|
|
8
|
+
},
|
|
9
|
+
"include": ["src"]
|
|
10
|
+
}
|
package/vitest.config.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
import { defineConfig } from 'vitest/config';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
2
|
|
|
4
3
|
export default defineConfig({
|
|
5
|
-
resolve: {
|
|
6
|
-
alias: {
|
|
7
|
-
'@holoscript/engine': resolve(__dirname, '../../engine/src'),
|
|
8
|
-
'@holoscript/core': resolve(__dirname, '../../core/src'),
|
|
9
|
-
},
|
|
10
|
-
},
|
|
11
4
|
test: {
|
|
12
5
|
globals: true,
|
|
13
6
|
environment: 'node',
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025-2026 HoloScript Contributors
|
|
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.
|