@builder.io/react 5.0.2-2 → 5.0.2-20

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.
@@ -2,9 +2,11 @@ import React from 'react';
2
2
  import { Builder, builder, BuilderElement } from '@builder.io/sdk';
3
3
  import { useEffect, useState } from 'react';
4
4
  import { BuilderBlocks } from '../components/builder-blocks.component';
5
-
6
- type UserAttributes = any;
7
- type Query = any;
5
+ import {
6
+ filterWithCustomTargeting,
7
+ filterWithCustomTargetingScript,
8
+ Query,
9
+ } from '../functions/filter-with-custom-targeting';
8
10
 
9
11
  export type PersonalizationContainerProps = {
10
12
  children: React.ReactNode;
@@ -21,15 +23,15 @@ export type PersonalizationContainerProps = {
21
23
  attributes: any;
22
24
  };
23
25
 
24
- function PersonalizationContainer(props: PersonalizationContainerProps) {
25
- const [isClient, setIsClient] = useState(false);
26
+ export function PersonalizationContainer(props: PersonalizationContainerProps) {
27
+ const isBeingHydrated = Boolean(
28
+ Builder.isBrowser && (window as any).__hydrated?.[props.builderBlock?.id!]
29
+ );
30
+ const [isClient, setIsClient] = useState(isBeingHydrated);
26
31
  const [update, setUpdate] = useState(0);
27
32
 
28
33
  useEffect(() => {
29
34
  setIsClient(true);
30
- }, []);
31
-
32
- useEffect(() => {
33
35
  const subscriber = builder.userAttributesChanged.subscribe(() => {
34
36
  setUpdate(update + 1);
35
37
  });
@@ -38,6 +40,54 @@ function PersonalizationContainer(props: PersonalizationContainerProps) {
38
40
  };
39
41
  }, []);
40
42
 
43
+ if (Builder.isServer) {
44
+ return (
45
+ <React.Fragment>
46
+ <div
47
+ {...props.attributes}
48
+ // same as the client side styles for hydration matching
49
+ style={{
50
+ opacity: 1,
51
+ transition: 'opacity 0.2s ease-in-out',
52
+ ...props.attributes?.style,
53
+ }}
54
+ className={`builder-personalization-container ${props.attributes.className}`}
55
+ >
56
+ {props.variants?.map((variant, index) => (
57
+ <template key={index} data-variant-id={props.builderBlock?.id! + index}>
58
+ <BuilderBlocks
59
+ blocks={variant.blocks}
60
+ parentElementId={props.builderBlock?.id}
61
+ dataPath={`component.options.variants.${index}.blocks`}
62
+ child
63
+ />
64
+ </template>
65
+ ))}
66
+ <script
67
+ id={`variants-script-${props.builderBlock?.id}`}
68
+ dangerouslySetInnerHTML={{
69
+ __html: getPersonalizationScript(props.variants, props.builderBlock?.id),
70
+ }}
71
+ />
72
+ <BuilderBlocks
73
+ blocks={props.builderBlock?.children}
74
+ parentElementId={props.builderBlock?.id}
75
+ dataPath="this.children"
76
+ child
77
+ />
78
+ </div>
79
+ <script
80
+ dangerouslySetInnerHTML={{
81
+ __html: `
82
+ window.__hydrated = window.__hydrated || {};
83
+ window.__hydrated['${props.builderBlock?.id}'] = true;
84
+ `.replace(/\s+/g, ' '),
85
+ }}
86
+ />
87
+ </React.Fragment>
88
+ );
89
+ }
90
+
41
91
  const filteredVariants = (props.variants || []).filter(variant => {
42
92
  return filterWithCustomTargeting(
43
93
  builder.getUserAttributes(),
@@ -48,154 +98,62 @@ function PersonalizationContainer(props: PersonalizationContainerProps) {
48
98
  });
49
99
 
50
100
  return (
51
- <div
52
- style={{
53
- opacity: isClient ? 1 : 0,
54
- transition: 'opacity 0.2s ease-in-out',
55
- ...props.attributes,
56
- }}
57
- {...props.attributes}
58
- className={`builder-personalization-container ${
59
- props.attributes.className
60
- } ${isClient ? '' : 'builder-personalization-container-loading'}`}
61
- >
62
- {/* If editing a specific varient */}
63
- {Builder.isEditing &&
64
- typeof props.previewingIndex === 'number' &&
65
- props.previewingIndex < (props.variants?.length || 0) ? (
66
- <BuilderBlocks
67
- blocks={props.variants?.[props.previewingIndex]?.blocks}
68
- parentElementId={props.builderBlock?.id}
69
- dataPath={`component.options.variants.${props.previewingIndex}.blocks`}
70
- child
71
- />
72
- ) : // If editing the default or we're on the server and there are no matching variants show the default
73
- (Builder.isEditing && typeof props.previewingIndex !== 'number') ||
74
- !isClient ||
75
- !filteredVariants.length ? (
76
- <BuilderBlocks
77
- blocks={props.builderBlock?.children}
78
- parentElementId={props.builderBlock?.id}
79
- dataPath="this.children"
80
- child
81
- />
82
- ) : (
83
- // Show the variant matching the current user attributes
84
- <BuilderBlocks
85
- blocks={filteredVariants[0]?.blocks}
86
- parentElementId={props.builderBlock?.id}
87
- dataPath={`component.options.variants.${props.variants?.indexOf(
88
- filteredVariants[0]
89
- )}.blocks`}
90
- child
91
- />
92
- )}
93
- </div>
101
+ <React.Fragment>
102
+ <div
103
+ {...props.attributes}
104
+ style={{
105
+ opacity: isClient ? 1 : 0,
106
+ transition: 'opacity 0.2s ease-in-out',
107
+ ...props.attributes?.style,
108
+ }}
109
+ className={`builder-personalization-container ${
110
+ props.attributes.className
111
+ } ${isClient ? '' : 'builder-personalization-container-loading'}`}
112
+ >
113
+ {/* If editing a specific varient */}
114
+ {Builder.isEditing &&
115
+ typeof props.previewingIndex === 'number' &&
116
+ props.previewingIndex < (props.variants?.length || 0) ? (
117
+ <BuilderBlocks
118
+ blocks={props.variants?.[props.previewingIndex]?.blocks}
119
+ parentElementId={props.builderBlock?.id}
120
+ dataPath={`component.options.variants.${props.previewingIndex}.blocks`}
121
+ child
122
+ />
123
+ ) : // If editing the default or we're on the server and there are no matching variants show the default
124
+ (Builder.isEditing && typeof props.previewingIndex !== 'number') ||
125
+ !isClient ||
126
+ !filteredVariants.length ? (
127
+ <BuilderBlocks
128
+ blocks={props.builderBlock?.children}
129
+ parentElementId={props.builderBlock?.id}
130
+ dataPath="this.children"
131
+ child
132
+ />
133
+ ) : (
134
+ // Show the variant matching the current user attributes
135
+ <BuilderBlocks
136
+ blocks={filteredVariants[0]?.blocks}
137
+ parentElementId={props.builderBlock?.id}
138
+ dataPath={`component.options.variants.${props.variants?.indexOf(
139
+ filteredVariants[0]
140
+ )}.blocks`}
141
+ child
142
+ />
143
+ )}
144
+ </div>
145
+ <script
146
+ dangerouslySetInnerHTML={{
147
+ __html: `
148
+ window.__hydrated = window.__hydrated || {};
149
+ window.__hydrated['${props.builderBlock?.id}'] = true;
150
+ `.replace(/\s+/g, ' '),
151
+ }}
152
+ />
153
+ </React.Fragment>
94
154
  );
95
155
  }
96
156
 
97
- export default PersonalizationContainer;
98
-
99
- export function filterWithCustomTargeting(
100
- userAttributes: UserAttributes,
101
- query: Query[],
102
- startDate?: string,
103
- endDate?: string
104
- ) {
105
- const item = {
106
- query,
107
- startDate,
108
- endDate,
109
- };
110
-
111
- const now = (userAttributes.date && new Date(userAttributes.date)) || new Date();
112
-
113
- if (item.startDate && new Date(item.startDate) > now) {
114
- return false;
115
- } else if (item.endDate && new Date(item.endDate) < now) {
116
- return false;
117
- }
118
-
119
- if (!item.query || !item.query.length) {
120
- return true;
121
- }
122
-
123
- return item.query.every((filter: Query) => {
124
- if (
125
- filter &&
126
- filter.property === 'urlPath' &&
127
- filter.value &&
128
- typeof filter.value === 'string' &&
129
- filter.value !== '/' &&
130
- filter.value.endsWith('/')
131
- ) {
132
- filter.value = filter.value.slice(0, -1);
133
- }
134
- return objectMatchesQuery(userAttributes, filter);
135
- });
136
-
137
- function isNumber(val: unknown) {
138
- return typeof val === 'number';
139
- }
140
-
141
- function isString(val: unknown) {
142
- return typeof val === 'string';
143
- }
144
-
145
- function objectMatchesQuery(userattr: UserAttributes, query: Query): boolean {
146
- const result = (() => {
147
- const property = query.property;
148
- const operator = query.operator;
149
- const testValue = query.value;
150
-
151
- // Check is query property is present in userAttributes. Proceed only if it is present.
152
- if (!(property && operator)) {
153
- return true;
154
- }
155
-
156
- if (Array.isArray(testValue)) {
157
- if (operator === 'isNot') {
158
- return testValue.every(val =>
159
- objectMatchesQuery(userattr, { property, operator, value: val })
160
- );
161
- }
162
- return !!testValue.find(val =>
163
- objectMatchesQuery(userattr, { property, operator, value: val })
164
- );
165
- }
166
- const value = userattr[property];
167
-
168
- if (Array.isArray(value)) {
169
- return value.includes(testValue);
170
- }
171
-
172
- switch (operator) {
173
- case 'is':
174
- return value === testValue;
175
- case 'isNot':
176
- return value !== testValue;
177
- case 'contains':
178
- return (isString(value) || Array.isArray(value)) && value.includes(testValue);
179
- case 'startsWith':
180
- return isString(value) && value.startsWith(testValue);
181
- case 'endsWith':
182
- return isString(value) && value.endsWith(testValue);
183
- case 'greaterThan':
184
- return isNumber(value) && isNumber(testValue) && value > testValue;
185
- case 'lessThan':
186
- return isNumber(value) && isNumber(testValue) && value < testValue;
187
- case 'greaterThanOrEqualTo':
188
- return isNumber(value) && isNumber(testValue) && value >= testValue;
189
- case 'lessThanOrEqualTo':
190
- return isNumber(value) && isNumber(testValue) && value <= testValue;
191
- }
192
- return false;
193
- })();
194
-
195
- return result;
196
- }
197
- }
198
-
199
157
  Builder.registerComponent(PersonalizationContainer, {
200
158
  name: 'PersonalizationContainer',
201
159
  noWrap: true,
@@ -214,7 +172,8 @@ Builder.registerComponent(PersonalizationContainer, {
214
172
  type: 'text',
215
173
  },
216
174
  {
217
- name: 'variants',
175
+ name: 'query',
176
+ friendlyName: 'Targeting rules',
218
177
  type: 'BuilderQuery',
219
178
  defaultValue: [],
220
179
  },
@@ -236,3 +195,63 @@ Builder.registerComponent(PersonalizationContainer, {
236
195
  },
237
196
  ],
238
197
  });
198
+
199
+ function getPersonalizationScript(
200
+ variants: PersonalizationContainerProps['variants'],
201
+ blockId?: string
202
+ ) {
203
+ return `
204
+ (function() {
205
+ function getCookie(name) {
206
+ var nameEQ = name + "=";
207
+ var ca = document.cookie.split(';');
208
+ for(var i=0;i < ca.length;i++) {
209
+ var c = ca[i];
210
+ while (c.charAt(0)==' ') c = c.substring(1,c.length);
211
+ if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
212
+ }
213
+ return null;
214
+ }
215
+ function removeVariants() {
216
+ variants.forEach(function (template, index) {
217
+ document.querySelector('template[data-variant-id="' + "${blockId}" + index + '"]').remove();
218
+ });
219
+ document.getElementById('variants-script-${blockId}').remove();
220
+ }
221
+
222
+ var attributes = JSON.parse(getCookie("${Builder.attributesCookieName}") || "{}");
223
+ var variants = ${JSON.stringify(variants?.map(v => ({ query: v.query, startDate: v.startDate, endDate: v.endDate })))};
224
+ var winningVariantIndex = variants.findIndex(function(variant) {
225
+ return filterWithCustomTargeting(
226
+ attributes,
227
+ variant.query,
228
+ variant.startDate,
229
+ variant.endDate
230
+ );
231
+ });
232
+ var isDebug = location.href.includes('builder.debug=true');
233
+ if (isDebug) {
234
+ console.debug('PersonalizationContainer', {
235
+ attributes: attributes,
236
+ variants: variants,
237
+ winningVariantIndex: winningVariantIndex,
238
+ });
239
+ }
240
+ if (winningVariantIndex !== -1) {
241
+ var winningVariant = document.querySelector('template[data-variant-id="' + "${blockId}" + winningVariantIndex + '"]');
242
+ if (winningVariant) {
243
+ var parentNode = winningVariant.parentNode;
244
+ var newParent = parentNode.cloneNode(false);
245
+ newParent.appendChild(winningVariant.content.firstChild);
246
+ parentNode.parentNode.replaceChild(newParent, parentNode);
247
+ if (isDebug) {
248
+ console.debug('PersonalizationContainer', 'Winning variant Replaced:', winningVariant);
249
+ }
250
+ }
251
+ } else if (variants.length > 0) {
252
+ removeVariants();
253
+ }
254
+ ${filterWithCustomTargetingScript}
255
+ })();
256
+ `.replace(/\s+/g, ' ');
257
+ }
@@ -51,6 +51,7 @@ export { FormSelect } from './blocks/forms/Select'; // advanced?
51
51
  export { TextArea } from './blocks/forms/TextArea';
52
52
  export { Img } from './blocks/raw/Img';
53
53
  export { RawText } from './blocks/raw/RawText';
54
+ export { PersonalizationContainer } from './blocks/PersonalizationContainer';
54
55
 
55
56
  export { stringToFunction } from './functions/string-to-function';
56
57
  export { useIsPreviewing } from './hooks/useIsPreviewing';
@@ -0,0 +1,129 @@
1
+ type UserAttributes = {
2
+ date?: string | Date;
3
+ urlPath?: string;
4
+ [key: string]: any; // Allow any other properties
5
+ };
6
+
7
+ // Query type
8
+ type QueryOperator =
9
+ | 'is'
10
+ | 'isNot'
11
+ | 'contains'
12
+ | 'startsWith'
13
+ | 'endsWith'
14
+ | 'greaterThan'
15
+ | 'lessThan'
16
+ | 'greaterThanOrEqualTo'
17
+ | 'lessThanOrEqualTo';
18
+
19
+ type QueryValue = string | number | boolean | Array<string | number | boolean>;
20
+
21
+ export type Query = {
22
+ property: string;
23
+ operator: QueryOperator;
24
+ value: QueryValue;
25
+ };
26
+
27
+ // minified version of the function need to be added to the script
28
+ export const filterWithCustomTargetingScript = `function filterWithCustomTargeting(e,t,n,r){var i={query:t,startDate:n,endDate:r},o=e.date&&new Date(e.date)||new Date;return!(i.startDate&&new Date(i.startDate)>o)&&(!(i.endDate&&new Date(i.endDate)<o)&&(!i.query||!i.query.length||i.query.every((function(t){return objectMatchesQuery(e,t)}))))}function isString(e){return"string"==typeof e}function isNumber(e){return"number"==typeof e}function objectMatchesQuery(e,t){return function(){var n=t.property,r=t.operator,i=t.value;if(t&&"urlPath"===t.property&&t.value&&"string"==typeof t.value&&"/"!==t.value&&t.value.endsWith("/")&&(i=t.value.slice(0,-1)),!n||!r)return!0;if(Array.isArray(i))return"isNot"===r?i.every((function(t){return objectMatchesQuery(e,{property:n,operator:r,value:t})})):!!i.find((function(t){return objectMatchesQuery(e,{property:n,operator:r,value:t})}));var o=e[n];if(Array.isArray(o))return o.includes(i);switch(r){case"is":return o===i;case"isNot":return o!==i;case"contains":return(isString(o)||Array.isArray(o))&&o.includes(String(i));case"startsWith":return isString(o)&&o.startsWith(String(i));case"endsWith":return isString(o)&&o.endsWith(String(i));case"greaterThan":return isNumber(o)&&isNumber(i)&&o>i;case"lessThan":return isNumber(o)&&isNumber(i)&&o<i;case"greaterThanOrEqualTo":return isNumber(o)&&isNumber(i)&&o>=i;case"lessThanOrEqualTo":return isNumber(o)&&isNumber(i)&&o<=i}return!1}()}`;
29
+
30
+ export function filterWithCustomTargeting(
31
+ userAttributes: UserAttributes,
32
+ query: Query[],
33
+ startDate?: string,
34
+ endDate?: string
35
+ ) {
36
+ const item = {
37
+ query,
38
+ startDate,
39
+ endDate,
40
+ };
41
+
42
+ const now = (userAttributes.date && new Date(userAttributes.date)) || new Date();
43
+
44
+ if (item.startDate && new Date(item.startDate) > now) {
45
+ return false;
46
+ } else if (item.endDate && new Date(item.endDate) < now) {
47
+ return false;
48
+ }
49
+
50
+ if (!item.query || !item.query.length) {
51
+ return true;
52
+ }
53
+
54
+ return item.query.every((filter: Query) => {
55
+ return objectMatchesQuery(userAttributes, filter);
56
+ });
57
+ }
58
+
59
+ function isString(val: unknown): val is string {
60
+ return typeof val === 'string';
61
+ }
62
+
63
+ function isNumber(val: unknown): val is number {
64
+ return typeof val === 'number';
65
+ }
66
+
67
+ function objectMatchesQuery(userattr: UserAttributes, query: Query): boolean {
68
+ const result = (() => {
69
+ const property = query.property;
70
+ const operator = query.operator;
71
+ let testValue = query.value;
72
+
73
+ if (
74
+ query &&
75
+ query.property === 'urlPath' &&
76
+ query.value &&
77
+ typeof query.value === 'string' &&
78
+ query.value !== '/' &&
79
+ query.value.endsWith('/')
80
+ ) {
81
+ testValue = query.value.slice(0, -1);
82
+ }
83
+
84
+ // Check is query property is present in userAttributes. Proceed only if it is present.
85
+ if (!(property && operator)) {
86
+ return true;
87
+ }
88
+
89
+ if (Array.isArray(testValue)) {
90
+ if (operator === 'isNot') {
91
+ return testValue.every(val =>
92
+ objectMatchesQuery(userattr, { property, operator, value: val })
93
+ );
94
+ }
95
+ return !!testValue.find(val =>
96
+ objectMatchesQuery(userattr, { property, operator, value: val })
97
+ );
98
+ }
99
+ const value = userattr[property];
100
+
101
+ if (Array.isArray(value)) {
102
+ return value.includes(testValue);
103
+ }
104
+
105
+ switch (operator) {
106
+ case 'is':
107
+ return value === testValue;
108
+ case 'isNot':
109
+ return value !== testValue;
110
+ case 'contains':
111
+ return (isString(value) || Array.isArray(value)) && value.includes(String(testValue));
112
+ case 'startsWith':
113
+ return isString(value) && value.startsWith(String(testValue));
114
+ case 'endsWith':
115
+ return isString(value) && value.endsWith(String(testValue));
116
+ case 'greaterThan':
117
+ return isNumber(value) && isNumber(testValue) && value > testValue;
118
+ case 'lessThan':
119
+ return isNumber(value) && isNumber(testValue) && value < testValue;
120
+ case 'greaterThanOrEqualTo':
121
+ return isNumber(value) && isNumber(testValue) && value >= testValue;
122
+ case 'lessThanOrEqualTo':
123
+ return isNumber(value) && isNumber(testValue) && value <= testValue;
124
+ }
125
+ return false;
126
+ })();
127
+
128
+ return result;
129
+ }