Skip to content

Commit

Permalink
feat(react-gen1): support block level personalization and scheduling (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
teleaziz committed Sep 13, 2024
1 parent 81e57d5 commit 1118b05
Show file tree
Hide file tree
Showing 13 changed files with 1,281 additions and 20 deletions.
6 changes: 6 additions & 0 deletions .changeset/dirty-pumpkins-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@builder.io/react": patch
"@builder.io/sdk": patch
---

Add built-in personalization container to suppoert block level personalization
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@builder.io/sdk",
"version": "3.0.1",
"version": "3.0.2-2",
"unpkg": "./dist/index.browser.js",
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/builder.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { Animator } from './classes/animator.class';
import { BuilderElement } from './types/element';
import Cookies from './classes/cookies.class';
import { omit } from './functions/omit.function';
import { getTopLevelDomain } from './functions/get-top-level-domain';
import { BuilderContent } from './types/content';
import { uuid } from './functions/uuid';
import { parse as urlParse } from './url';
Expand Down Expand Up @@ -118,7 +117,6 @@ function setCookie(name: string, value: string, expires?: Date) {
(value || '') +
expiresString +
'; path=/' +
`; domain=${getTopLevelDomain(location.hostname)}` +
(secure ? '; secure; SameSite=None' : '');
} catch (err) {
console.warn('Could not set cookie', err);
Expand Down Expand Up @@ -558,6 +556,8 @@ export interface Input {
/** @hidden */
imageHeight?: number;
/** @hidden */
behavior?: string;
/** @hidden */
imageWidth?: number;
/** @hidden */
mediaHeight?: number;
Expand Down Expand Up @@ -899,6 +899,7 @@ export class Builder {
static actions: Action[] = [];
static registry: { [key: string]: any[] } = {};
static overrideHost: string | undefined;
static attributesCookieName = 'builder.userAttributes';

/**
* @todo `key` property on any info where if a key matches a current
Expand Down Expand Up @@ -1705,6 +1706,17 @@ export class Builder {
this.setTestsFromUrl();
// TODO: do this on every request send?
this.getOverridesFromQueryString();

// cookies used in personalization container script, so need to set before hydration to match script result
const userAttrCookie = this.getCookie(Builder.attributesCookieName);
if (userAttrCookie) {
try {
const attributes = JSON.parse(userAttrCookie);
this.setUserAttributes(attributes);
} catch (err) {
console.debug('Error parsing user attributes cookie', err);
}
}
}
}

Expand Down Expand Up @@ -2147,6 +2159,10 @@ export class Builder {

setUserAttributes(options: object) {
assign(Builder.overrideUserAttributes, options);
if (this.canTrack) {
this.setCookie(Builder.attributesCookieName, JSON.stringify(this.getUserAttributes()));
}

this.userAttributesChanged.next(options);
}

Expand Down
3 changes: 0 additions & 3 deletions packages/core/src/classes/cookies.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;

import { IncomingMessage, ServerResponse } from 'http';
import { getTopLevelDomain } from '../functions/get-top-level-domain';
interface Options {
secure?: boolean;
expires?: Date;
Expand Down Expand Up @@ -61,8 +60,6 @@ class Cookies {
cookie.secure = !!opts.secure;
}

cookie.domain = req.headers.host && getTopLevelDomain(req.headers.host);

pushCookie(headers, cookie);

const setHeader = res.setHeader;
Expand Down
12 changes: 0 additions & 12 deletions packages/core/src/functions/get-top-level-domain.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@builder.io/react",
"version": "5.0.1",
"version": "5.0.2-20",
"description": "",
"keywords": [],
"main": "dist/builder-react.cjs.js",
Expand Down
257 changes: 257 additions & 0 deletions packages/react/src/blocks/PersonalizationContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import React from 'react';
import { Builder, builder, BuilderElement } from '@builder.io/sdk';
import { useEffect, useState } from 'react';
import { BuilderBlocks } from '../components/builder-blocks.component';
import {
filterWithCustomTargeting,
filterWithCustomTargetingScript,
Query,
} from '../functions/filter-with-custom-targeting';

export type PersonalizationContainerProps = {
children: React.ReactNode;
previewingIndex: number | null;
builderBlock?: BuilderElement;
variants?: [
{
query: Query[];
startDate?: string;
endDate?: string;
blocks: BuilderElement[];
},
];
attributes: any;
};

export function PersonalizationContainer(props: PersonalizationContainerProps) {
const isBeingHydrated = Boolean(
Builder.isBrowser && (window as any).__hydrated?.[props.builderBlock?.id!]
);
const [isClient, setIsClient] = useState(isBeingHydrated);
const [update, setUpdate] = useState(0);

useEffect(() => {
setIsClient(true);
const subscriber = builder.userAttributesChanged.subscribe(() => {
setUpdate(update + 1);
});
return () => {
subscriber.unsubscribe();
};
}, []);

if (Builder.isServer) {
return (
<React.Fragment>
<div
{...props.attributes}
// same as the client side styles for hydration matching
style={{
opacity: 1,
transition: 'opacity 0.2s ease-in-out',
...props.attributes?.style,
}}
className={`builder-personalization-container ${props.attributes.className}`}
>
{props.variants?.map((variant, index) => (
<template key={index} data-variant-id={props.builderBlock?.id! + index}>
<BuilderBlocks
blocks={variant.blocks}
parentElementId={props.builderBlock?.id}
dataPath={`component.options.variants.${index}.blocks`}
child
/>
</template>
))}
<script
id={`variants-script-${props.builderBlock?.id}`}
dangerouslySetInnerHTML={{
__html: getPersonalizationScript(props.variants, props.builderBlock?.id),
}}
/>
<BuilderBlocks
blocks={props.builderBlock?.children}
parentElementId={props.builderBlock?.id}
dataPath="this.children"
child
/>
</div>
<script
dangerouslySetInnerHTML={{
__html: `
window.__hydrated = window.__hydrated || {};
window.__hydrated['${props.builderBlock?.id}'] = true;
`.replace(/\s+/g, ' '),
}}
/>
</React.Fragment>
);
}

const filteredVariants = (props.variants || []).filter(variant => {
return filterWithCustomTargeting(
builder.getUserAttributes(),
variant.query,
variant.startDate,
variant.endDate
);
});

return (
<React.Fragment>
<div
{...props.attributes}
style={{
opacity: isClient ? 1 : 0,
transition: 'opacity 0.2s ease-in-out',
...props.attributes?.style,
}}
className={`builder-personalization-container ${
props.attributes.className
} ${isClient ? '' : 'builder-personalization-container-loading'}`}
>
{/* If editing a specific varient */}
{Builder.isEditing &&
typeof props.previewingIndex === 'number' &&
props.previewingIndex < (props.variants?.length || 0) ? (
<BuilderBlocks
blocks={props.variants?.[props.previewingIndex]?.blocks}
parentElementId={props.builderBlock?.id}
dataPath={`component.options.variants.${props.previewingIndex}.blocks`}
child
/>
) : // If editing the default or we're on the server and there are no matching variants show the default
(Builder.isEditing && typeof props.previewingIndex !== 'number') ||
!isClient ||
!filteredVariants.length ? (
<BuilderBlocks
blocks={props.builderBlock?.children}
parentElementId={props.builderBlock?.id}
dataPath="this.children"
child
/>
) : (
// Show the variant matching the current user attributes
<BuilderBlocks
blocks={filteredVariants[0]?.blocks}
parentElementId={props.builderBlock?.id}
dataPath={`component.options.variants.${props.variants?.indexOf(
filteredVariants[0]
)}.blocks`}
child
/>
)}
</div>
<script
dangerouslySetInnerHTML={{
__html: `
window.__hydrated = window.__hydrated || {};
window.__hydrated['${props.builderBlock?.id}'] = true;
`.replace(/\s+/g, ' '),
}}
/>
</React.Fragment>
);
}

Builder.registerComponent(PersonalizationContainer, {
name: 'PersonalizationContainer',
noWrap: true,
image:
'https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F37229ed30d8c41dfb10b8cca1992053a',
canHaveChildren: true,
inputs: [
{
name: 'variants',
defaultValue: [],
behavior: 'personalizationVariantList',
type: 'list',
subFields: [
{
name: 'name',
type: 'text',
},
{
name: 'query',
friendlyName: 'Targeting rules',
type: 'BuilderQuery',
defaultValue: [],
},
{
name: 'startDate',
type: 'date',
},
{
name: 'endDate',
type: 'date',
},
{
name: 'blocks',
type: 'UiBlocks',
hideFromUI: true,
defaultValue: [],
},
],
},
],
});

function getPersonalizationScript(
variants: PersonalizationContainerProps['variants'],
blockId?: string
) {
return `
(function() {
function getCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(';');
for(var i=0;i < ca.length;i++) {
var c = ca[i];
while (c.charAt(0)==' ') c = c.substring(1,c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
}
return null;
}
function removeVariants() {
variants.forEach(function (template, index) {
document.querySelector('template[data-variant-id="' + "${blockId}" + index + '"]').remove();
});
document.getElementById('variants-script-${blockId}').remove();
}
var attributes = JSON.parse(getCookie("${Builder.attributesCookieName}") || "{}");
var variants = ${JSON.stringify(variants?.map(v => ({ query: v.query, startDate: v.startDate, endDate: v.endDate })))};
var winningVariantIndex = variants.findIndex(function(variant) {
return filterWithCustomTargeting(
attributes,
variant.query,
variant.startDate,
variant.endDate
);
});
var isDebug = location.href.includes('builder.debug=true');
if (isDebug) {
console.debug('PersonalizationContainer', {
attributes: attributes,
variants: variants,
winningVariantIndex: winningVariantIndex,
});
}
if (winningVariantIndex !== -1) {
var winningVariant = document.querySelector('template[data-variant-id="' + "${blockId}" + winningVariantIndex + '"]');
if (winningVariant) {
var parentNode = winningVariant.parentNode;
var newParent = parentNode.cloneNode(false);
newParent.appendChild(winningVariant.content.firstChild);
parentNode.parentNode.replaceChild(newParent, parentNode);
if (isDebug) {
console.debug('PersonalizationContainer', 'Winning variant Replaced:', winningVariant);
}
}
} else if (variants.length > 0) {
removeVariants();
}
${filterWithCustomTargetingScript}
})();
`.replace(/\s+/g, ' ');
}
1 change: 1 addition & 0 deletions packages/react/src/builder-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export { FormSelect } from './blocks/forms/Select'; // advanced?
export { TextArea } from './blocks/forms/TextArea';
export { Img } from './blocks/raw/Img';
export { RawText } from './blocks/raw/RawText';
export { PersonalizationContainer } from './blocks/PersonalizationContainer';

export { stringToFunction } from './functions/string-to-function';
export { useIsPreviewing } from './hooks/useIsPreviewing';
Expand Down
Loading

0 comments on commit 1118b05

Please sign in to comment.