@lifeaz/lors-helper 1.0.26 → 1.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/README.md +1 -1
- package/dist/main.d.ts +239 -6
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +443 -1
- package/package.json +3 -3
- package/src/main.ts +701 -2
package/src/main.ts
CHANGED
|
@@ -50,8 +50,14 @@ export const PaymentProviderSchema = z.union([z.literal('_Mollie_'), z.literal('
|
|
|
50
50
|
export type DeliveryMethod = '_Standard_' | '_Express_';
|
|
51
51
|
export const DeliveryMethodSchema = z.union([z.literal('_Standard_'), z.literal('_Express_')]);
|
|
52
52
|
|
|
53
|
-
export type DiscountValueType = '_Percentage_' | '_Fixed_Amount_' | '_Fixed_Total_' | '_Item_';
|
|
54
|
-
export const DiscountValueTypeSchema = z.union([z.literal('_Percentage_'), z.literal('_Fixed_Amount_'), z.literal('_Fixed_Total_'), z.literal('_Item_')]);
|
|
53
|
+
export type DiscountValueType = '_Percentage_' | '_Fixed_Amount_' | '_Fixed_Total_' | '_Item_' | '_Composite_';
|
|
54
|
+
export const DiscountValueTypeSchema = z.union([z.literal('_Percentage_'), z.literal('_Fixed_Amount_'), z.literal('_Fixed_Total_'), z.literal('_Item_'), z.literal('_Composite_')]);
|
|
55
|
+
|
|
56
|
+
export type DiscountEffectType = '_Gift_Item_' | '_Amount_Monthly_' | '_Amount_One_Time_' | '_Fixed_Total_';
|
|
57
|
+
export const DiscountEffectTypeSchema = z.union([z.literal('_Gift_Item_'), z.literal('_Amount_Monthly_'), z.literal('_Amount_One_Time_'), z.literal('_Fixed_Total_')]);
|
|
58
|
+
|
|
59
|
+
export type DiscountAmountKind = '_Fixed_' | '_Percentage_';
|
|
60
|
+
export const DiscountAmountKindSchema = z.union([z.literal('_Fixed_'), z.literal('_Percentage_')]);
|
|
55
61
|
|
|
56
62
|
export type DiscountTargetScope = '_Whole_Cart_' | '_Specific_Items_' | '_Item_Category_' | '_Delivery_';
|
|
57
63
|
export const DiscountTargetScopeSchema = z.union([z.literal('_Whole_Cart_'), z.literal('_Specific_Items_'), z.literal('_Item_Category_'), z.literal('_Delivery_')]);
|
|
@@ -313,6 +319,17 @@ export const DiscountRuleNodeSchema: z.ZodType<DiscountRuleNode> = z.lazy(() =>
|
|
|
313
319
|
children: z.array(DiscountRuleNodeSchema),
|
|
314
320
|
}));
|
|
315
321
|
|
|
322
|
+
export const DiscountRuleEffectSchema = z.object({
|
|
323
|
+
effectId: uuidSchema,
|
|
324
|
+
type: DiscountEffectTypeSchema,
|
|
325
|
+
kind: DiscountAmountKindSchema.nullable(),
|
|
326
|
+
itemId: uuidSchema.nullable(),
|
|
327
|
+
relativeItemId: uuidSchema.nullable(),
|
|
328
|
+
quantity: z.number().int().positive().nullable(),
|
|
329
|
+
value: z.number().nullable(),
|
|
330
|
+
});
|
|
331
|
+
export type DiscountRuleEffect = z.infer<typeof DiscountRuleEffectSchema>;
|
|
332
|
+
|
|
316
333
|
export const DiscountRuleSchema = z.object({
|
|
317
334
|
ruleId: uuidSchema,
|
|
318
335
|
discountId: uuidSchema,
|
|
@@ -320,6 +337,7 @@ export const DiscountRuleSchema = z.object({
|
|
|
320
337
|
priority: z.number(),
|
|
321
338
|
amountExclVat: z.number(),
|
|
322
339
|
quantityItemId: uuidSchema.nullable().optional(),
|
|
340
|
+
effects: z.array(DiscountRuleEffectSchema).optional().default([]),
|
|
323
341
|
root: DiscountRuleNodeSchema.nullable(),
|
|
324
342
|
});
|
|
325
343
|
export type DiscountRule = z.infer<typeof DiscountRuleSchema>;
|
|
@@ -372,6 +390,14 @@ export const CartSchema = z.object({
|
|
|
372
390
|
status: CartStatusSchema,
|
|
373
391
|
currentStep: CartStepSchema,
|
|
374
392
|
maxReachedStep: CartStepSchema,
|
|
393
|
+
/**
|
|
394
|
+
* The funnel order frozen onto this cart when it was created.
|
|
395
|
+
*
|
|
396
|
+
* Null means the referential order. Frozen rather than re-resolved on every page because a
|
|
397
|
+
* cart whose steps are reordered halfway through would strand the customer on a step the
|
|
398
|
+
* server no longer expects them to be on.
|
|
399
|
+
*/
|
|
400
|
+
stepOrder: z.array(CartStepSchema).nullable().default(null),
|
|
375
401
|
profile: ProfileTypeSchema.nullable(),
|
|
376
402
|
offerType: OfferTypeSchema.nullable(),
|
|
377
403
|
paymentMethod: PaymentMethodSchema.nullable(),
|
|
@@ -632,3 +658,676 @@ export const PartnerSchema = z.object({
|
|
|
632
658
|
notices: z.array(PartnerNoticeSchema),
|
|
633
659
|
});
|
|
634
660
|
export type Partner = z.infer<typeof PartnerSchema>;
|
|
661
|
+
|
|
662
|
+
// =============================================================================
|
|
663
|
+
// MVT - Multivariate testing
|
|
664
|
+
// =============================================================================
|
|
665
|
+
|
|
666
|
+
export type MvtStatus = '_Draft_' | '_Scheduled_' | '_Running_' | '_Paused_' | '_Stopped_' | '_Archived_';
|
|
667
|
+
export const MvtStatusSchema = z.union([z.literal('_Draft_'), z.literal('_Scheduled_'), z.literal('_Running_'), z.literal('_Paused_'), z.literal('_Stopped_'), z.literal('_Archived_')]);
|
|
668
|
+
|
|
669
|
+
export type MvtScope = '_Storefront_' | '_Checkout_' | '_Global_';
|
|
670
|
+
export const MvtScopeSchema = z.union([z.literal('_Storefront_'), z.literal('_Checkout_'), z.literal('_Global_')]);
|
|
671
|
+
|
|
672
|
+
export type MvtUnit = '_Visitor_' | '_Session_' | '_Cart_';
|
|
673
|
+
export const MvtUnitSchema = z.union([z.literal('_Visitor_'), z.literal('_Session_'), z.literal('_Cart_')]);
|
|
674
|
+
|
|
675
|
+
export type MvtDevice = '_Mobile_' | '_Tablet_' | '_Desktop_';
|
|
676
|
+
export const MvtDeviceSchema = z.union([z.literal('_Mobile_'), z.literal('_Tablet_'), z.literal('_Desktop_')]);
|
|
677
|
+
|
|
678
|
+
export type MvtNodeKind = '_And_' | '_Or_' | '_Not_' | '_Leaf_';
|
|
679
|
+
export const MvtNodeKindSchema = z.union([z.literal('_And_'), z.literal('_Or_'), z.literal('_Not_'), z.literal('_Leaf_')]);
|
|
680
|
+
|
|
681
|
+
export type MvtOperator =
|
|
682
|
+
| '_Device_In_'
|
|
683
|
+
| '_Lang_In_'
|
|
684
|
+
| '_Country_In_'
|
|
685
|
+
| '_Profile_In_'
|
|
686
|
+
| '_Partner_In_'
|
|
687
|
+
| '_Partner_Absent_'
|
|
688
|
+
| '_Campaign_Seen_'
|
|
689
|
+
| '_Campaign_Not_Seen_'
|
|
690
|
+
| '_Visitor_Is_New_'
|
|
691
|
+
| '_Visitor_Is_Returning_'
|
|
692
|
+
| '_Utm_Source_In_'
|
|
693
|
+
| '_Utm_Medium_In_'
|
|
694
|
+
| '_Utm_Campaign_In_'
|
|
695
|
+
| '_Referrer_Matches_'
|
|
696
|
+
| '_Path_Matches_'
|
|
697
|
+
| '_Has_Cart_'
|
|
698
|
+
| '_Hour_Of_Day_Between_'
|
|
699
|
+
| '_Day_Of_Week_In_';
|
|
700
|
+
|
|
701
|
+
export const MvtOperatorSchema = z.union([
|
|
702
|
+
z.literal('_Device_In_'),
|
|
703
|
+
z.literal('_Lang_In_'),
|
|
704
|
+
z.literal('_Country_In_'),
|
|
705
|
+
z.literal('_Profile_In_'),
|
|
706
|
+
z.literal('_Partner_In_'),
|
|
707
|
+
z.literal('_Partner_Absent_'),
|
|
708
|
+
z.literal('_Campaign_Seen_'),
|
|
709
|
+
z.literal('_Campaign_Not_Seen_'),
|
|
710
|
+
z.literal('_Visitor_Is_New_'),
|
|
711
|
+
z.literal('_Visitor_Is_Returning_'),
|
|
712
|
+
z.literal('_Utm_Source_In_'),
|
|
713
|
+
z.literal('_Utm_Medium_In_'),
|
|
714
|
+
z.literal('_Utm_Campaign_In_'),
|
|
715
|
+
z.literal('_Referrer_Matches_'),
|
|
716
|
+
z.literal('_Path_Matches_'),
|
|
717
|
+
z.literal('_Has_Cart_'),
|
|
718
|
+
z.literal('_Hour_Of_Day_Between_'),
|
|
719
|
+
z.literal('_Day_Of_Week_In_'),
|
|
720
|
+
]);
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Operators that read no value at all. Storing an empty value list against one of them is not an
|
|
724
|
+
* error worth refusing, but the evaluator ignores it, and the Tools condition builder hides the
|
|
725
|
+
* value editor rather than offering a field that changes nothing.
|
|
726
|
+
*/
|
|
727
|
+
export const MVT_NULLARY_OPERATORS: readonly MvtOperator[] = [
|
|
728
|
+
'_Partner_Absent_',
|
|
729
|
+
'_Visitor_Is_New_',
|
|
730
|
+
'_Visitor_Is_Returning_',
|
|
731
|
+
'_Has_Cart_',
|
|
732
|
+
];
|
|
733
|
+
|
|
734
|
+
export type MvtOverrideOp = 'set' | 'merge' | 'remove' | 'reorder' | 'insert' | 'patch';
|
|
735
|
+
export const MvtOverrideOpSchema = z.union([z.literal('set'), z.literal('merge'), z.literal('remove'), z.literal('reorder'), z.literal('insert'), z.literal('patch')]);
|
|
736
|
+
|
|
737
|
+
export const MvtOverrideSchema = z.object({
|
|
738
|
+
slot: z.string().min(1),
|
|
739
|
+
op: MvtOverrideOpSchema,
|
|
740
|
+
value: z.unknown().optional(),
|
|
741
|
+
at: z.number().int().nonnegative().optional(),
|
|
742
|
+
match: z.object({ by: z.string().min(1), equals: z.string() }).optional(),
|
|
743
|
+
});
|
|
744
|
+
export type MvtOverride = z.infer<typeof MvtOverrideSchema>;
|
|
745
|
+
|
|
746
|
+
export type MvtAudienceNode = {
|
|
747
|
+
kind: MvtNodeKind;
|
|
748
|
+
operator?: MvtOperator | null | undefined;
|
|
749
|
+
values?: string[] | undefined;
|
|
750
|
+
children?: MvtAudienceNode[] | undefined;
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
export const MvtAudienceNodeSchema: z.ZodType<MvtAudienceNode> = z.lazy(() => z.object({
|
|
754
|
+
kind: MvtNodeKindSchema,
|
|
755
|
+
operator: MvtOperatorSchema.nullable().optional(),
|
|
756
|
+
values: z.array(z.string()).optional(),
|
|
757
|
+
children: z.array(z.lazy(() => MvtAudienceNodeSchema)).optional(),
|
|
758
|
+
}));
|
|
759
|
+
|
|
760
|
+
export const MvtVariantSchema = z.object({
|
|
761
|
+
id: uuidSchema,
|
|
762
|
+
key: z.string().min(1),
|
|
763
|
+
name: z.string(),
|
|
764
|
+
weight: z.number().int().nonnegative(),
|
|
765
|
+
control: z.boolean(),
|
|
766
|
+
overrides: z.array(MvtOverrideSchema),
|
|
767
|
+
});
|
|
768
|
+
export type MvtVariant = z.infer<typeof MvtVariantSchema>;
|
|
769
|
+
|
|
770
|
+
export const MvtExperimentSchema = z.object({
|
|
771
|
+
id: uuidSchema,
|
|
772
|
+
key: z.string().min(1),
|
|
773
|
+
name: z.string(),
|
|
774
|
+
description: z.string().nullable(),
|
|
775
|
+
status: MvtStatusSchema,
|
|
776
|
+
scope: MvtScopeSchema,
|
|
777
|
+
unit: MvtUnitSchema,
|
|
778
|
+
salt: z.string().min(1),
|
|
779
|
+
startsAt: z.string().nullable(),
|
|
780
|
+
endsAt: z.string().nullable(),
|
|
781
|
+
priority: z.number().int(),
|
|
782
|
+
exclusionGroup: z.string().nullable(),
|
|
783
|
+
trafficPct: z.number().int().min(0).max(100),
|
|
784
|
+
sticky: z.boolean(),
|
|
785
|
+
audience: MvtAudienceNodeSchema.nullable(),
|
|
786
|
+
variants: z.array(MvtVariantSchema),
|
|
787
|
+
});
|
|
788
|
+
export type MvtExperiment = z.infer<typeof MvtExperimentSchema>;
|
|
789
|
+
|
|
790
|
+
export const MvtConfigSchema = z.object({
|
|
791
|
+
version: z.string(),
|
|
792
|
+
enabled: z.boolean(),
|
|
793
|
+
experiments: z.array(MvtExperimentSchema),
|
|
794
|
+
});
|
|
795
|
+
export type MvtConfig = z.infer<typeof MvtConfigSchema>;
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Everything the audience tree is allowed to read, resolved once and frozen into the assignment.
|
|
799
|
+
*
|
|
800
|
+
* Frozen matters more than it looks: a visitor who rotates their phone crosses the tablet/desktop
|
|
801
|
+
* breakpoint, and a context read live would move them to another variant mid-session. The context
|
|
802
|
+
* captured at assignment time is the one that decides, for as long as the assignment lives.
|
|
803
|
+
*/
|
|
804
|
+
export interface MvtContext {
|
|
805
|
+
unitId: string;
|
|
806
|
+
device: MvtDevice;
|
|
807
|
+
lang: string;
|
|
808
|
+
country: string | null;
|
|
809
|
+
profile: string | null;
|
|
810
|
+
partner: string | null;
|
|
811
|
+
seen: Record<string, number>;
|
|
812
|
+
utm: { source: string | null; medium: string | null; campaign: string | null };
|
|
813
|
+
referrer: string | null;
|
|
814
|
+
path: string;
|
|
815
|
+
hasCart: boolean;
|
|
816
|
+
firstSeenAt: number;
|
|
817
|
+
now: number;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export interface MvtDecision {
|
|
821
|
+
experimentKey: string;
|
|
822
|
+
experimentId: UUID;
|
|
823
|
+
variantKey: string;
|
|
824
|
+
variantId: UUID;
|
|
825
|
+
control: boolean;
|
|
826
|
+
overrides: MvtOverride[];
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const MVT_RETURNING_AFTER_MS = 30 * 60 * 1000;
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* FNV-1a, 32 bits.
|
|
833
|
+
*
|
|
834
|
+
* Chosen for being trivially reimplementable and byte-for-byte identical everywhere: the API, the
|
|
835
|
+
* storefront and the checkout each bucket independently, with no round trip, and any disagreement
|
|
836
|
+
* between them would show up as a visitor silently changing variant when they cross from one
|
|
837
|
+
* application to the other.
|
|
838
|
+
*/
|
|
839
|
+
export function mvtHash(input: string): number {
|
|
840
|
+
let hash = 0x811c9dc5;
|
|
841
|
+
|
|
842
|
+
for (let i = 0; i < input.length; i++) {
|
|
843
|
+
hash ^= input.charCodeAt(i);
|
|
844
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
return hash >>> 0;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
export function mvtBucket(salt: string, unitId: string, namespace: string = 'variant'): number {
|
|
851
|
+
return mvtHash(`${salt}:${namespace}:${unitId}`) % 10000;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export function mvtIsEligibleForTraffic(experiment: MvtExperiment, unitId: string): boolean {
|
|
855
|
+
if (experiment.trafficPct >= 100) return true;
|
|
856
|
+
if (experiment.trafficPct <= 0) return false;
|
|
857
|
+
|
|
858
|
+
return mvtBucket(experiment.salt, unitId, 'traffic') < experiment.trafficPct * 100;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Pick the variant a unit falls into, by weight, deterministically.
|
|
863
|
+
*
|
|
864
|
+
* Weights are relative rather than percentages so that 90/10, 50/50 and 34/33/33 are all
|
|
865
|
+
* expressible without the operator having to make anything add up. They are sorted by key first,
|
|
866
|
+
* so that the mapping from bucket range to variant depends only on the weights themselves and not
|
|
867
|
+
* on the order rows happened to come back from the database.
|
|
868
|
+
*/
|
|
869
|
+
export function mvtPickVariant(experiment: MvtExperiment, unitId: string): MvtVariant | null {
|
|
870
|
+
const variants = [...experiment.variants].sort((a, b) => a.key.localeCompare(b.key));
|
|
871
|
+
const total = variants.reduce((sum, v) => sum + Math.max(v.weight, 0), 0);
|
|
872
|
+
|
|
873
|
+
if (!variants.length || total <= 0) return null;
|
|
874
|
+
|
|
875
|
+
const target = (mvtBucket(experiment.salt, unitId) / 10000) * total;
|
|
876
|
+
let cursor = 0;
|
|
877
|
+
|
|
878
|
+
for (const variant of variants) {
|
|
879
|
+
cursor += Math.max(variant.weight, 0);
|
|
880
|
+
|
|
881
|
+
if (target < cursor) return variant;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return variants[variants.length - 1];
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function mvtMatchesPattern(value: string | null, patterns: string[]): boolean {
|
|
888
|
+
if (value === null) return false;
|
|
889
|
+
|
|
890
|
+
const haystack = value.toLowerCase();
|
|
891
|
+
|
|
892
|
+
return patterns.some(pattern => {
|
|
893
|
+
const needle = pattern.trim().toLowerCase();
|
|
894
|
+
|
|
895
|
+
if (!needle) return false;
|
|
896
|
+
if (needle.startsWith('*') && needle.endsWith('*')) return haystack.includes(needle.slice(1, -1));
|
|
897
|
+
if (needle.startsWith('*')) return haystack.endsWith(needle.slice(1));
|
|
898
|
+
if (needle.endsWith('*')) return haystack.startsWith(needle.slice(0, -1));
|
|
899
|
+
|
|
900
|
+
return haystack === needle;
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function mvtEvaluateLeaf(operator: MvtOperator, values: string[], context: MvtContext): boolean {
|
|
905
|
+
const lower = values.map(v => v.toLowerCase());
|
|
906
|
+
|
|
907
|
+
switch (operator) {
|
|
908
|
+
case '_Device_In_': return values.includes(context.device);
|
|
909
|
+
case '_Lang_In_': return lower.includes(context.lang.toLowerCase());
|
|
910
|
+
case '_Country_In_': return context.country !== null && lower.includes(context.country.toLowerCase());
|
|
911
|
+
case '_Profile_In_': return context.profile !== null && values.includes(context.profile);
|
|
912
|
+
case '_Partner_In_': return context.partner !== null && lower.includes(context.partner.toLowerCase());
|
|
913
|
+
case '_Partner_Absent_': return context.partner === null;
|
|
914
|
+
case '_Campaign_Seen_': return values.some(v => context.seen[`campaign:${v}`] !== undefined);
|
|
915
|
+
case '_Campaign_Not_Seen_': return values.every(v => context.seen[`campaign:${v}`] === undefined);
|
|
916
|
+
case '_Visitor_Is_New_': return context.now - context.firstSeenAt < MVT_RETURNING_AFTER_MS;
|
|
917
|
+
case '_Visitor_Is_Returning_': return context.now - context.firstSeenAt >= MVT_RETURNING_AFTER_MS;
|
|
918
|
+
case '_Utm_Source_In_': return mvtMatchesPattern(context.utm.source, values);
|
|
919
|
+
case '_Utm_Medium_In_': return mvtMatchesPattern(context.utm.medium, values);
|
|
920
|
+
case '_Utm_Campaign_In_': return mvtMatchesPattern(context.utm.campaign, values);
|
|
921
|
+
case '_Referrer_Matches_': return mvtMatchesPattern(context.referrer, values);
|
|
922
|
+
case '_Path_Matches_': return mvtMatchesPattern(context.path, values);
|
|
923
|
+
case '_Has_Cart_': return context.hasCart;
|
|
924
|
+
case '_Hour_Of_Day_Between_': {
|
|
925
|
+
const hour = new Date(context.now).getHours();
|
|
926
|
+
const from = Number(values[0] ?? 0);
|
|
927
|
+
const to = Number(values[1] ?? 23);
|
|
928
|
+
|
|
929
|
+
return from <= to ? hour >= from && hour <= to : hour >= from || hour <= to;
|
|
930
|
+
}
|
|
931
|
+
case '_Day_Of_Week_In_': return values.map(Number).includes(new Date(context.now).getDay());
|
|
932
|
+
default: return false;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Evaluate an audience tree.
|
|
938
|
+
*
|
|
939
|
+
* A null tree targets everyone, which is the common case and should not require building a node.
|
|
940
|
+
* An operator this build does not know returns false rather than throwing: a Tools release that
|
|
941
|
+
* introduces a new operator must never be able to break a front that has not shipped yet, and
|
|
942
|
+
* failing closed means the visitor stays on control instead of seeing a broken page.
|
|
943
|
+
*/
|
|
944
|
+
export function mvtEvaluateAudience(node: MvtAudienceNode | null, context: MvtContext): boolean {
|
|
945
|
+
if (!node) return true;
|
|
946
|
+
|
|
947
|
+
switch (node.kind) {
|
|
948
|
+
case '_And_': return (node.children ?? []).every(child => mvtEvaluateAudience(child, context));
|
|
949
|
+
case '_Or_': return (node.children ?? []).some(child => mvtEvaluateAudience(child, context));
|
|
950
|
+
case '_Not_': return !(node.children ?? []).some(child => mvtEvaluateAudience(child, context));
|
|
951
|
+
case '_Leaf_': return node.operator ? mvtEvaluateLeaf(node.operator, node.values ?? [], context) : false;
|
|
952
|
+
default: return false;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export function mvtIsWithinWindow(experiment: MvtExperiment, now: number): boolean {
|
|
957
|
+
if (experiment.startsAt && Date.parse(experiment.startsAt) > now) return false;
|
|
958
|
+
if (experiment.endsAt && Date.parse(experiment.endsAt) <= now) return false;
|
|
959
|
+
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export function mvtIsLive(experiment: MvtExperiment, now: number): boolean {
|
|
964
|
+
return experiment.status === '_Running_' && mvtIsWithinWindow(experiment, now);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Resolve every decision for one unit, in one pass.
|
|
969
|
+
*
|
|
970
|
+
* Experiments are walked by descending priority so that exclusion groups resolve predictably: the
|
|
971
|
+
* first live experiment of a group claims it, and the rest are skipped for this unit. Without an
|
|
972
|
+
* order the winner would depend on row order, and two visitors could end up in mutually exclusive
|
|
973
|
+
* tests that were meant to be exclusive.
|
|
974
|
+
*/
|
|
975
|
+
export function mvtResolve(config: MvtConfig, context: MvtContext, forced: Record<string, string> = {}): MvtDecision[] {
|
|
976
|
+
if (!config.enabled) return [];
|
|
977
|
+
|
|
978
|
+
const decisions: MvtDecision[] = [];
|
|
979
|
+
const claimed = new Set<string>();
|
|
980
|
+
const experiments = [...config.experiments].sort((a, b) => b.priority - a.priority || a.key.localeCompare(b.key));
|
|
981
|
+
|
|
982
|
+
for (const experiment of experiments) {
|
|
983
|
+
const forcedKey = forced[experiment.key];
|
|
984
|
+
|
|
985
|
+
if (forcedKey) {
|
|
986
|
+
const variant = experiment.variants.find(v => v.key === forcedKey);
|
|
987
|
+
|
|
988
|
+
if (variant) decisions.push(mvtToDecision(experiment, variant));
|
|
989
|
+
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (!mvtIsLive(experiment, context.now)) continue;
|
|
994
|
+
if (experiment.exclusionGroup && claimed.has(experiment.exclusionGroup)) continue;
|
|
995
|
+
if (!mvtEvaluateAudience(experiment.audience, context)) continue;
|
|
996
|
+
if (!mvtIsEligibleForTraffic(experiment, context.unitId)) continue;
|
|
997
|
+
|
|
998
|
+
const variant = mvtPickVariant(experiment, context.unitId);
|
|
999
|
+
|
|
1000
|
+
if (!variant) continue;
|
|
1001
|
+
if (experiment.exclusionGroup) claimed.add(experiment.exclusionGroup);
|
|
1002
|
+
|
|
1003
|
+
decisions.push(mvtToDecision(experiment, variant));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return decisions;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function mvtToDecision(experiment: MvtExperiment, variant: MvtVariant): MvtDecision {
|
|
1010
|
+
return {
|
|
1011
|
+
experimentKey: experiment.key,
|
|
1012
|
+
experimentId: experiment.id,
|
|
1013
|
+
variantKey: variant.key,
|
|
1014
|
+
variantId: variant.id,
|
|
1015
|
+
control: variant.control,
|
|
1016
|
+
overrides: variant.overrides,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// -----------------------------------------------------------------------------
|
|
1021
|
+
// Slot registry
|
|
1022
|
+
// -----------------------------------------------------------------------------
|
|
1023
|
+
|
|
1024
|
+
export type MvtSlotKind = 'boolean' | 'number' | 'duration' | 'text' | 'i18n' | 'color' | 'image' | 'enum' | 'list' | 'object' | 'record';
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* One editable field of a fixed-shape slot.
|
|
1028
|
+
*
|
|
1029
|
+
* The registry describes the shape rather than leaving the interface to infer it, because the
|
|
1030
|
+
* alternative is an operator typing JSON into a textarea and discovering their mistake from a
|
|
1031
|
+
* customer. A field the editor does not recognise degrades to a text input rather than
|
|
1032
|
+
* disappearing, so adding a kind here never blanks out a control in an older build.
|
|
1033
|
+
*/
|
|
1034
|
+
export interface MvtSlotField {
|
|
1035
|
+
key: string;
|
|
1036
|
+
label: string;
|
|
1037
|
+
kind: MvtSlotKind;
|
|
1038
|
+
options?: readonly string[];
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
export interface MvtSlotDefinition {
|
|
1042
|
+
key: string;
|
|
1043
|
+
scope: MvtScope;
|
|
1044
|
+
area: string;
|
|
1045
|
+
label: string;
|
|
1046
|
+
/** One short sentence an operator reads before deciding whether this is the slot they want. */
|
|
1047
|
+
hint: string;
|
|
1048
|
+
kind: MvtSlotKind;
|
|
1049
|
+
ops: readonly MvtOverrideOp[];
|
|
1050
|
+
/** Allowed values for an `enum`, or the closed set of entries for a constrained `list`. */
|
|
1051
|
+
options?: readonly string[];
|
|
1052
|
+
/** The named fields of an `object`, or the per-item fields of a list of objects. */
|
|
1053
|
+
fields?: readonly MvtSlotField[];
|
|
1054
|
+
/** The kind every value takes in a `record`, whose keys are open-ended. */
|
|
1055
|
+
entryKind?: MvtSlotKind;
|
|
1056
|
+
/** Which property identifies an item, for the `remove`, `patch` and `reorder` operations. */
|
|
1057
|
+
itemKey?: string;
|
|
1058
|
+
schema: z.ZodType<unknown>;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const i18nTextSchema = z.record(LangSchema, z.string());
|
|
1062
|
+
const colorSchema = z.string().regex(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
|
1063
|
+
|
|
1064
|
+
function slot(definition: MvtSlotDefinition): MvtSlotDefinition {
|
|
1065
|
+
return definition;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* The single source of truth for what a test is allowed to change.
|
|
1070
|
+
*
|
|
1071
|
+
* Shared by all four applications on purpose. The API validates writes against it, so a malformed
|
|
1072
|
+
* override is refused at authoring time rather than discovered by a customer; Tools reads it to
|
|
1073
|
+
* render a typed editor instead of a free JSON field; and the fronts read it to reject anything
|
|
1074
|
+
* that slipped through anyway. A slot absent from this list simply does not exist, which is what
|
|
1075
|
+
* keeps the blast radius of the whole feature bounded.
|
|
1076
|
+
*
|
|
1077
|
+
* Nothing monetary appears here, and nothing ever should. Prices, items and discounts are steered
|
|
1078
|
+
* by campaigns and discounts, which carry their own controls and their own audit trail. A test
|
|
1079
|
+
* decides how an offer is presented, never what it costs.
|
|
1080
|
+
*/
|
|
1081
|
+
export const MVT_SLOTS: readonly MvtSlotDefinition[] = [
|
|
1082
|
+
slot({ key: 'storefront.sections.order', scope: '_Storefront_', area: 'Structure', label: 'Section order', hint: 'Reorder the blocks of a page. Blocks not listed keep their place at the end.', kind: 'list', ops: ['reorder', 'set'], itemKey: 'id', schema: z.array(z.string()) }),
|
|
1083
|
+
slot({ key: 'storefront.sections.hidden', scope: '_Storefront_', area: 'Structure', label: 'Hidden sections', hint: 'Blocks to take off the page entirely.', kind: 'list', ops: ['set'], schema: z.array(z.string()) }),
|
|
1084
|
+
slot({ key: 'storefront.texts', scope: '_Storefront_', area: 'Structure', label: 'Text overrides', hint: 'Replace any piece of copy, written per language.', kind: 'record', entryKind: 'i18n', ops: ['merge', 'set'], schema: z.record(z.string(), i18nTextSchema) }),
|
|
1085
|
+
slot({ key: 'storefront.links', scope: '_Storefront_', area: 'Structure', label: 'Link targets', hint: 'Repoint a link somewhere else, for instance from the product sheet to a comparison page.', kind: 'record', entryKind: 'text', ops: ['merge', 'set'], schema: z.record(z.string(), z.string()) }),
|
|
1086
|
+
|
|
1087
|
+
slot({
|
|
1088
|
+
key: 'storefront.ctas', scope: '_Storefront_', area: 'CTA', label: 'Call to action list',
|
|
1089
|
+
hint: 'Hide, reorder or reword the buttons of a page.',
|
|
1090
|
+
kind: 'list', ops: ['remove', 'reorder', 'patch', 'insert', 'set'], itemKey: 'label',
|
|
1091
|
+
fields: [
|
|
1092
|
+
{ key: 'label', label: 'Label', kind: 'text' },
|
|
1093
|
+
{ key: 'variant', label: 'Style', kind: 'enum', options: ['primary', 'secondary', 'tertiary'] },
|
|
1094
|
+
{ key: 'route', label: 'Internal route', kind: 'text' },
|
|
1095
|
+
{ key: 'href', label: 'External link', kind: 'text' },
|
|
1096
|
+
],
|
|
1097
|
+
schema: z.array(z.record(z.string(), z.unknown())),
|
|
1098
|
+
}),
|
|
1099
|
+
slot({
|
|
1100
|
+
key: 'storefront.cta.colors', scope: '_Storefront_', area: 'CTA', label: 'Call to action colors',
|
|
1101
|
+
hint: 'Recolour the buttons, per style.',
|
|
1102
|
+
kind: 'object', ops: ['merge', 'set'],
|
|
1103
|
+
fields: [
|
|
1104
|
+
{ key: 'primary', label: 'Primary', kind: 'color' },
|
|
1105
|
+
{ key: 'secondary', label: 'Secondary', kind: 'color' },
|
|
1106
|
+
{ key: 'tertiary', label: 'Tertiary', kind: 'color' },
|
|
1107
|
+
],
|
|
1108
|
+
schema: z.record(z.string(), colorSchema),
|
|
1109
|
+
}),
|
|
1110
|
+
slot({ key: 'storefront.header.mobile.ctas.hidden', scope: '_Storefront_', area: 'CTA', label: 'Burger menu hidden CTAs', hint: 'Buttons to drop from the mobile menu only.', kind: 'list', ops: ['set'], schema: z.array(z.string()) }),
|
|
1111
|
+
|
|
1112
|
+
slot({
|
|
1113
|
+
key: 'campaign.banner.colors', scope: '_Global_', area: 'Flash offer', label: 'Banner colors',
|
|
1114
|
+
hint: 'Recolour the flash offer banner.',
|
|
1115
|
+
kind: 'object', ops: ['merge', 'set'],
|
|
1116
|
+
fields: [
|
|
1117
|
+
{ key: 'background', label: 'Background', kind: 'color' },
|
|
1118
|
+
{ key: 'text', label: 'Text', kind: 'color' },
|
|
1119
|
+
{ key: 'tagBackground', label: 'Tag background', kind: 'color' },
|
|
1120
|
+
{ key: 'tagText', label: 'Tag text', kind: 'color' },
|
|
1121
|
+
{ key: 'countdownText', label: 'Countdown text', kind: 'color' },
|
|
1122
|
+
],
|
|
1123
|
+
schema: z.record(z.string(), colorSchema),
|
|
1124
|
+
}),
|
|
1125
|
+
slot({ key: 'campaign.banner.text', scope: '_Global_', area: 'Flash offer', label: 'Banner text', hint: 'The wording of the flash offer banner.', kind: 'i18n', ops: ['set'], schema: i18nTextSchema }),
|
|
1126
|
+
slot({ key: 'campaign.banner.tag', scope: '_Global_', area: 'Flash offer', label: 'Banner tag', hint: 'The small badge at the start of the banner.', kind: 'i18n', ops: ['set'], schema: i18nTextSchema }),
|
|
1127
|
+
slot({ key: 'campaign.banner.showCountdown', scope: '_Global_', area: 'Flash offer', label: 'Show countdown', hint: 'Whether the banner counts down to the end of the offer.', kind: 'boolean', ops: ['set'], schema: z.boolean() }),
|
|
1128
|
+
slot({ key: 'campaign.banner.placement', scope: '_Global_', area: 'Flash offer', label: 'Banner placement', hint: 'Where the banner sits on the page.', kind: 'enum', ops: ['set'], options: ['top', 'below-header', 'sticky-bottom'], schema: z.enum(['top', 'below-header', 'sticky-bottom']) }),
|
|
1129
|
+
slot({ key: 'campaign.priceTag.text', scope: '_Global_', area: 'Flash offer', label: 'Discount badge text', hint: 'The wording of the discount badge shown next to prices.', kind: 'i18n', ops: ['set'], schema: i18nTextSchema }),
|
|
1130
|
+
slot({ key: 'campaign.priceTag.placement', scope: '_Global_', area: 'Flash offer', label: 'Discount badge placement', hint: 'Where the discount badge sits relative to the price.', kind: 'enum', ops: ['set'], options: ['price-inline', 'card-corner', 'above-price', 'hidden'], schema: z.enum(['price-inline', 'card-corner', 'above-price', 'hidden']) }),
|
|
1131
|
+
slot({
|
|
1132
|
+
key: 'campaign.priceTag.colors', scope: '_Global_', area: 'Flash offer', label: 'Discount badge colors',
|
|
1133
|
+
hint: 'Recolour the discount badge.',
|
|
1134
|
+
kind: 'object', ops: ['merge', 'set'],
|
|
1135
|
+
fields: [
|
|
1136
|
+
{ key: 'background', label: 'Background', kind: 'color' },
|
|
1137
|
+
{ key: 'text', label: 'Text', kind: 'color' },
|
|
1138
|
+
],
|
|
1139
|
+
schema: z.record(z.string(), colorSchema),
|
|
1140
|
+
}),
|
|
1141
|
+
slot({ key: 'campaign.ribbon.text', scope: '_Global_', area: 'Flash offer', label: 'Ribbon text', hint: 'The ribbon shown on the offer summary.', kind: 'i18n', ops: ['set'], schema: i18nTextSchema }),
|
|
1142
|
+
|
|
1143
|
+
slot({ key: 'popup.welcome.enabled', scope: '_Storefront_', area: 'Popup', label: 'Welcome popup enabled', hint: 'Whether the welcome popup appears at all.', kind: 'boolean', ops: ['set'], schema: z.boolean() }),
|
|
1144
|
+
slot({ key: 'popup.welcome.delayMs', scope: '_Storefront_', area: 'Popup', label: 'Welcome popup delay', hint: 'How long to wait before showing it.', kind: 'duration', ops: ['set'], schema: z.number().int().min(0).max(600000) }),
|
|
1145
|
+
slot({ key: 'popup.welcome.trigger', scope: '_Storefront_', area: 'Popup', label: 'Welcome popup trigger', hint: 'What makes it appear: a timer, scrolling, or the cursor leaving the page.', kind: 'enum', ops: ['set'], options: ['timer', 'scroll', 'exit-intent'], schema: z.enum(['timer', 'scroll', 'exit-intent']) }),
|
|
1146
|
+
slot({
|
|
1147
|
+
key: 'popup.welcome.frequency', scope: '_Storefront_', area: 'Popup', label: 'Welcome popup frequency',
|
|
1148
|
+
hint: 'How often one visitor sees it again.',
|
|
1149
|
+
kind: 'object', ops: ['merge', 'set'],
|
|
1150
|
+
fields: [
|
|
1151
|
+
{ key: 'mode', label: 'Mode', kind: 'enum', options: ['once', 'session', 'days'] },
|
|
1152
|
+
{ key: 'days', label: 'Every N days', kind: 'number' },
|
|
1153
|
+
],
|
|
1154
|
+
schema: z.object({ mode: z.enum(['once', 'session', 'days']), days: z.number().int().min(1).max(365).optional() }),
|
|
1155
|
+
}),
|
|
1156
|
+
slot({
|
|
1157
|
+
key: 'popup.welcome.content', scope: '_Storefront_', area: 'Popup', label: 'Welcome popup content',
|
|
1158
|
+
hint: 'The wording and image of the welcome popup.',
|
|
1159
|
+
kind: 'object', ops: ['merge', 'set'],
|
|
1160
|
+
fields: [
|
|
1161
|
+
{ key: 'title', label: 'Title', kind: 'i18n' },
|
|
1162
|
+
{ key: 'subtitle', label: 'Subtitle', kind: 'i18n' },
|
|
1163
|
+
{ key: 'ctaLabel', label: 'Button label', kind: 'i18n' },
|
|
1164
|
+
{ key: 'confirmationTitle', label: 'Confirmation title', kind: 'i18n' },
|
|
1165
|
+
{ key: 'confirmationText', label: 'Confirmation text', kind: 'i18n' },
|
|
1166
|
+
{ key: 'image', label: 'Image', kind: 'image' },
|
|
1167
|
+
{ key: 'imageMobile', label: 'Image (mobile)', kind: 'image' },
|
|
1168
|
+
],
|
|
1169
|
+
schema: z.record(z.string(), z.unknown()),
|
|
1170
|
+
}),
|
|
1171
|
+
|
|
1172
|
+
slot({ key: 'checkout.steps.order', scope: '_Checkout_', area: 'Funnel', label: 'Step order', hint: 'Reorder the funnel. Must remain a permutation of the six steps, or it is ignored.', kind: 'list', ops: ['reorder', 'set'], options: ['_Profile_', '_Offers_', '_Information_', '_Options_', '_Delivery_', '_Payment_'], schema: z.array(CartStepSchema) }),
|
|
1173
|
+
slot({ key: 'checkout.steps.hidden', scope: '_Checkout_', area: 'Funnel', label: 'Hidden steps', hint: 'Steps to skip entirely.', kind: 'list', ops: ['set'], options: ['_Profile_', '_Offers_', '_Information_', '_Options_', '_Delivery_', '_Payment_'], schema: z.array(CartStepSchema) }),
|
|
1174
|
+
slot({ key: 'checkout.options.preselected', scope: '_Checkout_', area: 'Funnel', label: 'Preselected options', hint: 'Options ticked by default on the options step.', kind: 'list', ops: ['set'], schema: z.array(z.string()) }),
|
|
1175
|
+
slot({ key: 'checkout.progress.visible', scope: '_Checkout_', area: 'Funnel', label: 'Progress bar visible', hint: 'Whether a progress indicator is shown.', kind: 'boolean', ops: ['set'], schema: z.boolean() }),
|
|
1176
|
+
slot({ key: 'checkout.progress.style', scope: '_Checkout_', area: 'Funnel', label: 'Progress bar style', hint: 'The shape the progress indicator takes.', kind: 'enum', ops: ['set'], options: ['bar', 'steps', 'percentage'], schema: z.enum(['bar', 'steps', 'percentage']) }),
|
|
1177
|
+
slot({ key: 'checkout.chat.enabled', scope: '_Checkout_', area: 'Contact', label: 'Chat enabled', hint: 'Whether the chat widget loads inside the checkout.', kind: 'boolean', ops: ['set'], schema: z.boolean() }),
|
|
1178
|
+
slot({ key: 'checkout.chat.delayMs', scope: '_Checkout_', area: 'Contact', label: 'Chat load delay', hint: 'How long to wait before loading the chat.', kind: 'duration', ops: ['set'], schema: z.number().int().min(0).max(600000) }),
|
|
1179
|
+
slot({ key: 'checkout.exitIntent.enabled', scope: '_Checkout_', area: 'Contact', label: 'Exit popup enabled', hint: 'Whether a popup appears when the cursor leaves the page.', kind: 'boolean', ops: ['set'], schema: z.boolean() }),
|
|
1180
|
+
slot({
|
|
1181
|
+
key: 'checkout.exitIntent.content', scope: '_Checkout_', area: 'Contact', label: 'Exit popup content',
|
|
1182
|
+
hint: 'The wording of the exit popup.',
|
|
1183
|
+
kind: 'object', ops: ['merge', 'set'],
|
|
1184
|
+
fields: [
|
|
1185
|
+
{ key: 'title', label: 'Title', kind: 'i18n' },
|
|
1186
|
+
{ key: 'text', label: 'Text', kind: 'i18n' },
|
|
1187
|
+
{ key: 'ctaLabel', label: 'Button label', kind: 'i18n' },
|
|
1188
|
+
],
|
|
1189
|
+
schema: z.record(z.string(), i18nTextSchema),
|
|
1190
|
+
}),
|
|
1191
|
+
slot({ key: 'checkout.texts', scope: '_Checkout_', area: 'Structure', label: 'Text overrides', hint: 'Replace any piece of checkout copy, written per language.', kind: 'record', entryKind: 'i18n', ops: ['merge', 'set'], schema: z.record(z.string(), i18nTextSchema) }),
|
|
1192
|
+
slot({
|
|
1193
|
+
key: 'checkout.media.visible', scope: '_Checkout_', area: 'Structure', label: 'Media visibility',
|
|
1194
|
+
hint: 'Show or hide the product photos, per device. Hiding them on mobile buys back a lot of height.',
|
|
1195
|
+
kind: 'object', ops: ['merge', 'set'],
|
|
1196
|
+
fields: [
|
|
1197
|
+
{ key: 'mobile', label: 'Mobile', kind: 'boolean' },
|
|
1198
|
+
{ key: 'tablet', label: 'Tablet', kind: 'boolean' },
|
|
1199
|
+
{ key: 'desktop', label: 'Desktop', kind: 'boolean' },
|
|
1200
|
+
],
|
|
1201
|
+
schema: z.object({ mobile: z.boolean().optional(), tablet: z.boolean().optional(), desktop: z.boolean().optional() }),
|
|
1202
|
+
}),
|
|
1203
|
+
] as const;
|
|
1204
|
+
|
|
1205
|
+
const MVT_SLOTS_BY_KEY: ReadonlyMap<string, MvtSlotDefinition> = new Map(MVT_SLOTS.map(s => [s.key, s]));
|
|
1206
|
+
|
|
1207
|
+
export function mvtSlot(key: string): MvtSlotDefinition | null {
|
|
1208
|
+
return MVT_SLOTS_BY_KEY.get(key) ?? null;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
export function mvtSlotsForScope(scope: MvtScope): MvtSlotDefinition[] {
|
|
1212
|
+
return MVT_SLOTS.filter(s => s.scope === scope || s.scope === '_Global_' || scope === '_Global_');
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
export interface MvtOverrideIssue {
|
|
1216
|
+
index: number;
|
|
1217
|
+
slot: string;
|
|
1218
|
+
reason: string;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Validate a variant's overrides against the registry.
|
|
1223
|
+
*
|
|
1224
|
+
* Called by the API before a variant is stored, so that a slot typo or a colour written as a name
|
|
1225
|
+
* instead of a hex value is a 422 for the operator who can still fix it, rather than a silently
|
|
1226
|
+
* ignored override that makes a test look like it had no effect.
|
|
1227
|
+
*/
|
|
1228
|
+
export function mvtValidateOverrides(overrides: MvtOverride[]): MvtOverrideIssue[] {
|
|
1229
|
+
const issues: MvtOverrideIssue[] = [];
|
|
1230
|
+
|
|
1231
|
+
overrides.forEach((override, index) => {
|
|
1232
|
+
const definition = mvtSlot(override.slot);
|
|
1233
|
+
|
|
1234
|
+
if (!definition) {
|
|
1235
|
+
issues.push({ index, slot: override.slot, reason: 'unknown_slot' });
|
|
1236
|
+
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (!definition.ops.includes(override.op)) {
|
|
1241
|
+
issues.push({ index, slot: override.slot, reason: 'unsupported_op' });
|
|
1242
|
+
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (override.op === 'remove' || override.op === 'reorder') return;
|
|
1247
|
+
|
|
1248
|
+
if (override.op === 'merge' || override.op === 'patch' || override.op === 'insert') {
|
|
1249
|
+
if (override.value === undefined) issues.push({ index, slot: override.slot, reason: 'missing_value' });
|
|
1250
|
+
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
if (!definition.schema.safeParse(override.value).success)
|
|
1255
|
+
issues.push({ index, slot: override.slot, reason: 'invalid_value' });
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
return issues;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Apply one override to a base value.
|
|
1263
|
+
*
|
|
1264
|
+
* Every branch is total: an operation that cannot be carried out on the shape it was given returns
|
|
1265
|
+
* the base untouched. A test that does nothing is a disappointment; a test that throws inside a
|
|
1266
|
+
* component's render path is an outage, and the two are not worth trading against each other.
|
|
1267
|
+
*/
|
|
1268
|
+
export function mvtApplyOverride<T>(base: T, override: MvtOverride): T {
|
|
1269
|
+
const value = override.value;
|
|
1270
|
+
|
|
1271
|
+
switch (override.op) {
|
|
1272
|
+
case 'set':
|
|
1273
|
+
return (value as T);
|
|
1274
|
+
|
|
1275
|
+
case 'merge':
|
|
1276
|
+
if (base === null || typeof base !== 'object' || Array.isArray(base)) return base;
|
|
1277
|
+
if (value === null || typeof value !== 'object') return base;
|
|
1278
|
+
|
|
1279
|
+
return { ...(base as object), ...(value as object) } as T;
|
|
1280
|
+
|
|
1281
|
+
case 'remove': {
|
|
1282
|
+
if (!Array.isArray(base)) return base;
|
|
1283
|
+
const key = override.match?.by;
|
|
1284
|
+
const equals = override.match?.equals;
|
|
1285
|
+
|
|
1286
|
+
if (!key) return base;
|
|
1287
|
+
|
|
1288
|
+
return (base as unknown[]).filter(item => String((item as Record<string, unknown>)?.[key] ?? item) !== equals) as T;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
case 'reorder': {
|
|
1292
|
+
if (!Array.isArray(base) || !Array.isArray(value)) return base;
|
|
1293
|
+
const key = override.match?.by;
|
|
1294
|
+
const identity = (item: unknown) => key ? String((item as Record<string, unknown>)?.[key] ?? '') : String(item);
|
|
1295
|
+
const wanted = (value as unknown[]).map(String);
|
|
1296
|
+
const known = (base as unknown[]).filter(item => wanted.includes(identity(item)));
|
|
1297
|
+
const rest = (base as unknown[]).filter(item => !wanted.includes(identity(item)));
|
|
1298
|
+
|
|
1299
|
+
known.sort((a, b) => wanted.indexOf(identity(a)) - wanted.indexOf(identity(b)));
|
|
1300
|
+
|
|
1301
|
+
return [...known, ...rest] as T;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
case 'insert': {
|
|
1305
|
+
if (!Array.isArray(base) || value === undefined) return base;
|
|
1306
|
+
const next = [...(base as unknown[])];
|
|
1307
|
+
const at = Math.min(override.at ?? next.length, next.length);
|
|
1308
|
+
|
|
1309
|
+
next.splice(at, 0, value);
|
|
1310
|
+
|
|
1311
|
+
return next as T;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
case 'patch': {
|
|
1315
|
+
if (!Array.isArray(base) || value === null || typeof value !== 'object') return base;
|
|
1316
|
+
const key = override.match?.by;
|
|
1317
|
+
const equals = override.match?.equals;
|
|
1318
|
+
|
|
1319
|
+
if (!key) return base;
|
|
1320
|
+
|
|
1321
|
+
return (base as unknown[]).map(item => {
|
|
1322
|
+
const current = item as Record<string, unknown>;
|
|
1323
|
+
|
|
1324
|
+
if (String(current?.[key] ?? '') !== equals) return item;
|
|
1325
|
+
|
|
1326
|
+
return { ...current, ...(value as object) };
|
|
1327
|
+
}) as T;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
default:
|
|
1331
|
+
return base;
|
|
1332
|
+
}
|
|
1333
|
+
}
|