├── .gitignore ├── jest.config.js ├── README.md ├── src ├── browser.ts ├── cookie-utils.ts ├── index.ts ├── async-configurator.tsx ├── get-attributes.ts ├── init-user-attributes.ts ├── url-utils.ts ├── get-personalized-rewrite.ts ├── use-context-menu.ts └── configurator.tsx ├── tsconfig.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | src/*.js 2 | src/*.d.ts 3 | node_modules 4 | dist 5 | public 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | }; 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This package has [moved](https://github.com/BuilderIO/builder/tree/main/packages/personalization-utils) 2 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | export { AsyncConfigurator } from "./async-configurator"; 2 | export { initUserAttributes } from './init-user-attributes'; 3 | -------------------------------------------------------------------------------- /src/cookie-utils.ts: -------------------------------------------------------------------------------- 1 | export const getTargetingCookies = (cookiesMap: Record) => 2 | Object.keys(cookiesMap).filter((cookie) => 3 | cookie.startsWith("builder.userAttributes") 4 | ); 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { getPersonalizedRewrite } from "./get-personalized-rewrite"; 2 | export { getTargetingCookies } from "./cookie-utils"; 3 | export { getTargetingValues, getUrlSegments } from "./url-utils"; 4 | -------------------------------------------------------------------------------- /src/async-configurator.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic'; 2 | import type {ConfiguratorProps} from './configurator' 3 | 4 | export const AsyncConfigurator = dynamic(() => import('./configurator').then(mod => mod.Configurator)); 5 | 6 | -------------------------------------------------------------------------------- /src/get-attributes.ts: -------------------------------------------------------------------------------- 1 | import { createAdminApiClient } from '@builder.io/admin-sdk'; 2 | 3 | export const getAttributes = async (privateKey: string) => { 4 | const adminSDK = createAdminApiClient(privateKey); 5 | const res = await adminSDK.query({ 6 | settings: true, 7 | }) 8 | return res.data?.settings.customTargetingAttributes; 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "dist", 5 | "module": "CommonJS", 6 | "target": "es6", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "declaration": true, 10 | "jsx": "react", 11 | "skipLibCheck": true 12 | }, 13 | "include": ["./src"], 14 | "exclude": ["node_modules", "**/*.test.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /src/init-user-attributes.ts: -------------------------------------------------------------------------------- 1 | import { Builder, builder } from "@builder.io/sdk"; 2 | import { getTargetingCookies } from "./cookie-utils"; 3 | 4 | export const initUserAttributes = (cookiesMap: Record) => { 5 | if (Builder.isBrowser) { 6 | const targeting = getTargetingCookies(cookiesMap).reduce((acc, cookie) => { 7 | const value = cookiesMap[cookie]; 8 | const key = cookie.split("builder.userAttributes.")[1]; 9 | return { 10 | ...acc, 11 | [key]: value, 12 | }; 13 | }, {}); 14 | builder.setUserAttributes(targeting); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/url-utils.ts: -------------------------------------------------------------------------------- 1 | export const getUrlSegments = (values: Record) => { 2 | const attrs = Object.keys(values); 3 | return attrs 4 | .map((attr) => `${attr}=${encodeURIComponent(values[attr])}`) 5 | .sort(); 6 | }; 7 | 8 | export const getTargetingValues = (path: string[]) : Record => { 9 | return path.sort().reduce((acc, segment) => { 10 | const [key, value] = segment.split("="); 11 | if (key) { 12 | return { 13 | ...acc, 14 | [key]: decodeURIComponent(value), 15 | }; 16 | } 17 | return acc; 18 | }, {}); 19 | }; 20 | -------------------------------------------------------------------------------- /src/get-personalized-rewrite.ts: -------------------------------------------------------------------------------- 1 | import { getUrlSegments } from "./url-utils"; 2 | import { getTargetingCookies } from "./cookie-utils"; 3 | 4 | const delimeter = ';' 5 | 6 | export const isPersonalizedPath = (path?: string) => { 7 | return path?.startsWith(delimeter) 8 | } 9 | 10 | export const getPersonalizedRewrite = ( 11 | pathname: string, 12 | cookies: Record 13 | ) => { 14 | const attributes = getTargetingCookies(cookies); 15 | const values = attributes.reduce((acc, cookie) => { 16 | const value = cookies[cookie]; 17 | const key = cookie.split("builder.userAttributes.")[1]; 18 | return { 19 | ...acc, 20 | ...(typeof value === 'string' && { [key]: value }), 21 | }; 22 | }, {}); 23 | 24 | if (Object.keys(values).length > 0) { 25 | return `/;${getUrlSegments({ 26 | urlPath: pathname, 27 | ...values, 28 | }).join(delimeter)}`; 29 | } 30 | 31 | return false; 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@builder.io/personalization-utils", 3 | "version": "0.2.0", 4 | "description": "Utils for personalization at the edge [Experimental]", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "prepublish": "npm run build", 9 | "test": "jest", 10 | "prettier": "prettier" 11 | }, 12 | "files": [ 13 | "dist/**/*", 14 | "src/**/*" 15 | ], 16 | "devDependencies": { 17 | "@builder.io/sdk": "^1.1.20", 18 | "@types/js-cookie": "^3.0.0", 19 | "@types/react": "^17.0.27", 20 | "@types/react-dom": "^17.0.9", 21 | "jest": "^26.6.3", 22 | "next": "^12", 23 | "react": "^16.14.0", 24 | "react-dom": "^16.14.0", 25 | "ts-jest": "^26.4.4", 26 | "typescript": "^4.1.3" 27 | }, 28 | "dependencies": { 29 | "@builder.io/admin-sdk": "0.0.4-0", 30 | "@szhsin/react-menu": "^2.1.0", 31 | "axios": "^0.22.0", 32 | "js-cookie": "^3.0.1", 33 | "prettier": "^2.4.1" 34 | }, 35 | "peerDependencies": { 36 | "@builder.io/sdk": "^1.1.20", 37 | "react": "^16.14.0", 38 | "react-dom": "^16.14.0", 39 | "next": "^12" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/use-context-menu.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useState } from "react"; 2 | 3 | export const useContextMenu = () => { 4 | const [x, setX] = useState(0); 5 | const [y, setY] = useState(0); 6 | const [menu, showMenu] = useState(false); 7 | const [enabled, enableContextMenu] = useState(false); 8 | const [ctrlDown, setCtrlDown] = useState(false); 9 | 10 | const handleContextMenu = useCallback( 11 | (event) => { 12 | if (!enabled || !ctrlDown) { 13 | return; 14 | } 15 | event.preventDefault(); 16 | setX(event.clientX); 17 | setY(event.clientY); 18 | showMenu(true); 19 | }, 20 | [showMenu, setX, setY, enabled, ctrlDown] 21 | ); 22 | 23 | const handleClick = useCallback(() => { 24 | showMenu(false); 25 | }, [showMenu]); 26 | 27 | const handleKeydown = useCallback( 28 | (event: KeyboardEvent) => { 29 | setCtrlDown(event.ctrlKey); 30 | }, 31 | [setCtrlDown] 32 | ); 33 | 34 | useEffect(() => { 35 | document.addEventListener("click", handleClick); 36 | document.addEventListener("contextmenu", handleContextMenu); 37 | document.addEventListener("keydown", handleKeydown); 38 | return () => { 39 | document.removeEventListener("click", handleClick); 40 | document.removeEventListener("contextmenu", handleContextMenu); 41 | document.removeEventListener("keydown", handleKeydown); 42 | }; 43 | }); 44 | 45 | return { x, y, menu, enableContextMenu }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/configurator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | import Cookies from "js-cookie"; 4 | import { getTargetingCookies } from "./cookie-utils"; 5 | import { Input } from "@builder.io/sdk"; 6 | import { 7 | ControlledMenu, 8 | FocusableItem, 9 | MenuHeader, 10 | MenuItem, 11 | MenuRadioGroup, 12 | SubMenu, 13 | useMenuState, 14 | } from "@szhsin/react-menu"; 15 | 16 | import { useContextMenu } from "./use-context-menu"; 17 | 18 | export interface TargetingAttributes { 19 | [key: string]: Input; 20 | } 21 | 22 | export interface ConfiguratorProps { 23 | targetingAttributes?: TargetingAttributes; 24 | attributesApiPath?: string; 25 | } 26 | 27 | export const Configurator: React.FC = ({ targetingAttributes, attributesApiPath }) => { 28 | const router = useRouter(); 29 | const { x, y, menu, enableContextMenu } = useContextMenu(); 30 | const [loading, setLoading] = useState(false); 31 | 32 | const [attributes, setAttributes] = useState(targetingAttributes); 33 | useEffect(() => { 34 | async function init() { 35 | if (attributes) { 36 | enableContextMenu(true); 37 | } else { 38 | setLoading(true); 39 | try { 40 | const response = await fetch( 41 | attributesApiPath || "/api/attributes" 42 | ).then((res) => res.json()); 43 | setAttributes(response); 44 | enableContextMenu(true); 45 | } catch (error) { 46 | console.error(error); 47 | } 48 | setLoading(false); 49 | } 50 | } 51 | init(); 52 | }, []); 53 | const setCookie = (name: string, val: string) => () => { 54 | Cookies.set(`builder.userAttributes.${name}`, val); 55 | router.reload(); 56 | }; 57 | 58 | const reset = () => { 59 | const cookies = getTargetingCookies(Cookies.get()); 60 | cookies.forEach((cookie) => Cookies.remove(cookie)); 61 | router.reload(); 62 | }; 63 | 64 | const { toggleMenu, ...menuProps } = useMenuState(); 65 | 66 | useEffect(() => { 67 | if (menu && attributes) { 68 | toggleMenu(true); 69 | } 70 | }, [menu]); 71 | 72 | const keys = Object.keys(attributes || {}); 73 | 74 | return ( 75 | toggleMenu(false)} 79 | > 80 | Personalization settings 81 | Reset 82 | 83 | {keys.sort().map((attr, index) => { 84 | const options = attributes![attr].enum as string[]; 85 | return ( 86 | 87 | {options ? ( 88 | 91 | {options.map((option) => ( 92 | 97 | {option} 98 | 99 | ))} 100 | 101 | ) : ( 102 | 103 | {({ ref }) => ( 104 |
{ 106 | const data = new FormData(e.currentTarget); 107 | const values = Object.fromEntries(data.entries()); 108 | e.preventDefault(); 109 | setCookie(attr, values[attr] as string)(); 110 | }} 111 | > 112 | 120 |
121 | )} 122 |
123 | )} 124 |
125 | ); 126 | })} 127 | {loading && "Loading.."} 128 |
129 | ); 130 | }; 131 | --------------------------------------------------------------------------------