From 648a918a037f06d2c4fd20c0056b0dc9390f91c6 Mon Sep 17 00:00:00 2001 From: Tim Raderschad Date: Wed, 10 Jul 2024 22:11:58 +0200 Subject: [PATCH] feat: add cookie consent options --- .changeset/two-masks-hear.md | 7 +++ apps/web/abby.config.ts | 1 + apps/web/src/components/Pricing.tsx | 1 + apps/web/src/pages/devtools.tsx | 1 + apps/web/src/pages/index.tsx | 1 + packages/core/src/index.ts | 63 +++++++++++++++++++++-- packages/core/src/mathHelpers.ts | 48 +++++++++++------ packages/core/src/shared/interfaces.ts | 6 ++- packages/react/src/StorageService.ts | 13 +++-- packages/react/src/context.tsx | 2 +- packages/svelte/src/lib/StorageService.ts | 8 +-- 11 files changed, 121 insertions(+), 30 deletions(-) create mode 100644 .changeset/two-masks-hear.md diff --git a/.changeset/two-masks-hear.md b/.changeset/two-masks-hear.md new file mode 100644 index 00000000..17883227 --- /dev/null +++ b/.changeset/two-masks-hear.md @@ -0,0 +1,7 @@ +--- +"@tryabby/svelte": minor +"@tryabby/react": minor +"@tryabby/core": minor +--- + +add cookie consent options diff --git a/apps/web/abby.config.ts b/apps/web/abby.config.ts index 2fcd4947..d0cf68ec 100644 --- a/apps/web/abby.config.ts +++ b/apps/web/abby.config.ts @@ -23,5 +23,6 @@ export default defineConfig( remoteConfig: { abc: "JSON", }, + cookies: { disableByDefault: true, expiresInDays: 30 }, } ); diff --git a/apps/web/src/components/Pricing.tsx b/apps/web/src/components/Pricing.tsx index 0f36d976..66523ce0 100644 --- a/apps/web/src/components/Pricing.tsx +++ b/apps/web/src/components/Pricing.tsx @@ -84,6 +84,7 @@ function PricingElement({ string | null; - set: (key: string, value: string) => void; + set: (key: string, value: string, options?: StorageServiceOptions) => void; } export type AbbyConfig< @@ -95,6 +96,10 @@ export type AbbyConfig< settings?: Settings, F.NoInfer, F.NoInfer>; debug?: boolean; fetch?: (typeof globalThis)["fetch"]; + cookies?: { + disableByDefault?: boolean; + expiresInDays?: number; + }; __experimentalCdnUrl?: string; }; @@ -125,6 +130,8 @@ export class Abby< private testOverrides: Map = new Map(); private remoteConfigOverrides = new Map(); + private COOKIE_CONSENT_KEY = "$_abcc_$"; + constructor( private config: F.Narrow< AbbyConfig @@ -153,6 +160,17 @@ export class Abby< }, {} as Record ); + + if (persistantTestStorage) { + this.persistantTestStorage = { + get: (...args) => persistantTestStorage.get(...args), + set: (...args) => { + if (config.cookies?.disableByDefault) return; + const [key, value] = args; + persistantTestStorage.set(key, value, { expiresInDays: config.cookies?.expiresInDays }); + }, + }; + } } /** @@ -425,7 +443,9 @@ export class Abby< return persistedValue; } - + if (this.config.cookies?.disableByDefault) { + return getVariantWithHeighestWeightOrFirst(variants, weights); + } const weightedVariant = getWeightedRandomVariant(variants, weights); this.persistantTestStorage?.set(key as string, weightedVariant); @@ -531,6 +551,13 @@ export class Abby< "" ); + // special case for the cookie consent + if (testName === this.COOKIE_CONSENT_KEY) { + this.config.cookies ??= {}; + this.config.cookies.disableByDefault = cookieValue !== "true"; + return; + } + this.testOverrides.set(testName as TestName, cookieValue); this.persistantTestStorage?.set(testName as TestName, cookieValue); } @@ -604,4 +631,32 @@ export class Abby< value: RemoteConfigValueStringToType; }>; } + + /** + * Enables the usage of cookies for the storage of user data + * and also sets the cookies if possible + */ + enableCookies() { + this.config.cookies ??= {}; + this.config.cookies.disableByDefault = false; + this.persistantTestStorage?.set(this.COOKIE_CONSENT_KEY, "true"); + + Object.keys(this.#data.tests).forEach((testName) => { + this.persistantTestStorage?.set(testName, this.getTestVariant(testName as TestName)); + }); + } + + /** + * Disables the usage of cookies for storage of user data + * and also removes all set cookies if possible + */ + disableCookies() { + this.config.cookies ??= {}; + this.config.cookies.disableByDefault = true; + this.persistantTestStorage?.set(this.COOKIE_CONSENT_KEY, "false"); + + Object.keys(this.#data.tests).forEach((testName) => { + this.persistantTestStorage?.set(testName, this.getTestVariant(testName as TestName)); + }); + } } diff --git a/packages/core/src/mathHelpers.ts b/packages/core/src/mathHelpers.ts index 4956a65b..347cae41 100644 --- a/packages/core/src/mathHelpers.ts +++ b/packages/core/src/mathHelpers.ts @@ -12,9 +12,7 @@ function getRandomDecimal() { return Math.random(); } -function getWeightedRandomNumber>( - spec: T -): keyof T { +function getWeightedRandomNumber>(spec: T): keyof T { let i: keyof T; let sum = 0; const r = getRandomDecimal(); @@ -27,17 +25,13 @@ function getWeightedRandomNumber>( return i!; } -function getDefaultWeights>( - variants: Variants -) { - return Array.from({ length: variants.length }).fill( - 1 / variants.length - ); +function getDefaultWeights>(variants: Variants) { + return Array.from({ length: variants.length }).fill(1 / variants.length); } export function validateWeights< Variants extends ReadonlyArray, - Weights extends Array + Weights extends Array, >(variants: Variants, weights?: Weights): Weights { const sum = weights?.reduce((acc, weight) => acc + weight, 0); return weights != null && sum === 1 && variants.length === weights.length @@ -45,14 +39,34 @@ export function validateWeights< : (getDefaultWeights(variants) as Weights); } -export function getWeightedRandomVariant< - Variants extends ReadonlyArray ->(variants: Variants, weights?: Array): Variants[number] { +export function getWeightedRandomVariant>( + variants: Variants, + weights?: Array +): Variants[number] { const validatedWeights = validateWeights(variants, weights); return getWeightedRandomNumber( - variants.reduce((acc, variant, index) => { - acc[variant as Variants[number]] = validatedWeights[index]; - return acc; - }, {} as Record) + variants.reduce( + (acc, variant, index) => { + acc[variant as Variants[number]] = validatedWeights[index]; + return acc; + }, + {} as Record + ) ); } + +export function getVariantWithHeighestWeightOrFirst>( + variants: Variants, + weights?: Array +): Variants[number] { + const validatedWeights = validateWeights(variants, weights); + let variantWithHeighestWeight = variants[0]; + + for (let i = 1; i < variants.length; i++) { + if (validatedWeights[i] > validatedWeights[i - 1]) { + variantWithHeighestWeight = variants[i]; + } + } + + return variantWithHeighestWeight; +} diff --git a/packages/core/src/shared/interfaces.ts b/packages/core/src/shared/interfaces.ts index 6df9d679..87d0b704 100644 --- a/packages/core/src/shared/interfaces.ts +++ b/packages/core/src/shared/interfaces.ts @@ -1,5 +1,9 @@ +export interface StorageServiceOptions { + expiresInDays?: number; +} + export interface IStorageService { get(projectId: string, key: string): string | null; - set(projectId: string, key: string, value: string): void; + set(projectId: string, key: string, value: string, options?: StorageServiceOptions): void; remove(projectId: string, key: string): void; } diff --git a/packages/react/src/StorageService.ts b/packages/react/src/StorageService.ts index db135f7a..5e752128 100644 --- a/packages/react/src/StorageService.ts +++ b/packages/react/src/StorageService.ts @@ -4,8 +4,11 @@ import { getRCStorageKey, type IStorageService, } from "@tryabby/core"; +import type { StorageServiceOptions } from "@tryabby/core/dist/shared/interfaces"; import Cookie from "js-cookie"; +const DEFAULT_COOKIE_AGE = 365; + class ABStorageService implements IStorageService { get(projectId: string, testName: string): string | null { const retrievedValue = Cookie.get(getABStorageKey(projectId, testName)); @@ -14,8 +17,10 @@ class ABStorageService implements IStorageService { return retrievedValue; } - set(projectId: string, testName: string, value: string): void { - Cookie.set(getABStorageKey(projectId, testName), value); + set(projectId: string, testName: string, value: string, options?: StorageServiceOptions): void { + Cookie.set(getABStorageKey(projectId, testName), value, { + expires: options?.expiresInDays ? options.expiresInDays : DEFAULT_COOKIE_AGE, + }); } remove(projectId: string, testName: string): void { @@ -32,7 +37,7 @@ class FFStorageService implements IStorageService { } set(projectId: string, flagName: string, value: string): void { - Cookie.set(getFFStorageKey(projectId, flagName), value); + Cookie.set(getFFStorageKey(projectId, flagName), value, { expires: DEFAULT_COOKIE_AGE }); } remove(projectId: string, flagName: string): void { @@ -47,7 +52,7 @@ class RCStorageService implements IStorageService { } set(projectId: string, key: string, value: string): void { - Cookie.set(getRCStorageKey(projectId, key), value); + Cookie.set(getRCStorageKey(projectId, key), value, { expires: DEFAULT_COOKIE_AGE }); } remove(projectId: string, key: string): void { diff --git a/packages/react/src/context.tsx b/packages/react/src/context.tsx index f1e4e2b1..31c27a7e 100644 --- a/packages/react/src/context.tsx +++ b/packages/react/src/context.tsx @@ -51,7 +51,7 @@ export function createAbby< return TestStorageService.get(abbyConfig.projectId, key); }, set: (key: string, value: any) => { - if (typeof window === "undefined") return; + if (typeof window === "undefined" || config.cookies?.disableByDefault) return; TestStorageService.set(abbyConfig.projectId, key, value); }, }, diff --git a/packages/svelte/src/lib/StorageService.ts b/packages/svelte/src/lib/StorageService.ts index 9968f70e..c1488d1d 100644 --- a/packages/svelte/src/lib/StorageService.ts +++ b/packages/svelte/src/lib/StorageService.ts @@ -4,6 +4,7 @@ import { getRCStorageKey, type IStorageService, } from "@tryabby/core"; +import type { StorageServiceOptions } from "@tryabby/core/dist/shared/interfaces"; import Cookie from "js-cookie"; class ABStorageService implements IStorageService { @@ -14,10 +15,11 @@ class ABStorageService implements IStorageService { return retrievedValue; } - set(projectId: string, testName: string, value: string): void { - Cookie.set(getABStorageKey(projectId, testName), value); + set(projectId: string, testName: string, value: string, options?: StorageServiceOptions): void { + Cookie.set(getABStorageKey(projectId, testName), value, { + expires: options?.expiresInDays ? options.expiresInDays : 365, + }); } - remove(projectId: string, testName: string): void { Cookie.remove(getABStorageKey(projectId, testName)); }