@builder.io/react 5.0.2-0 → 5.0.2-11

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