@lifeaz/lors-helper 1.0.27 → 1.1.1

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/src/main.ts CHANGED
@@ -390,6 +390,14 @@ export const CartSchema = z.object({
390
390
  status: CartStatusSchema,
391
391
  currentStep: CartStepSchema,
392
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),
393
401
  profile: ProfileTypeSchema.nullable(),
394
402
  offerType: OfferTypeSchema.nullable(),
395
403
  paymentMethod: PaymentMethodSchema.nullable(),
@@ -650,3 +658,1039 @@ export const PartnerSchema = z.object({
650
658
  notices: z.array(PartnerNoticeSchema),
651
659
  });
652
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
+ | '_Query_Param_Matches_'
701
+ | '_Screen_Width_Between_'
702
+ | '_Visits_At_Least_'
703
+ | '_Logged_In_'
704
+ | '_Logged_Out_'
705
+ | '_Is_Weekend_'
706
+ | '_Date_Between_';
707
+
708
+ export const MvtOperatorSchema = z.union([
709
+ z.literal('_Device_In_'),
710
+ z.literal('_Lang_In_'),
711
+ z.literal('_Country_In_'),
712
+ z.literal('_Profile_In_'),
713
+ z.literal('_Partner_In_'),
714
+ z.literal('_Partner_Absent_'),
715
+ z.literal('_Campaign_Seen_'),
716
+ z.literal('_Campaign_Not_Seen_'),
717
+ z.literal('_Visitor_Is_New_'),
718
+ z.literal('_Visitor_Is_Returning_'),
719
+ z.literal('_Utm_Source_In_'),
720
+ z.literal('_Utm_Medium_In_'),
721
+ z.literal('_Utm_Campaign_In_'),
722
+ z.literal('_Referrer_Matches_'),
723
+ z.literal('_Path_Matches_'),
724
+ z.literal('_Has_Cart_'),
725
+ z.literal('_Hour_Of_Day_Between_'),
726
+ z.literal('_Day_Of_Week_In_'),
727
+ z.literal('_Query_Param_Matches_'),
728
+ z.literal('_Screen_Width_Between_'),
729
+ z.literal('_Visits_At_Least_'),
730
+ z.literal('_Logged_In_'),
731
+ z.literal('_Logged_Out_'),
732
+ z.literal('_Is_Weekend_'),
733
+ z.literal('_Date_Between_'),
734
+ ]);
735
+
736
+ /**
737
+ * Operators that read no value at all. Storing an empty value list against one of them is not an
738
+ * error worth refusing, but the evaluator ignores it, and the Tools condition builder hides the
739
+ * value editor rather than offering a field that changes nothing.
740
+ */
741
+ export const MVT_NULLARY_OPERATORS: readonly MvtOperator[] = [
742
+ '_Partner_Absent_',
743
+ '_Visitor_Is_New_',
744
+ '_Visitor_Is_Returning_',
745
+ '_Has_Cart_',
746
+ '_Logged_In_',
747
+ '_Logged_Out_',
748
+ '_Is_Weekend_',
749
+ ];
750
+
751
+ export type MvtOverrideOp = 'set' | 'merge' | 'remove' | 'reorder' | 'insert' | 'patch';
752
+ export const MvtOverrideOpSchema = z.union([z.literal('set'), z.literal('merge'), z.literal('remove'), z.literal('reorder'), z.literal('insert'), z.literal('patch')]);
753
+
754
+ export const MvtOverrideSchema = z.object({
755
+ slot: z.string().min(1),
756
+ op: MvtOverrideOpSchema,
757
+ value: z.unknown().optional(),
758
+ at: z.number().int().nonnegative().optional(),
759
+ match: z.object({ by: z.string().min(1), equals: z.string() }).optional(),
760
+ });
761
+ export type MvtOverride = z.infer<typeof MvtOverrideSchema>;
762
+
763
+ export type MvtAudienceNode = {
764
+ kind: MvtNodeKind;
765
+ operator?: MvtOperator | null | undefined;
766
+ values?: string[] | undefined;
767
+ children?: MvtAudienceNode[] | undefined;
768
+ };
769
+
770
+ export const MvtAudienceNodeSchema: z.ZodType<MvtAudienceNode> = z.lazy(() => z.object({
771
+ kind: MvtNodeKindSchema,
772
+ operator: MvtOperatorSchema.nullable().optional(),
773
+ values: z.array(z.string()).optional(),
774
+ children: z.array(z.lazy(() => MvtAudienceNodeSchema)).optional(),
775
+ }));
776
+
777
+ export const MvtVariantSchema = z.object({
778
+ id: uuidSchema,
779
+ key: z.string().min(1),
780
+ name: z.string(),
781
+ weight: z.number().int().nonnegative(),
782
+ control: z.boolean(),
783
+ overrides: z.array(MvtOverrideSchema),
784
+ /**
785
+ * Optional sub-targeting. A variant carrying one stops being a share of the split and becomes
786
+ * a rule: every unit matching it lands here, whatever the weights say. See mvtPickVariant.
787
+ */
788
+ audience: MvtAudienceNodeSchema.nullable().default(null),
789
+ /** Evaluation order of the targeted variants. The first match wins, so this is the tie-break. */
790
+ order: z.number().int().default(0),
791
+ /** Purely cosmetic, so one arm keeps the same colour across the split bar, the studio and the results. */
792
+ color: z.string().nullable().default(null),
793
+ description: z.string().nullable().default(null),
794
+ });
795
+ export type MvtVariant = z.infer<typeof MvtVariantSchema>;
796
+
797
+ /**
798
+ * How one variant ends up receiving traffic, resolved from the variant set as a whole.
799
+ *
800
+ * `_Targeted_` variants are claimed by their own conditions and take no part in the weighted
801
+ * draw; `_Weighted_` ones share whatever is left. Computed in one place because the operator is
802
+ * shown this and the evaluator obeys it, and the two disagreeing would be the worst outcome.
803
+ */
804
+ export type MvtAllocationMode = '_Targeted_' | '_Weighted_' | '_Unreachable_';
805
+
806
+ export interface MvtAllocationRow {
807
+ key: string;
808
+ mode: MvtAllocationMode;
809
+ weight: number;
810
+ /** Share of the units that reach the weighted draw. Zero for a targeted variant, whose share depends on the audience rather than on a number. */
811
+ sharePct: number;
812
+ }
813
+
814
+ export const MvtExperimentSchema = z.object({
815
+ id: uuidSchema,
816
+ key: z.string().min(1),
817
+ name: z.string(),
818
+ description: z.string().nullable(),
819
+ status: MvtStatusSchema,
820
+ scope: MvtScopeSchema,
821
+ unit: MvtUnitSchema,
822
+ salt: z.string().min(1),
823
+ startsAt: z.string().nullable(),
824
+ endsAt: z.string().nullable(),
825
+ priority: z.number().int(),
826
+ exclusionGroup: z.string().nullable(),
827
+ trafficPct: z.number().int().min(0).max(100),
828
+ sticky: z.boolean(),
829
+ audience: MvtAudienceNodeSchema.nullable(),
830
+ variants: z.array(MvtVariantSchema),
831
+ });
832
+ export type MvtExperiment = z.infer<typeof MvtExperimentSchema>;
833
+
834
+ export const MvtConfigSchema = z.object({
835
+ version: z.string(),
836
+ enabled: z.boolean(),
837
+ experiments: z.array(MvtExperimentSchema),
838
+ });
839
+ export type MvtConfig = z.infer<typeof MvtConfigSchema>;
840
+
841
+ /**
842
+ * Everything the audience tree is allowed to read, resolved once and frozen into the assignment.
843
+ *
844
+ * Frozen matters more than it looks: a visitor who rotates their phone crosses the tablet/desktop
845
+ * breakpoint, and a context read live would move them to another variant mid-session. The context
846
+ * captured at assignment time is the one that decides, for as long as the assignment lives.
847
+ */
848
+ export interface MvtContext {
849
+ unitId: string;
850
+ device: MvtDevice;
851
+ lang: string;
852
+ country: string | null;
853
+ profile: string | null;
854
+ partner: string | null;
855
+ seen: Record<string, number>;
856
+ utm: { source: string | null; medium: string | null; campaign: string | null };
857
+ referrer: string | null;
858
+ path: string;
859
+ hasCart: boolean;
860
+ firstSeenAt: number;
861
+ now: number;
862
+ /** Query string of the landing URL, lowercased keys. Reads as a targeting dimension of its own so an ad link can pin an arm without an UTM convention. */
863
+ query: Record<string, string>;
864
+ /** Viewport width in CSS pixels. Coarser than the device buckets, for the tests where the breakpoint itself is the subject. */
865
+ screenWidth: number;
866
+ /** Sessions this unit has opened, this one included. */
867
+ visits: number;
868
+ loggedIn: boolean;
869
+ }
870
+
871
+ /**
872
+ * Fill in the dimensions an older caller does not provide.
873
+ *
874
+ * The context grew, and a front built against the previous shape must not start failing every
875
+ * leaf that reads a new field. Defaults are chosen so an absent dimension excludes rather than
876
+ * includes: a condition on something the caller cannot observe should not match everybody.
877
+ */
878
+ export function mvtNormalizeContext(context: Partial<MvtContext> & { unitId: string }): MvtContext {
879
+ return {
880
+ device: '_Desktop_',
881
+ lang: 'fr',
882
+ country: null,
883
+ profile: null,
884
+ partner: null,
885
+ seen: {},
886
+ utm: { source: null, medium: null, campaign: null },
887
+ referrer: null,
888
+ path: '/',
889
+ hasCart: false,
890
+ firstSeenAt: Date.now(),
891
+ now: Date.now(),
892
+ query: {},
893
+ screenWidth: 0,
894
+ visits: 1,
895
+ loggedIn: false,
896
+ ...context,
897
+ };
898
+ }
899
+
900
+ export interface MvtDecision {
901
+ experimentKey: string;
902
+ experimentId: UUID;
903
+ variantKey: string;
904
+ variantId: UUID;
905
+ control: boolean;
906
+ overrides: MvtOverride[];
907
+ }
908
+
909
+ const MVT_RETURNING_AFTER_MS = 30 * 60 * 1000;
910
+
911
+ /**
912
+ * FNV-1a, 32 bits.
913
+ *
914
+ * Chosen for being trivially reimplementable and byte-for-byte identical everywhere: the API, the
915
+ * storefront and the checkout each bucket independently, with no round trip, and any disagreement
916
+ * between them would show up as a visitor silently changing variant when they cross from one
917
+ * application to the other.
918
+ */
919
+ export function mvtHash(input: string): number {
920
+ let hash = 0x811c9dc5;
921
+
922
+ for (let i = 0; i < input.length; i++) {
923
+ hash ^= input.charCodeAt(i);
924
+ hash = Math.imul(hash, 0x01000193) >>> 0;
925
+ }
926
+
927
+ return hash >>> 0;
928
+ }
929
+
930
+ export function mvtBucket(salt: string, unitId: string, namespace: string = 'variant'): number {
931
+ return mvtHash(`${salt}:${namespace}:${unitId}`) % 10000;
932
+ }
933
+
934
+ export function mvtIsEligibleForTraffic(experiment: MvtExperiment, unitId: string): boolean {
935
+ if (experiment.trafficPct >= 100) return true;
936
+ if (experiment.trafficPct <= 0) return false;
937
+
938
+ return mvtBucket(experiment.salt, unitId, 'traffic') < experiment.trafficPct * 100;
939
+ }
940
+
941
+ /**
942
+ * Pick the variant a unit falls into, by weight, deterministically.
943
+ *
944
+ * Weights are relative rather than percentages so that 90/10, 50/50 and 34/33/33 are all
945
+ * expressible without the operator having to make anything add up. They are sorted by key first,
946
+ * so that the mapping from bucket range to variant depends only on the weights themselves and not
947
+ * on the order rows happened to come back from the database.
948
+ */
949
+ export function mvtPickVariant(experiment: MvtExperiment, unitId: string, context?: MvtContext): MvtVariant | null {
950
+ const targeted = experiment.variants
951
+ .filter(variant => !!variant.audience)
952
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.key.localeCompare(b.key));
953
+
954
+ // Targeted arms are rules, not shares: the first whose own conditions hold claims the unit
955
+ // outright. Evaluated before the draw so that "everyone on mobile sees B" means exactly that
956
+ // rather than "mobile visitors have a better chance of seeing B", which is what a weight
957
+ // nudged by hand would have produced and what an operator would have had to verify by eye.
958
+ if (context)
959
+ for (const variant of targeted)
960
+ if (mvtEvaluateAudience(variant.audience ?? null, context)) return variant;
961
+
962
+ const pool = experiment.variants.filter(variant => !variant.audience).sort((a, b) => a.key.localeCompare(b.key));
963
+ const total = pool.reduce((sum, v) => sum + Math.max(v.weight, 0), 0);
964
+
965
+ // Every arm targeted and none of them matching leaves nobody to fall back on. The unit is
966
+ // left out of the experiment entirely rather than pushed onto an arm whose conditions it
967
+ // failed, which would silently make the targeting a lie.
968
+ if (!pool.length || total <= 0) return null;
969
+
970
+ const target = (mvtBucket(experiment.salt, unitId) / 10000) * total;
971
+ let cursor = 0;
972
+
973
+ for (const variant of pool) {
974
+ cursor += Math.max(variant.weight, 0);
975
+
976
+ if (target < cursor) return variant;
977
+ }
978
+
979
+ return pool[pool.length - 1];
980
+ }
981
+
982
+ /**
983
+ * How the split actually resolves, for the operator rather than the evaluator.
984
+ *
985
+ * Weights are relative and targeted arms are outside the draw entirely, so the number an operator
986
+ * typed and the share it buys are rarely the same. Computing that here, from the same rules the
987
+ * bucketing obeys, is what lets the interface show a split bar that cannot drift from the truth.
988
+ */
989
+ export function mvtDistribution(variants: readonly MvtVariant[]): MvtAllocationRow[] {
990
+ const pool = variants.filter(variant => !variant.audience);
991
+ const total = pool.reduce((sum, v) => sum + Math.max(v.weight, 0), 0);
992
+
993
+ return variants.map(variant => {
994
+ if (variant.audience) return { key: variant.key, mode: '_Targeted_' as const, weight: variant.weight, sharePct: 0 };
995
+ if (total <= 0 || variant.weight <= 0) return { key: variant.key, mode: '_Unreachable_' as const, weight: variant.weight, sharePct: 0 };
996
+
997
+ return {
998
+ key: variant.key,
999
+ mode: '_Weighted_' as const,
1000
+ weight: variant.weight,
1001
+ sharePct: Math.round((Math.max(variant.weight, 0) / total) * 1000) / 10,
1002
+ };
1003
+ });
1004
+ }
1005
+
1006
+ function mvtMatchesPattern(value: string | null, patterns: string[]): boolean {
1007
+ if (value === null) return false;
1008
+
1009
+ const haystack = value.toLowerCase();
1010
+
1011
+ return patterns.some(pattern => {
1012
+ const needle = pattern.trim().toLowerCase();
1013
+
1014
+ if (!needle) return false;
1015
+ if (needle.startsWith('*') && needle.endsWith('*')) return haystack.includes(needle.slice(1, -1));
1016
+ if (needle.startsWith('*')) return haystack.endsWith(needle.slice(1));
1017
+ if (needle.endsWith('*')) return haystack.startsWith(needle.slice(0, -1));
1018
+
1019
+ return haystack === needle;
1020
+ });
1021
+ }
1022
+
1023
+ function mvtEvaluateLeaf(operator: MvtOperator, values: string[], context: MvtContext): boolean {
1024
+ const lower = values.map(v => v.toLowerCase());
1025
+
1026
+ switch (operator) {
1027
+ case '_Device_In_': return values.includes(context.device);
1028
+ case '_Lang_In_': return lower.includes(context.lang.toLowerCase());
1029
+ case '_Country_In_': return context.country !== null && lower.includes(context.country.toLowerCase());
1030
+ case '_Profile_In_': return context.profile !== null && values.includes(context.profile);
1031
+ case '_Partner_In_': return context.partner !== null && lower.includes(context.partner.toLowerCase());
1032
+ case '_Partner_Absent_': return context.partner === null;
1033
+ case '_Campaign_Seen_': return values.some(v => context.seen[`campaign:${v}`] !== undefined);
1034
+ case '_Campaign_Not_Seen_': return values.every(v => context.seen[`campaign:${v}`] === undefined);
1035
+ case '_Visitor_Is_New_': return context.now - context.firstSeenAt < MVT_RETURNING_AFTER_MS;
1036
+ case '_Visitor_Is_Returning_': return context.now - context.firstSeenAt >= MVT_RETURNING_AFTER_MS;
1037
+ case '_Utm_Source_In_': return mvtMatchesPattern(context.utm.source, values);
1038
+ case '_Utm_Medium_In_': return mvtMatchesPattern(context.utm.medium, values);
1039
+ case '_Utm_Campaign_In_': return mvtMatchesPattern(context.utm.campaign, values);
1040
+ case '_Referrer_Matches_': return mvtMatchesPattern(context.referrer, values);
1041
+ case '_Path_Matches_': return mvtMatchesPattern(context.path, values);
1042
+ case '_Has_Cart_': return context.hasCart;
1043
+ case '_Hour_Of_Day_Between_': {
1044
+ const hour = new Date(context.now).getHours();
1045
+ const from = Number(values[0] ?? 0);
1046
+ const to = Number(values[1] ?? 23);
1047
+
1048
+ return from <= to ? hour >= from && hour <= to : hour >= from || hour <= to;
1049
+ }
1050
+ case '_Day_Of_Week_In_': return values.map(Number).includes(new Date(context.now).getDay());
1051
+ case '_Logged_In_': return context.loggedIn;
1052
+ case '_Logged_Out_': return !context.loggedIn;
1053
+ case '_Is_Weekend_': return [0, 6].includes(new Date(context.now).getDay());
1054
+ case '_Visits_At_Least_': return context.visits >= Number(values[0] ?? 1);
1055
+
1056
+ // Written "name=value", or "name" on its own to test for presence. The value side goes
1057
+ // through the same wildcard matcher as the UTM leaves, so utm-like conventions carried in
1058
+ // a bespoke parameter behave the way an operator already expects them to.
1059
+ case '_Query_Param_Matches_': {
1060
+ return values.some(raw => {
1061
+ const separator = raw.indexOf('=');
1062
+ const name = (separator < 0 ? raw : raw.slice(0, separator)).trim().toLowerCase();
1063
+ const expected = separator < 0 ? null : raw.slice(separator + 1);
1064
+ const actual = context.query[name];
1065
+
1066
+ if (actual === undefined) return false;
1067
+ if (expected === null) return true;
1068
+
1069
+ return mvtMatchesPattern(actual, [expected]);
1070
+ });
1071
+ }
1072
+
1073
+ case '_Screen_Width_Between_': {
1074
+ const from = Number(values[0] ?? 0);
1075
+ const to = Number(values[1] ?? Number.MAX_SAFE_INTEGER);
1076
+
1077
+ return context.screenWidth >= from && context.screenWidth <= to;
1078
+ }
1079
+
1080
+ // Bounds are wall-clock dates, and either may be left out for an open-ended window. This
1081
+ // is not the experiment's own schedule: it is for the tests whose point is a date, such
1082
+ // as a variant that only exists over a sale weekend.
1083
+ case '_Date_Between_': {
1084
+ const from = values[0] ? Date.parse(values[0]) : Number.NEGATIVE_INFINITY;
1085
+ const to = values[1] ? Date.parse(values[1]) : Number.POSITIVE_INFINITY;
1086
+
1087
+ if (Number.isNaN(from) || Number.isNaN(to)) return false;
1088
+
1089
+ return context.now >= from && context.now <= to;
1090
+ }
1091
+
1092
+ default: return false;
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * Evaluate an audience tree.
1098
+ *
1099
+ * A null tree targets everyone, which is the common case and should not require building a node.
1100
+ * An operator this build does not know returns false rather than throwing: a Tools release that
1101
+ * introduces a new operator must never be able to break a front that has not shipped yet, and
1102
+ * failing closed means the visitor stays on control instead of seeing a broken page.
1103
+ */
1104
+ export function mvtEvaluateAudience(node: MvtAudienceNode | null, context: MvtContext): boolean {
1105
+ if (!node) return true;
1106
+
1107
+ switch (node.kind) {
1108
+ case '_And_': return (node.children ?? []).every(child => mvtEvaluateAudience(child, context));
1109
+ case '_Or_': return (node.children ?? []).some(child => mvtEvaluateAudience(child, context));
1110
+ case '_Not_': return !(node.children ?? []).some(child => mvtEvaluateAudience(child, context));
1111
+ case '_Leaf_': return node.operator ? mvtEvaluateLeaf(node.operator, node.values ?? [], context) : false;
1112
+ default: return false;
1113
+ }
1114
+ }
1115
+
1116
+ export function mvtIsWithinWindow(experiment: MvtExperiment, now: number): boolean {
1117
+ if (experiment.startsAt && Date.parse(experiment.startsAt) > now) return false;
1118
+ if (experiment.endsAt && Date.parse(experiment.endsAt) <= now) return false;
1119
+
1120
+ return true;
1121
+ }
1122
+
1123
+ export function mvtIsLive(experiment: MvtExperiment, now: number): boolean {
1124
+ return experiment.status === '_Running_' && mvtIsWithinWindow(experiment, now);
1125
+ }
1126
+
1127
+ /**
1128
+ * Resolve every decision for one unit, in one pass.
1129
+ *
1130
+ * Experiments are walked by descending priority so that exclusion groups resolve predictably: the
1131
+ * first live experiment of a group claims it, and the rest are skipped for this unit. Without an
1132
+ * order the winner would depend on row order, and two visitors could end up in mutually exclusive
1133
+ * tests that were meant to be exclusive.
1134
+ */
1135
+ export function mvtResolve(config: MvtConfig, context: MvtContext, forced: Record<string, string> = {}): MvtDecision[] {
1136
+ if (!config.enabled) return [];
1137
+
1138
+ const decisions: MvtDecision[] = [];
1139
+ const claimed = new Set<string>();
1140
+ const experiments = [...config.experiments].sort((a, b) => b.priority - a.priority || a.key.localeCompare(b.key));
1141
+
1142
+ for (const experiment of experiments) {
1143
+ const forcedKey = forced[experiment.key];
1144
+
1145
+ if (forcedKey) {
1146
+ const variant = experiment.variants.find(v => v.key === forcedKey);
1147
+
1148
+ if (variant) decisions.push(mvtToDecision(experiment, variant));
1149
+
1150
+ continue;
1151
+ }
1152
+
1153
+ if (!mvtIsLive(experiment, context.now)) continue;
1154
+ if (experiment.exclusionGroup && claimed.has(experiment.exclusionGroup)) continue;
1155
+ if (!mvtEvaluateAudience(experiment.audience, context)) continue;
1156
+ if (!mvtIsEligibleForTraffic(experiment, context.unitId)) continue;
1157
+
1158
+ const variant = mvtPickVariant(experiment, context.unitId, context);
1159
+
1160
+ if (!variant) continue;
1161
+ if (experiment.exclusionGroup) claimed.add(experiment.exclusionGroup);
1162
+
1163
+ decisions.push(mvtToDecision(experiment, variant));
1164
+ }
1165
+
1166
+ return decisions;
1167
+ }
1168
+
1169
+ function mvtToDecision(experiment: MvtExperiment, variant: MvtVariant): MvtDecision {
1170
+ return {
1171
+ experimentKey: experiment.key,
1172
+ experimentId: experiment.id,
1173
+ variantKey: variant.key,
1174
+ variantId: variant.id,
1175
+ control: variant.control,
1176
+ overrides: variant.overrides,
1177
+ };
1178
+ }
1179
+
1180
+ // -----------------------------------------------------------------------------
1181
+ // Slot registry
1182
+ // -----------------------------------------------------------------------------
1183
+
1184
+ export type MvtSlotKind = 'boolean' | 'number' | 'duration' | 'text' | 'i18n' | 'color' | 'image' | 'enum' | 'list' | 'object' | 'record';
1185
+
1186
+ /**
1187
+ * One editable field of a fixed-shape slot.
1188
+ *
1189
+ * The registry describes the shape rather than leaving the interface to infer it, because the
1190
+ * alternative is an operator typing JSON into a textarea and discovering their mistake from a
1191
+ * customer. A field the editor does not recognise degrades to a text input rather than
1192
+ * disappearing, so adding a kind here never blanks out a control in an older build.
1193
+ */
1194
+ export interface MvtSlotField {
1195
+ key: string;
1196
+ label: string;
1197
+ kind: MvtSlotKind;
1198
+ options?: readonly string[];
1199
+ }
1200
+
1201
+ export interface MvtSlotDefinition {
1202
+ key: string;
1203
+ scope: MvtScope;
1204
+ area: string;
1205
+ label: string;
1206
+ /** One short sentence an operator reads before deciding whether this is the slot they want. */
1207
+ hint: string;
1208
+ kind: MvtSlotKind;
1209
+ ops: readonly MvtOverrideOp[];
1210
+ /** Allowed values for an `enum`, or the closed set of entries for a constrained `list`. */
1211
+ options?: readonly string[];
1212
+ /** The named fields of an `object`, or the per-item fields of a list of objects. */
1213
+ fields?: readonly MvtSlotField[];
1214
+ /** The kind every value takes in a `record`, whose keys are open-ended. */
1215
+ entryKind?: MvtSlotKind;
1216
+ /** Which property identifies an item, for the `remove`, `patch` and `reorder` operations. */
1217
+ itemKey?: string;
1218
+ schema: z.ZodType<unknown>;
1219
+ }
1220
+
1221
+ /**
1222
+ * A piece of copy, written in whichever languages the operator filled in.
1223
+ *
1224
+ * Partial on purpose, and it has to be said in the schema rather than left to convention: zod
1225
+ * treats a record keyed by a closed set as exhaustive, so `z.record` here would refuse a French-
1226
+ * only override at write time - which is the normal case, since a language left empty is
1227
+ * documented everywhere as keeping the untouched original rather than blanking it.
1228
+ */
1229
+ const i18nTextSchema = z.partialRecord(LangSchema, z.string());
1230
+ const colorSchema = z.string().regex(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
1231
+
1232
+ /**
1233
+ * What a test may do to one anchored element.
1234
+ *
1235
+ * This is the whole vocabulary of the visual editor, and it is deliberately short. Hide it, move
1236
+ * it, reword it, repoint it, restyle it within a closed set of properties. Nothing here can
1237
+ * inject markup or a script, so an override authored by clicking around a page carries the same
1238
+ * blast radius as one typed into the registry editor, which is what makes the studio safe to
1239
+ * hand to somebody who is not going to read a diff.
1240
+ *
1241
+ * Every field is optional and absence means untouched, so the patches stay small and two editing
1242
+ * sessions on the same element compose instead of overwriting one another.
1243
+ */
1244
+ export const MvtAnchorPatchSchema = z.object({
1245
+ hidden: z.boolean().optional(),
1246
+ /** Position among its siblings. Lower comes first; anchors without one keep their place. */
1247
+ order: z.number().int().min(-999).max(999).optional(),
1248
+ text: i18nTextSchema.optional(),
1249
+ href: z.string().max(2048).optional(),
1250
+ route: z.string().max(512).optional(),
1251
+ image: z.string().max(2048).optional(),
1252
+ style: z.object({
1253
+ background: colorSchema.optional(),
1254
+ color: colorSchema.optional(),
1255
+ borderColor: colorSchema.optional(),
1256
+ borderRadius: z.number().int().min(0).max(96).optional(),
1257
+ fontSize: z.number().int().min(8).max(96).optional(),
1258
+ fontWeight: z.enum(['300', '400', '500', '600', '700', '800']).optional(),
1259
+ align: z.enum(['left', 'center', 'right']).optional(),
1260
+ paddingY: z.number().int().min(0).max(160).optional(),
1261
+ paddingX: z.number().int().min(0).max(160).optional(),
1262
+ }).optional(),
1263
+ });
1264
+ export type MvtAnchorPatch = z.infer<typeof MvtAnchorPatchSchema>;
1265
+
1266
+ const anchorsSchema = z.record(z.string(), MvtAnchorPatchSchema);
1267
+
1268
+ const ANCHOR_FIELDS: readonly MvtSlotField[] = [
1269
+ { key: 'hidden', label: 'Hidden', kind: 'boolean' },
1270
+ { key: 'order', label: 'Position', kind: 'number' },
1271
+ { key: 'text', label: 'Text', kind: 'i18n' },
1272
+ { key: 'href', label: 'External link', kind: 'text' },
1273
+ { key: 'route', label: 'Internal route', kind: 'text' },
1274
+ { key: 'image', label: 'Image', kind: 'image' },
1275
+ ];
1276
+
1277
+ function slot(definition: MvtSlotDefinition): MvtSlotDefinition {
1278
+ return definition;
1279
+ }
1280
+
1281
+ /**
1282
+ * The single source of truth for what a test is allowed to change.
1283
+ *
1284
+ * Shared by all four applications on purpose. The API validates writes against it, so a malformed
1285
+ * override is refused at authoring time rather than discovered by a customer; Tools reads it to
1286
+ * render a typed editor instead of a free JSON field; and the fronts read it to reject anything
1287
+ * that slipped through anyway. A slot absent from this list simply does not exist, which is what
1288
+ * keeps the blast radius of the whole feature bounded.
1289
+ *
1290
+ * Nothing monetary appears here, and nothing ever should. Prices, items and discounts are steered
1291
+ * by campaigns and discounts, which carry their own controls and their own audit trail. A test
1292
+ * decides how an offer is presented, never what it costs.
1293
+ */
1294
+ export const MVT_SLOTS: readonly MvtSlotDefinition[] = [
1295
+ 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()) }),
1296
+ 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()) }),
1297
+ 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) }),
1298
+ 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()) }),
1299
+
1300
+ slot({
1301
+ key: 'storefront.ctas', scope: '_Storefront_', area: 'CTA', label: 'Call to action list',
1302
+ hint: 'Hide, reorder or reword the buttons of a page.',
1303
+ kind: 'list', ops: ['remove', 'reorder', 'patch', 'insert', 'set'], itemKey: 'label',
1304
+ fields: [
1305
+ { key: 'label', label: 'Label', kind: 'text' },
1306
+ { key: 'variant', label: 'Style', kind: 'enum', options: ['primary', 'secondary', 'tertiary'] },
1307
+ { key: 'route', label: 'Internal route', kind: 'text' },
1308
+ { key: 'href', label: 'External link', kind: 'text' },
1309
+ ],
1310
+ schema: z.array(z.record(z.string(), z.unknown())),
1311
+ }),
1312
+ slot({
1313
+ key: 'storefront.cta.colors', scope: '_Storefront_', area: 'CTA', label: 'Call to action colors',
1314
+ hint: 'Recolour the buttons, per style.',
1315
+ kind: 'object', ops: ['merge', 'set'],
1316
+ fields: [
1317
+ { key: 'primary', label: 'Primary', kind: 'color' },
1318
+ { key: 'secondary', label: 'Secondary', kind: 'color' },
1319
+ { key: 'tertiary', label: 'Tertiary', kind: 'color' },
1320
+ ],
1321
+ schema: z.record(z.string(), colorSchema),
1322
+ }),
1323
+ 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()) }),
1324
+
1325
+ slot({
1326
+ key: 'campaign.banner.colors', scope: '_Global_', area: 'Flash offer', label: 'Banner colors',
1327
+ hint: 'Recolour the flash offer banner.',
1328
+ kind: 'object', ops: ['merge', 'set'],
1329
+ fields: [
1330
+ { key: 'background', label: 'Background', kind: 'color' },
1331
+ { key: 'text', label: 'Text', kind: 'color' },
1332
+ { key: 'tagBackground', label: 'Tag background', kind: 'color' },
1333
+ { key: 'tagText', label: 'Tag text', kind: 'color' },
1334
+ { key: 'countdownText', label: 'Countdown text', kind: 'color' },
1335
+ ],
1336
+ schema: z.record(z.string(), colorSchema),
1337
+ }),
1338
+ 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 }),
1339
+ 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 }),
1340
+ 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() }),
1341
+ 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']) }),
1342
+ 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 }),
1343
+ 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']) }),
1344
+ slot({
1345
+ key: 'campaign.priceTag.colors', scope: '_Global_', area: 'Flash offer', label: 'Discount badge colors',
1346
+ hint: 'Recolour the discount badge.',
1347
+ kind: 'object', ops: ['merge', 'set'],
1348
+ fields: [
1349
+ { key: 'background', label: 'Background', kind: 'color' },
1350
+ { key: 'text', label: 'Text', kind: 'color' },
1351
+ ],
1352
+ schema: z.record(z.string(), colorSchema),
1353
+ }),
1354
+ 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 }),
1355
+
1356
+ 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() }),
1357
+ 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) }),
1358
+ 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']) }),
1359
+ slot({
1360
+ key: 'popup.welcome.frequency', scope: '_Storefront_', area: 'Popup', label: 'Welcome popup frequency',
1361
+ hint: 'How often one visitor sees it again.',
1362
+ kind: 'object', ops: ['merge', 'set'],
1363
+ fields: [
1364
+ { key: 'mode', label: 'Mode', kind: 'enum', options: ['once', 'session', 'days'] },
1365
+ { key: 'days', label: 'Every N days', kind: 'number' },
1366
+ ],
1367
+ schema: z.object({ mode: z.enum(['once', 'session', 'days']), days: z.number().int().min(1).max(365).optional() }),
1368
+ }),
1369
+ slot({
1370
+ key: 'popup.welcome.content', scope: '_Storefront_', area: 'Popup', label: 'Welcome popup content',
1371
+ hint: 'The wording and image of the welcome popup.',
1372
+ kind: 'object', ops: ['merge', 'set'],
1373
+ fields: [
1374
+ { key: 'title', label: 'Title', kind: 'i18n' },
1375
+ { key: 'subtitle', label: 'Subtitle', kind: 'i18n' },
1376
+ { key: 'ctaLabel', label: 'Button label', kind: 'i18n' },
1377
+ { key: 'confirmationTitle', label: 'Confirmation title', kind: 'i18n' },
1378
+ { key: 'confirmationText', label: 'Confirmation text', kind: 'i18n' },
1379
+ { key: 'image', label: 'Image', kind: 'image' },
1380
+ { key: 'imageMobile', label: 'Image (mobile)', kind: 'image' },
1381
+ ],
1382
+ schema: z.record(z.string(), z.unknown()),
1383
+ }),
1384
+
1385
+ 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) }),
1386
+ 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) }),
1387
+ 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()) }),
1388
+ 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() }),
1389
+ 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']) }),
1390
+ 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() }),
1391
+ 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) }),
1392
+ 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() }),
1393
+ slot({
1394
+ key: 'checkout.exitIntent.content', scope: '_Checkout_', area: 'Contact', label: 'Exit popup content',
1395
+ hint: 'The wording of the exit popup.',
1396
+ kind: 'object', ops: ['merge', 'set'],
1397
+ fields: [
1398
+ { key: 'title', label: 'Title', kind: 'i18n' },
1399
+ { key: 'text', label: 'Text', kind: 'i18n' },
1400
+ { key: 'ctaLabel', label: 'Button label', kind: 'i18n' },
1401
+ ],
1402
+ schema: z.record(z.string(), i18nTextSchema),
1403
+ }),
1404
+ 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) }),
1405
+ slot({
1406
+ key: 'checkout.media.visible', scope: '_Checkout_', area: 'Structure', label: 'Media visibility',
1407
+ hint: 'Show or hide the product photos, per device. Hiding them on mobile buys back a lot of height.',
1408
+ kind: 'object', ops: ['merge', 'set'],
1409
+ fields: [
1410
+ { key: 'mobile', label: 'Mobile', kind: 'boolean' },
1411
+ { key: 'tablet', label: 'Tablet', kind: 'boolean' },
1412
+ { key: 'desktop', label: 'Desktop', kind: 'boolean' },
1413
+ ],
1414
+ schema: z.object({ mobile: z.boolean().optional(), tablet: z.boolean().optional(), desktop: z.boolean().optional() }),
1415
+ }),
1416
+
1417
+ slot({
1418
+ key: 'storefront.anchors', scope: '_Storefront_', area: 'Studio', label: 'Anchored elements',
1419
+ hint: 'Everything the visual editor writes: elements hidden, moved, reworded or restyled by clicking on the page itself.',
1420
+ kind: 'record', entryKind: 'object', ops: ['merge', 'set'], fields: ANCHOR_FIELDS, itemKey: 'anchor',
1421
+ schema: anchorsSchema,
1422
+ }),
1423
+ slot({
1424
+ key: 'checkout.anchors', scope: '_Checkout_', area: 'Studio', label: 'Anchored elements',
1425
+ hint: 'Everything the visual editor writes: elements hidden, moved, reworded or restyled by clicking on the page itself.',
1426
+ kind: 'record', entryKind: 'object', ops: ['merge', 'set'], fields: ANCHOR_FIELDS, itemKey: 'anchor',
1427
+ schema: anchorsSchema,
1428
+ }),
1429
+ ] as const;
1430
+
1431
+ const MVT_SLOTS_BY_KEY: ReadonlyMap<string, MvtSlotDefinition> = new Map(MVT_SLOTS.map(s => [s.key, s]));
1432
+
1433
+ export function mvtSlot(key: string): MvtSlotDefinition | null {
1434
+ return MVT_SLOTS_BY_KEY.get(key) ?? null;
1435
+ }
1436
+
1437
+ export function mvtSlotsForScope(scope: MvtScope): MvtSlotDefinition[] {
1438
+ return MVT_SLOTS.filter(s => s.scope === scope || s.scope === '_Global_' || scope === '_Global_');
1439
+ }
1440
+
1441
+ export interface MvtOverrideIssue {
1442
+ index: number;
1443
+ slot: string;
1444
+ reason: string;
1445
+ }
1446
+
1447
+ /**
1448
+ * Validate a variant's overrides against the registry.
1449
+ *
1450
+ * Called by the API before a variant is stored, so that a slot typo or a colour written as a name
1451
+ * instead of a hex value is a 422 for the operator who can still fix it, rather than a silently
1452
+ * ignored override that makes a test look like it had no effect.
1453
+ */
1454
+ export function mvtValidateOverrides(overrides: MvtOverride[]): MvtOverrideIssue[] {
1455
+ const issues: MvtOverrideIssue[] = [];
1456
+
1457
+ overrides.forEach((override, index) => {
1458
+ const definition = mvtSlot(override.slot);
1459
+
1460
+ if (!definition) {
1461
+ issues.push({ index, slot: override.slot, reason: 'unknown_slot' });
1462
+
1463
+ return;
1464
+ }
1465
+
1466
+ if (!definition.ops.includes(override.op)) {
1467
+ issues.push({ index, slot: override.slot, reason: 'unsupported_op' });
1468
+
1469
+ return;
1470
+ }
1471
+
1472
+ if (override.op === 'remove' || override.op === 'reorder') return;
1473
+
1474
+ if (override.op === 'merge' || override.op === 'patch' || override.op === 'insert') {
1475
+ if (override.value === undefined) {
1476
+ issues.push({ index, slot: override.slot, reason: 'missing_value' });
1477
+
1478
+ return;
1479
+ }
1480
+
1481
+ // A merge onto a record is still a record of the same entries, so the full schema
1482
+ // applies and catches a malformed anchor patch here rather than in a customer's
1483
+ // browser. A merge onto a fixed-shape object is a partial by definition, and running
1484
+ // the whole schema over it would refuse every legitimate single-field edit.
1485
+ if (definition.kind === 'record' && !definition.schema.safeParse(override.value).success)
1486
+ issues.push({ index, slot: override.slot, reason: 'invalid_value' });
1487
+
1488
+ return;
1489
+ }
1490
+
1491
+ if (!definition.schema.safeParse(override.value).success)
1492
+ issues.push({ index, slot: override.slot, reason: 'invalid_value' });
1493
+ });
1494
+
1495
+ return issues;
1496
+ }
1497
+
1498
+ /**
1499
+ * Apply one override to a base value.
1500
+ *
1501
+ * Every branch is total: an operation that cannot be carried out on the shape it was given returns
1502
+ * the base untouched. A test that does nothing is a disappointment; a test that throws inside a
1503
+ * component's render path is an outage, and the two are not worth trading against each other.
1504
+ */
1505
+ export function mvtApplyOverride<T>(base: T, override: MvtOverride): T {
1506
+ const value = override.value;
1507
+
1508
+ switch (override.op) {
1509
+ case 'set':
1510
+ return (value as T);
1511
+
1512
+ case 'merge':
1513
+ if (base === null || typeof base !== 'object' || Array.isArray(base)) return base;
1514
+ if (value === null || typeof value !== 'object') return base;
1515
+
1516
+ return { ...(base as object), ...(value as object) } as T;
1517
+
1518
+ case 'remove': {
1519
+ if (!Array.isArray(base)) return base;
1520
+ const key = override.match?.by;
1521
+ const equals = override.match?.equals;
1522
+
1523
+ if (!key) return base;
1524
+
1525
+ return (base as unknown[]).filter(item => String((item as Record<string, unknown>)?.[key] ?? item) !== equals) as T;
1526
+ }
1527
+
1528
+ case 'reorder': {
1529
+ if (!Array.isArray(base) || !Array.isArray(value)) return base;
1530
+ const key = override.match?.by;
1531
+ const identity = (item: unknown) => key ? String((item as Record<string, unknown>)?.[key] ?? '') : String(item);
1532
+ const wanted = (value as unknown[]).map(String);
1533
+ const known = (base as unknown[]).filter(item => wanted.includes(identity(item)));
1534
+ const rest = (base as unknown[]).filter(item => !wanted.includes(identity(item)));
1535
+
1536
+ known.sort((a, b) => wanted.indexOf(identity(a)) - wanted.indexOf(identity(b)));
1537
+
1538
+ return [...known, ...rest] as T;
1539
+ }
1540
+
1541
+ case 'insert': {
1542
+ if (!Array.isArray(base) || value === undefined) return base;
1543
+ const next = [...(base as unknown[])];
1544
+ const at = Math.min(override.at ?? next.length, next.length);
1545
+
1546
+ next.splice(at, 0, value);
1547
+
1548
+ return next as T;
1549
+ }
1550
+
1551
+ case 'patch': {
1552
+ if (!Array.isArray(base) || value === null || typeof value !== 'object') return base;
1553
+ const key = override.match?.by;
1554
+ const equals = override.match?.equals;
1555
+
1556
+ if (!key) return base;
1557
+
1558
+ return (base as unknown[]).map(item => {
1559
+ const current = item as Record<string, unknown>;
1560
+
1561
+ if (String(current?.[key] ?? '') !== equals) return item;
1562
+
1563
+ return { ...current, ...(value as object) };
1564
+ }) as T;
1565
+ }
1566
+
1567
+ default:
1568
+ return base;
1569
+ }
1570
+ }
1571
+
1572
+ // -----------------------------------------------------------------------------
1573
+ // Anchors
1574
+ // -----------------------------------------------------------------------------
1575
+
1576
+ export const MVT_ANCHOR_ATTRIBUTE = 'data-mvt-anchor';
1577
+
1578
+ /**
1579
+ * The anchors slot for one scope.
1580
+ *
1581
+ * Kept as a lookup rather than a naming convention the call sites repeat, because the studio, the
1582
+ * API validator and the two fronts all have to agree on it, and a typo in any of the four would
1583
+ * produce an override that validates and does nothing.
1584
+ */
1585
+ export function mvtAnchorsSlot(scope: MvtScope): string {
1586
+ return scope === '_Checkout_' ? 'checkout.anchors' : 'storefront.anchors';
1587
+ }
1588
+
1589
+ /**
1590
+ * Fold a stack of anchor patches into one.
1591
+ *
1592
+ * Later patches win field by field rather than wholesale: two overrides touching the same element,
1593
+ * one hiding it and one rewording it, both have to survive, or the operator has to remember which
1594
+ * of their own edits cancelled which.
1595
+ */
1596
+ export function mvtMergeAnchorPatch(base: MvtAnchorPatch | undefined, next: MvtAnchorPatch | undefined): MvtAnchorPatch {
1597
+ if (!base) return next ?? {};
1598
+ if (!next) return base;
1599
+
1600
+ return {
1601
+ ...base,
1602
+ ...next,
1603
+ text: next.text || base.text ? { ...(base.text ?? {}), ...(next.text ?? {}) } : undefined,
1604
+ style: next.style || base.style ? { ...(base.style ?? {}), ...(next.style ?? {}) } : undefined,
1605
+ };
1606
+ }
1607
+
1608
+ /**
1609
+ * The inline style an anchor patch asks for, as a plain property bag.
1610
+ *
1611
+ * Only the closed set the patch schema allows can come out of here, which is the point: a front
1612
+ * applying this is not evaluating anything an operator wrote, it is reading a handful of numbers
1613
+ * and colours that have already been validated twice.
1614
+ */
1615
+ export function mvtAnchorStyle(patch: MvtAnchorPatch | null | undefined): Record<string, string> {
1616
+ const style = patch?.style;
1617
+
1618
+ if (!style) return {};
1619
+
1620
+ const out: Record<string, string> = {};
1621
+
1622
+ if (style.background) out['background-color'] = style.background;
1623
+ if (style.color) out['color'] = style.color;
1624
+ if (style.borderColor) out['border-color'] = style.borderColor;
1625
+ if (style.borderRadius !== undefined) out['border-radius'] = `${style.borderRadius}px`;
1626
+ if (style.fontSize !== undefined) out['font-size'] = `${style.fontSize}px`;
1627
+ if (style.fontWeight) out['font-weight'] = style.fontWeight;
1628
+ if (style.align) out['text-align'] = style.align;
1629
+ if (style.paddingY !== undefined) out['padding-block'] = `${style.paddingY}px`;
1630
+ if (style.paddingX !== undefined) out['padding-inline'] = `${style.paddingX}px`;
1631
+
1632
+ return out;
1633
+ }
1634
+
1635
+ // -----------------------------------------------------------------------------
1636
+ // Studio bridge
1637
+ // -----------------------------------------------------------------------------
1638
+
1639
+ /**
1640
+ * The message vocabulary between the Tools studio and the front rendered inside its frame.
1641
+ *
1642
+ * A separate protocol rather than a preview link with more query parameters, because the studio
1643
+ * has to do two things a link cannot: read back what is actually on the page - the anchors, their
1644
+ * labels, their nesting - and apply an edit without a reload, so that changing a colour is a
1645
+ * colour changing rather than a page blinking.
1646
+ *
1647
+ * Every message carries the protocol version. A front and a Tools release drift apart eventually,
1648
+ * and a version mismatch has to degrade to a plain preview rather than to an editor whose clicks
1649
+ * quietly do nothing.
1650
+ */
1651
+ export const MVT_STUDIO_PROTOCOL = 1;
1652
+
1653
+ export type MvtStudioAnchorRole = 'section' | 'cta' | 'text' | 'image' | 'block';
1654
+
1655
+ export interface MvtStudioAnchor {
1656
+ /** Stable identifier written into the markup by the front, never derived from the DOM position. */
1657
+ id: string;
1658
+ label: string;
1659
+ role: MvtStudioAnchorRole;
1660
+ /** The anchor this one sits inside, for the studio's tree. Null at the top level. */
1661
+ parentId: string | null;
1662
+ order: number;
1663
+ /** What the untouched page shows today, so the editor opens on the real copy rather than an empty field. */
1664
+ text: string | null;
1665
+ movable: boolean;
1666
+ hideable: boolean;
1667
+ rect: { top: number; left: number; width: number; height: number };
1668
+ }
1669
+
1670
+ /** Tools to front. */
1671
+ export type MvtStudioRequest =
1672
+ | { protocol: number; type: 'studio:hello' }
1673
+ | { protocol: number; type: 'studio:scan' }
1674
+ | { protocol: number; type: 'studio:select'; anchorId: string | null }
1675
+ | { protocol: number; type: 'studio:apply'; anchors: Record<string, MvtAnchorPatch>; lang: string }
1676
+ | { protocol: number; type: 'studio:mode'; picking: boolean }
1677
+ | { protocol: number; type: 'studio:navigate'; path: string };
1678
+
1679
+ /** Front to Tools. */
1680
+ export type MvtStudioEvent =
1681
+ | { protocol: number; type: 'studio:ready'; scope: MvtScope; path: string; langs: string[] }
1682
+ | { protocol: number; type: 'studio:anchors'; anchors: MvtStudioAnchor[]; path: string }
1683
+ | { protocol: number; type: 'studio:picked'; anchor: MvtStudioAnchor }
1684
+ | { protocol: number; type: 'studio:navigated'; path: string };
1685
+
1686
+ export function mvtIsStudioEvent(data: unknown): data is MvtStudioEvent {
1687
+ if (!data || typeof data !== 'object') return false;
1688
+
1689
+ const message = data as Partial<MvtStudioEvent>;
1690
+
1691
+ return message.protocol === MVT_STUDIO_PROTOCOL && typeof message.type === 'string' && message.type.startsWith('studio:');
1692
+ }
1693
+
1694
+ export function mvtIsStudioRequest(data: unknown): data is MvtStudioRequest {
1695
+ return mvtIsStudioEvent(data);
1696
+ }