webhanger 1.0.8 → 1.0.9

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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * WebHanger Personalization Engine
3
+ * Resolves which component variant to serve based on rules.
4
+ *
5
+ * Rules are evaluated top-to-bottom — first match wins.
6
+ * Falls back to "default" if no rule matches.
7
+ */
8
+
9
+ // ─── Context builders ─────────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Build the personalization context from available signals.
13
+ * Works in both browser and Node.js (edge worker).
14
+ */
15
+ export function buildContext(options = {}) {
16
+ const ctx = {};
17
+
18
+ // Country — from CF header (edge) or options
19
+ ctx.country = options.country || (typeof navigator === "undefined" ? null : null);
20
+
21
+ // Device — from User-Agent
22
+ const ua = options.userAgent ||
23
+ (typeof navigator !== "undefined" ? navigator.userAgent : "");
24
+ ctx.device = detectDevice(ua);
25
+
26
+ // Language — from browser or options
27
+ ctx.lang = options.lang ||
28
+ (typeof navigator !== "undefined" ? (navigator.language || "").split("-")[0] : "en");
29
+
30
+ // Role — from JWT token (WHAuth) or options
31
+ ctx.role = options.role || getStoredRole();
32
+
33
+ // A/B test bucket — stable per user, stored in localStorage
34
+ ctx.abTest = options.abTest || getABBucket();
35
+
36
+ // Hour of day (0-23) for time-based rules
37
+ ctx.hour = options.hour !== undefined ? options.hour : new Date().getHours();
38
+
39
+ // Subscription plan — from options or localStorage
40
+ ctx.plan = options.plan ||
41
+ (typeof localStorage !== "undefined" ? localStorage.getItem("wh_plan") || "free" : "free");
42
+
43
+ // Custom context — merge any extra fields
44
+ if (options.custom) Object.assign(ctx, options.custom);
45
+
46
+ return ctx;
47
+ }
48
+
49
+ function detectDevice(ua) {
50
+ if (!ua) return "desktop";
51
+ if (/tablet|ipad/i.test(ua)) return "tablet";
52
+ if (/mobile|android|iphone|ipod|blackberry|windows phone/i.test(ua)) return "mobile";
53
+ return "desktop";
54
+ }
55
+
56
+ function getStoredRole() {
57
+ if (typeof localStorage === "undefined") return null;
58
+ try {
59
+ const token = localStorage.getItem("wh_auth_token");
60
+ if (!token) return null;
61
+ const payload = JSON.parse(atob(token.split(".")[1]));
62
+ return payload.role || null;
63
+ } catch (_) { return null; }
64
+ }
65
+
66
+ function getABBucket() {
67
+ if (typeof localStorage === "undefined") return "variant-a";
68
+ let bucket = localStorage.getItem("wh_ab_bucket");
69
+ if (!bucket) {
70
+ bucket = Math.random() < 0.5 ? "variant-a" : "variant-b";
71
+ localStorage.setItem("wh_ab_bucket", bucket);
72
+ }
73
+ return bucket;
74
+ }
75
+
76
+ // ─── Rule evaluator ───────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Evaluate a single rule condition against the context.
80
+ */
81
+ function matchesCondition(condition, ctx) {
82
+ for (const [key, value] of Object.entries(condition)) {
83
+ const ctxVal = ctx[key];
84
+
85
+ if (Array.isArray(value)) {
86
+ // Array = any of these values
87
+ if (!value.includes(ctxVal)) return false;
88
+ } else if (typeof value === "object" && value !== null) {
89
+ // Range: { min: 9, max: 17 }
90
+ if (value.min !== undefined && ctxVal < value.min) return false;
91
+ if (value.max !== undefined && ctxVal > value.max) return false;
92
+ } else {
93
+ // Exact match (case-insensitive for strings)
94
+ const a = typeof ctxVal === "string" ? ctxVal.toLowerCase() : ctxVal;
95
+ const b = typeof value === "string" ? value.toLowerCase() : value;
96
+ if (a !== b) return false;
97
+ }
98
+ }
99
+ return true;
100
+ }
101
+
102
+ /**
103
+ * Resolve which component to load for a slot based on rules + context.
104
+ *
105
+ * @param {string} slotName - e.g. "hero"
106
+ * @param {object} rules - rules config for this slot
107
+ * @param {object} ctx - personalization context
108
+ * @returns {string} - resolved component name
109
+ */
110
+ export function resolveComponent(slotName, rules, ctx) {
111
+ if (!rules || !rules.rules) return rules?.default || slotName;
112
+
113
+ for (const rule of rules.rules) {
114
+ if (matchesCondition(rule.if || {}, ctx)) {
115
+ return rule.component;
116
+ }
117
+ }
118
+
119
+ return rules.default || slotName;
120
+ }
121
+
122
+ /**
123
+ * Resolve all slots in a personalization config.
124
+ *
125
+ * @param {object} config - full wh-personalization.json
126
+ * @param {object} options - context overrides
127
+ * @returns {object} - { slotName: resolvedComponentName }
128
+ */
129
+ export function resolveAll(config, options = {}) {
130
+ const ctx = buildContext(options);
131
+ const resolved = {};
132
+
133
+ for (const [slot, rules] of Object.entries(config)) {
134
+ resolved[slot] = resolveComponent(slot, rules, ctx);
135
+ }
136
+
137
+ return { resolved, ctx };
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webhanger",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Component-as-a-Service platform — bundle, sign, and deliver UI components via edge CDN",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -35,7 +35,9 @@
35
35
  "chalk": "^5.6.2",
36
36
  "firebase-admin": "^12.0.0",
37
37
  "fs-extra": "^11.3.4",
38
- "inquirer": "^13.4.1"
38
+ "inquirer": "^13.4.1",
39
+ "webhanger-admin": "^1.0.0",
40
+ "webhanger-auth": "^1.0.0"
39
41
  },
40
42
  "devDependencies": {
41
43
  "terser": "^5.46.1"