diff --git a/.changeset/personalization-plugin-transfer.md b/.changeset/personalization-plugin-transfer.md new file mode 100644 index 000000000..79959f690 --- /dev/null +++ b/.changeset/personalization-plugin-transfer.md @@ -0,0 +1,15 @@ +--- +'@sanity/personalization-plugin': major +--- + +Port @sanity/personalization-plugin to the Sanity plugins monorepo + +This major release includes several breaking changes as part of the migration to the monorepo: + +- **React Compiler enabled**: The package is now built with React Compiler targeting React 19 +- **ESM-only**: CommonJS support has been removed. The package now ships only ESM +- **React 19.2+ required**: Minimum React version is now 19.2 (previously ^18 || ^19) +- **react-dom 19.2+ required**: `react-dom` is now a required peer dependency +- **Sanity Studio v5+ required**: Minimum Sanity version is now v5 (Sanity v3 and v4 are no longer supported) +- **Node.js 20.19+ required**: Minimum Node.js version is now 20.19 (previously >=18) +- **Sanity v2 compatibility shim removed**: The `@sanity/incompatible-plugin` dependency, `sanity.json`, and `v2-incompatible.js` are no longer shipped diff --git a/.oxlintrc.json b/.oxlintrc.json index d78a8e62b..803755771 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -130,6 +130,16 @@ "restrict-template-expressions": "off" } }, + { + "files": ["plugins/@sanity/personalization-plugin/src/**/*.{ts,tsx}"], + "rules": { + "no-unsafe-type-assertion": "off", + "restrict-template-expressions": "off", + "no-base-to-string": "off", + "no-await-in-loop": "off", + "react-hooks-js/set-state-in-effect": "off" + } + }, { "files": ["plugins/sanity-naive-html-serializer/test/**/*.ts"], "rules": { diff --git a/README.md b/README.md index 878e82416..f113e3291 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Sessions can be compared in the DevTools UI to diff bundle changes between build | [`@sanity/document-internationalization`](./plugins/@sanity/document-internationalization) | Document-level translations linked by a shared reference | | [`@sanity/language-filter`](./plugins/@sanity/language-filter) | Filter localized fields by language | | [`@sanity/orderable-document-list`](./plugins/@sanity/orderable-document-list) | Drag-and-drop document ordering without leaving the editing surface | +| [`@sanity/personalization-plugin`](./plugins/@sanity/personalization-plugin) | Field-level personalization and A/B testing experiments | | [`@sanity/presets`](./plugins/@sanity/presets) | Experimental preset patterns for Sanity Studio | | [`@sanity/rich-date-input`](./plugins/@sanity/rich-date-input) | Timezone-aware datetime input for Sanity Studio | | [`@sanity/studio-secrets`](./plugins/@sanity/studio-secrets) | Manage Studio secrets at runtime | diff --git a/dev/test-studio/package.json b/dev/test-studio/package.json index 430fd6e67..14d25ba96 100644 --- a/dev/test-studio/package.json +++ b/dev/test-studio/package.json @@ -22,6 +22,7 @@ "@sanity/icons": "^3.7.4", "@sanity/language-filter": "workspace:*", "@sanity/orderable-document-list": "workspace:*", + "@sanity/personalization-plugin": "workspace:*", "@sanity/presets": "workspace:*", "@sanity/rich-date-input": "workspace:*", "@sanity/sfcc": "workspace:*", diff --git a/dev/test-studio/sanity.config.ts b/dev/test-studio/sanity.config.ts index 06d536c3d..16c795742 100644 --- a/dev/test-studio/sanity.config.ts +++ b/dev/test-studio/sanity.config.ts @@ -28,6 +28,7 @@ import { orderableDocumentListExample, orderableDocumentListExampleStructure, } from '#orderable-document-list' +import {personalizationExample} from '#personalization' import {presetsWorkspace} from '#presets' import {richDateInputExample} from '#rich-date-input' import {sanityNaiveHtmlSerializerExample} from '#sanity-naive-html-serializer' @@ -104,6 +105,7 @@ export default defineConfig([ assistExample(), googleTranslateExample(), // add new plugins here + personalizationExample(), orderableDocumentListExample(), latexInputExample(), debugLiveSyncTagsExample(), diff --git a/dev/test-studio/src/personalization/index.tsx b/dev/test-studio/src/personalization/index.tsx new file mode 100644 index 000000000..3f17ca7f8 --- /dev/null +++ b/dev/test-studio/src/personalization/index.tsx @@ -0,0 +1,51 @@ +import {fieldLevelExperiments} from '@sanity/personalization-plugin' +import {defineField, definePlugin, defineType} from 'sanity' + +const experiments = [ + { + id: 'homepage-headline', + label: 'Homepage Headline Test', + variants: [ + {id: 'control', label: 'Control'}, + {id: 'emotional', label: 'Emotional Appeal'}, + ], + }, + { + id: 'signup-cta', + label: 'Signup CTA Test', + variants: [ + {id: 'control', label: 'Control'}, + {id: 'urgent', label: 'Urgency Messaging'}, + {id: 'benefit', label: 'Benefit Focused'}, + ], + }, +] + +const personalizationTest = defineType({ + type: 'document', + name: 'personalizationTest', + title: 'Personalization', + fields: [ + defineField({type: 'string', name: 'title', title: 'Title'}), + defineField({ + type: 'experimentString', + name: 'headline', + title: 'Headline (A/B testable)', + }), + defineField({ + type: 'experimentText', + name: 'description', + title: 'Description (A/B testable)', + }), + ], +}) + +export const personalizationExample = definePlugin(() => ({ + schema: {types: [personalizationTest]}, + plugins: [ + fieldLevelExperiments({ + fields: ['string', 'text'], + experiments, + }), + ], +})) diff --git a/knip.jsonc b/knip.jsonc index adffbb2a4..fb88e1cfb 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -174,6 +174,11 @@ ], }, // add new plugin workspaces here + "plugins/@sanity/personalization-plugin": { + "entry": ["package.config.ts"], + "project": ["src/**/*.{ts,tsx}"], + }, + "plugins/sanity-plugin-documents-pane": { "entry": ["package.config.ts"], "project": ["src/**/*.{ts,tsx}"], diff --git a/plugins/@sanity/personalization-plugin/CHANGELOG.md b/plugins/@sanity/personalization-plugin/CHANGELOG.md new file mode 100644 index 000000000..0b29deb1a --- /dev/null +++ b/plugins/@sanity/personalization-plugin/CHANGELOG.md @@ -0,0 +1,137 @@ +# @sanity/personalization-plugin + +## [2.5.0](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.4.3...v2.5.0) (2026-01-19) + +### Features + +- movec LaunchDarkly export to own subpath and refactored ([021e96c](https://github.com/sanity-io/sanity-plugin-personalization/commit/021e96c4e5c5bfa7d070880aa84cb88efff565cf)) +- use sanity secrets, ability to filter and paginate request to LD ([05b95db](https://github.com/sanity-io/sanity-plugin-personalization/commit/05b95db752a5510ea03344470b3043427b0dc7fc)) + +### Bug Fixes + +- updated so all values from LD stored as strings ([bbe64aa](https://github.com/sanity-io/sanity-plugin-personalization/commit/bbe64aacc4da7796b82f527efe38dbd63ab53fda)) +- use value for variant label if no label ([6ed4175](https://github.com/sanity-io/sanity-plugin-personalization/commit/6ed417505105a7a5b9049fac273612019dfe123f)) + +## [2.4.3](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.4.2...v2.4.3) (2025-12-18) + +### Bug Fixes + +- **deps:** make peer dependencies include sanity 5.x ([34b7d99](https://github.com/sanity-io/sanity-plugin-personalization/commit/34b7d998eaf2efd24c1b5b91c5c8117b47f8f3cd)) + +## [2.4.2](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.4.1...v2.4.2) (2025-08-15) + +### Bug Fixes + +- copy default works in modals ([0be717b](https://github.com/sanity-io/sanity-plugin-personalization/commit/0be717b83c6fc9fd8fb355813f3c8c88fe99955c)) +- when used in modal auto set to active ([a62e6fa](https://github.com/sanity-io/sanity-plugin-personalization/commit/a62e6fae5608ea14ecbfbaae5df2a93f86d09ee7)) + +## [2.4.1](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.4.0...v2.4.1) (2025-07-10) + +### Bug Fixes + +- added media to preview ([e1402db](https://github.com/sanity-io/sanity-plugin-personalization/commit/e1402dbd8eb3efb3455557678ca28b962f3efa24)) +- **deps:** allow studio v4 in peer dep ranges ([#34](https://github.com/sanity-io/sanity-plugin-personalization/issues/34)) ([8b8e6a1](https://github.com/sanity-io/sanity-plugin-personalization/commit/8b8e6a1d8de07609aee134a89f40fc8ccf207a16)) +- improved preview in arraays ([d8beda3](https://github.com/sanity-io/sanity-plugin-personalization/commit/d8beda3f9fe84c8d68fa6ed45eea60503ad931e3)) +- improved preview when used in array ([0191341](https://github.com/sanity-io/sanity-plugin-personalization/commit/0191341e7b24a863189fa0330b16871102b72e63)) + +## [2.4.0](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.3.0...v2.4.0) (2025-05-16) + +### Features + +- increase flatten depth ([#31](https://github.com/sanity-io/sanity-plugin-personalization/issues/31)) ([0cb34f3](https://github.com/sanity-io/sanity-plugin-personalization/commit/0cb34f30062da6e9f792a39534ff8a5b2e7fb007)) + +## [2.3.0](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.2.1...v2.3.0) (2025-05-06) + +### Features + +- added boolena conversion check ([51a3a59](https://github.com/sanity-io/sanity-plugin-personalization/commit/51a3a59199992269dfe6dcf8f054f0f278bb4d7c)) +- added growthbook flied experiments as a plugin ([d28d2cc](https://github.com/sanity-io/sanity-plugin-personalization/commit/d28d2cc7875c2addbfb749f55e69221822e035f5)) +- made growthbook its own subpath export ([bcc1034](https://github.com/sanity-io/sanity-plugin-personalization/commit/bcc1034d4ed49327ac6e0250341964b1d1c673f5)) +- new config option, updated experiment fetching ([ef300fb](https://github.com/sanity-io/sanity-plugin-personalization/commit/ef300fbcb2116e2b49a13a17195a09b6e479ea7e)) + +### Bug Fixes + +- ensure that values are not duplicated ([52ab544](https://github.com/sanity-io/sanity-plugin-personalization/commit/52ab5441c175653ac075b7f4224c92f0363c38f6)) +- get experiments from feature flags for growthbook and store values that will be used by FE ([eb40e0b](https://github.com/sanity-io/sanity-plugin-personalization/commit/eb40e0baeeb536cdf6a74f14dd5007c16e041426)) +- only show secret input when finished loading ([a0eb18d](https://github.com/sanity-io/sanity-plugin-personalization/commit/a0eb18d494d4db3f92b09ce1b1edde846ee8c21d)) +- resolved issue with too many re renders on experiments ([24f018e](https://github.com/sanity-io/sanity-plugin-personalization/commit/24f018ed3028ffd36f3b86975543a1a9cdca9239)) +- updated base url to use corect api domain ([4d9b4f1](https://github.com/sanity-io/sanity-plugin-personalization/commit/4d9b4f1bc4c3acd15b0642a80efa364202239179)) + +## [2.2.1](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.2.0...v2.2.1) (2025-04-25) + +### Bug Fixes + +- works with content releases and removes variants if experiment changes" ([75c9a78](https://github.com/sanity-io/sanity-plugin-personalization/commit/75c9a78fb67feb9ab461c0c6f67943155e29ad2c)) + +## [2.2.0](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.1.0...v2.2.0) (2025-03-26) + +### Features + +- added message to warn there are no defeined experiments ([9302f08](https://github.com/sanity-io/sanity-plugin-personalization/commit/9302f0817327d33feb8ff26661ab18391fb4ff9d)) + +## [2.1.0](https://github.com/sanity-io/sanity-plugin-personalization/compare/v2.0.0...v2.1.0) (2025-02-21) + +### Features + +- added ability to copy default to a variant ([c1ff90a](https://github.com/sanity-io/sanity-plugin-personalization/commit/c1ff90a0cf000f8bb2fa455077d4a4e605820650)) +- allow overiding of experiment and variant field names ([f42e75c](https://github.com/sanity-io/sanity-plugin-personalization/commit/f42e75c1643dee5074b5278742df086e4264c139)) + +## [2.0.0](https://github.com/sanity-io/sanity-plugin-personalization/compare/v1.1.1...v2.0.0) (2025-02-07) + +### ⚠ BREAKING CHANGES + +- use US english for repo name + +### Features + +- updated list preview to reflect experiment variants ([c479c65](https://github.com/sanity-io/sanity-plugin-personalization/commit/c479c654f91ef4897295ff2a1e43e52597b8f3f5)) + +### Documentation + +- updated repo link ([79d0b02](https://github.com/sanity-io/sanity-plugin-personalization/commit/79d0b0245e3e17553b24ab6d555d9e6e51b1aba7)) + +## [1.1.1](https://github.com/sanity-io/sanity-plugin-personalisation/compare/v1.1.0...v1.1.1) (2025-01-02) + +### Bug Fixes + +- remove unneeded comments ([57d3d9a](https://github.com/sanity-io/sanity-plugin-personalisation/commit/57d3d9a16ed39296ca5d28a9d997e6856798c143)) + +## [1.1.0](https://github.com/sanity-io/sanity-plugin-personalisation/compare/v1.0.3...v1.1.0) (2024-12-09) + +### Features + +- allow canary branch to make releases ([936068d](https://github.com/sanity-io/sanity-plugin-personalisation/commit/936068dd392074c62821f5ab2ba4bbcfb34a9489)) + +### Bug Fixes + +- use onchagne from props rather than document operation for patch ([b61cdce](https://github.com/sanity-io/sanity-plugin-personalisation/commit/b61cdce12e470125fe70293bce983f48d091ade6)) + +## [1.0.3](https://github.com/sanity-io/sanity-plugin-personalisation/compare/v1.0.2...v1.0.3) (2024-11-26) + +### Bug Fixes + +- updated plugin name to match scoped package ([d0eb0cc](https://github.com/sanity-io/sanity-plugin-personalisation/commit/d0eb0cc930a9d1a4c2c38ff35bc68eafb8435ebc)) + +## [1.0.2](https://github.com/sanity-io/sanity-plugin-personalisation/compare/v1.0.1...v1.0.2) (2024-11-26) + +### Bug Fixes + +- updated name of package in readme ([847a8a1](https://github.com/sanity-io/sanity-plugin-personalisation/commit/847a8a1f04e24a7421381490a0d31020cc30dff3)) + +## [1.0.1](https://github.com/sanity-io/sanity-plugin-personalisation/compare/v1.0.0...v1.0.1) (2024-11-25) + +### Bug Fixes + +- update relase workflow ([a991386](https://github.com/sanity-io/sanity-plugin-personalisation/commit/a991386ee97142ec91f1a01a81acd135ccbe74ef)) + +## 1.0.0 (2024-11-25) + +### Features + +- updated name of package ([881e19f](https://github.com/sanity-io/sanity-plugin-personalisation/commit/881e19f001cbd4be6df12bc8b45f8a9d5f263311)) + +### Bug Fixes + +- add field action to show hide extra experiment data ([f4cdf44](https://github.com/sanity-io/sanity-plugin-personalisation/commit/f4cdf44a83b56fb6c29f705e4b4ebe02c938f1d1)) +- add field action to show hide extra experiment data ([810e913](https://github.com/sanity-io/sanity-plugin-personalisation/commit/810e913b325e45ff9f689f3b56ae74abc87dd9fc)) +- on removal of experiment clear additional fields ([d2613a3](https://github.com/sanity-io/sanity-plugin-personalisation/commit/d2613a369e237861519fb857fff585c5f4b9e8db)) diff --git a/plugins/@sanity/personalization-plugin/README.md b/plugins/@sanity/personalization-plugin/README.md new file mode 100644 index 000000000..4c732800c --- /dev/null +++ b/plugins/@sanity/personalization-plugin/README.md @@ -0,0 +1,909 @@ +# @sanity/personalization-plugin + +## Previously known as @sanity/personalisation-plugin + +This plugin allows users to add a/b/n testing experiments to individual fields and page-level experiments. + +> **Full Demo** +> +> 🚀 For a full working example of this plugin implemented with Next.js, see the [personalization-plugin-example](https://github.com/demo-repositories/personalization-plugin-example) repository. +> +> 🎬 Watch the [video walkthrough](https://www.loom.com/share/3e1314575b23434eb0aa35ccad9b9592) to see how the plugin works in a Next.js project. + +![image](./overview.gif) + +For this plugin you need to define the experiments you are running and the variations those experiments have. Each experiment needs to have an id, a label, and an array of variants that have an id and a label. You can either pass an array of experiments in the plugin config, or you can use and async function to retrieve the experiments and variants from an external service like growthbook, Amplitude, LaunchDarkly... You could even store the experiments in your sanity dataset. + +Once configured you can query the values using the ids of the experiment and variant + +- [@sanity/personalization-plugin](#sanitypersonalization-plugin) + - [Previously known as @sanity/personalisation-plugin](#previously-known-as-sanitypersonalisation-plugin) + - [Installation](#installation) + - [When to Use This Plugin](#when-to-use-this-plugin) + - [Usage](#usage) + - [Loading Experiments](#loading-experiments) + - [Option 1: Static Array](#option-1-static-array) + - [Option 2: Fetch from External Service](#option-2-fetch-from-external-service) + - [Option 3: Store in Sanity Dataset](#option-3-store-in-sanity-dataset) + - [Using complex field configurations](#using-complex-field-configurations) + - [Page-Level Experiments](#page-level-experiments) + - [Step 1: Configure the Plugin with a Reference Field](#step-1-configure-the-plugin-with-a-reference-field) + - [Step 2: Create a Route Experiment Document Type](#step-2-create-a-route-experiment-document-type) + - [Step 3: Query the Correct Page](#step-3-query-the-correct-page) + - [Step 4: Implement Proxy for Routing](#step-4-implement-proxy-for-routing) + - [Validation of individual array items](#validation-of-individual-array-items) + - [Shape of stored data](#shape-of-stored-data) + - [Querying data](#querying-data) + - [Variant Assignment](#variant-assignment) + - [Variant ID Consistency](#variant-id-consistency) + - [Cookie-Based Assignment](#cookie-based-assignment) + - [Reading Variants in Page Components](#reading-variants-in-page-components) + - [Third-Party Integration](#third-party-integration) + - [Split testing (URL-based)](#split-testing-url-based) + - [Studio Setup](#studio-setup) + - [Frontend usage](#frontend-usage) + - [Using experiment fields in an array](#using-experiment-fields-in-an-array) + - [Overwriting the experiment and variant field names](#overwriting-the-experiment-and-variant-field-names) + - [Example: Audience Segmentation](#example-audience-segmentation) + - [Stored Data Structure](#stored-data-structure) + - [Querying with Custom Field Names](#querying-with-custom-field-names) + - [License](#license) + - [Develop \& test](#develop--test) + - [Release new version](#release-new-version) + +> For Specific information about the Growthbook FieldLevel export see its [readme](/growthbook.md) +> +> For Specific information about the LaunchDarkly FieldLevel export see its [readme](/launchdarkly.md) + +## Installation + +```sh +npm install @sanity/personalization-plugin +``` + +## When to Use This Plugin + +This plugin supports two types of A/B testing: + +| Type | Use Case | Example | +| --------------- | ---------------------------------------------- | ------------------------------------------- | +| **Field-Level** | Test different content values on the same page | Different headlines, CTAs, or descriptions | +| **Page-Level** | Test entirely different page layouts | Different homepage designs or landing pages | + +**Choose Field-Level when:** + +- You want to test a single element (headline, button text, image) +- The page structure stays the same +- You need fine-grained control over individual content pieces + +**Choose Page-Level when:** + +- You want to test completely different page designs +- Multiple elements change together as part of a cohesive variant +- You're running landing page optimization tests + +## Usage + +Add it as a plugin in `sanity.config.ts` (or .js): + +```ts +import {defineConfig} from 'sanity' +import {fieldLevelExperiments} from '@sanity/personalization-plugin' + +// Example: Testing different homepage headlines +const headlineExperiment = { + id: 'homepage-headline', + label: 'Homepage Headline Test', + variants: [ + { + id: 'control', + label: 'Control', + }, + { + id: 'emotional', + label: 'Emotional Appeal', + }, + ], +} + +// Example: Testing different signup button text +const ctaExperiment = { + id: 'signup-cta', + label: 'Signup CTA Test', + variants: [ + { + id: 'control', + label: 'Control', + }, + { + id: 'urgent', + label: 'Urgency Messaging', + }, + { + id: 'benefit', + label: 'Benefit Focused', + }, + ], +} + +export default defineConfig({ + //... + plugins: [ + //... + fieldLevelExperiments({ + fields: ['string'], + experiments: [headlineExperiment, ctaExperiment], + }), + ], +}) +``` + +This will register two new fields to the schema based on the setting passed into `fields:`: + +- `experimentString` - An object field with a `string` field called `default`, a `string` field called `experimentId`, and an array field called `variants` +- `variantString` - An object field with a `string` field called `value`, a string field called `variantId`, and a `string` field called `experimentId` + +Use the experiment field in your schema like this: + +```ts +// Example: blog post with A/B testable title +// In post.ts + +fields: [ + defineField({ + name: 'title', + type: 'experimentString', + }), +] +``` + +When editors open a document with this field, they can: + +1. Enter a **default value** (shown to users not in an experiment) +2. Click the beaker icon **beaker icon** ("Add experiment") to assign an experiment +3. Enter **variant-specific values** for each variant in the experiment + +![Field-level experiment — click the beaker icon to add an experiment](./field-experiment.png) + +> 💡 **Tip:** Look for the beaker icon beaker icon in the field toolbar — clicking it opens the experiment picker where you can assign an experiment and enter variant-specific values. + +## Loading Experiments + +Experiments must be an array of objects with an `id`, `label`, and an array of `variants` (each with `id` and `label`). + +**Important:** The variant `id` values must match what your frontend uses to assign users to variants (typically via cookies). + +### Option 1: Static Array + +Define experiments directly in your config: + +```ts +experiments: [ + { + id: 'homepage-headline', + label: 'Homepage Headline Test', + variants: [ + {id: 'control', label: 'Control'}, + {id: 'emotional', label: 'Emotional Appeal'}, + ], + }, + { + id: 'signup-cta', + label: 'Signup CTA Test', + variants: [ + {id: 'control', label: 'Control'}, + {id: 'urgent', label: 'Urgency Messaging'}, + {id: 'benefit', label: 'Benefit Focused'}, + ], + }, +] +``` + +### Option 2: Fetch from External Service + +Use an async function to load experiments from services like GrowthBook, Amplitude, or LaunchDarkly: + +```ts +experiments: async () => { + const response = await fetch('https://api.growthbook.io/experiments') + const {experiments: externalExperiments} = await response.json() + + return externalExperiments?.map((experiment) => ({ + id: experiment.id, + label: experiment.name, + variants: experiment.variations?.map((variant) => ({ + id: variant.variationId, + label: variant.name, + })), + })) +} +``` + +### Option 3: Store in Sanity Dataset + +The async function receives a configured Sanity Client, allowing you to store experiments as documents: + +```ts +experiments: async (client) => { + // Fetch experiment documents from your dataset + const experiments = await client.fetch(` + *[_type == 'experiment']{ + id, + label, + variants[]{id, label} + } + `) + return experiments +} +``` + +This approach lets content editors create and manage experiments directly in Sanity Studio without code changes. + +## Using complex field configurations + +For more control over the value field, you can pass a schema definition into the fields array. + +```ts +import {defineConfig, defineField} from 'sanity' +import {fieldLevelExperiments} from '@sanity/personalization-plugin' + +export default defineConfig({ + //... + plugins: [ + //... + fieldLevelExperiments({ + fields: [ + defineField({ + name: 'featuredProduct', + type: 'reference', + to: [{type: 'product'}], + hidden: ({document}) => !document?.title, + }), + ], + experiments: [headlineExperiment, ctaExperiment], + }), + ], +}) +``` + +This would also create two new fields in your schema: + +- `experimentFeaturedProduct` - An object field with a `reference` field called `default`, a `string` field called `experimentId`, and an array field called `variants` +- `variantFeaturedProduct` - An object field with a `reference` field called `value`, a string field called `variantId`, and a `string` field called `experimentId` + +Note that the `name` key in the field definition is used to name the generated field type, while the actual field inside is always called `value`. + +## Page-Level Experiments + +You can use this plugin to A/B test entire pages by experimenting on reference fields. This is useful when you want to show completely different page content to different user segments. + +![Page-level experiment — click the beaker icon to add an experiment](./page-experiment.png) + +> 💡 **Tip:** Just like field-level experiments, click the beaker icon beaker icon on the reference field to assign an experiment and configure variant-specific pages. + +### Step 1: Configure the Plugin with a Reference Field + +```ts +import {defineConfig, defineField} from 'sanity' +import {fieldLevelExperiments} from '@sanity/personalization-plugin' + +const homepageExperiment = { + id: 'homepage-redesign', + label: 'Homepage Redesign Test', + variants: [ + {id: 'control', label: 'Control (Current Design)'}, + {id: 'variant-a', label: 'Variant A (New Design)'}, + ], +} + +export default defineConfig({ + //... + plugins: [ + fieldLevelExperiments({ + fields: [ + 'string', + // Add a reference field for page-level experiments + defineField({ + name: 'page', + type: 'reference', + to: [{type: 'page'}, {type: 'homePage'}], + }), + ], + experiments: [homepageExperiment], + }), + ], +}) +``` + +### Step 2: Create a Route Experiment Document Type + +Create a document type to store which pages should be shown for each route: + +```ts +import {defineType, defineField} from 'sanity' + +export const routeExperiment = defineType({ + name: 'routeExperiment', + title: 'Route Experiment', + type: 'document', + fields: [ + defineField({ + name: 'name', + title: 'Experiment Name', + type: 'string', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'targetRoute', + title: 'Target Route', + type: 'string', + description: 'The URL path this experiment applies to (e.g., "/" for homepage)', + validation: (Rule) => Rule.required(), + }), + defineField({ + name: 'isActive', + title: 'Active', + type: 'boolean', + initialValue: false, + }), + defineField({ + name: 'page', + title: 'Page', + type: 'experimentPage', // Auto-generated by the plugin + description: 'Select default page and variant pages', + }), + ], +}) +``` + +### Step 3: Query the Correct Page + +Use GROQ to resolve the correct page based on experiment and variant: + +```ts +const ROUTE_EXPERIMENT_QUERY = ` + *[_type == "routeExperiment" && targetRoute == $path && isActive == true][0]{ + "page": coalesce( + page.variants[experimentId == $experimentId && variantId == $variantId][0].value, + page.default + )->{ + _id, + _type, + title, + slug, + // ... other page fields + } + } +` +``` + +> The `slug` field is required for the slug-based path rewrite (Option B in Step 4). + +### Step 4: Implement Proxy for Routing + +In your frontend (e.g., Next.js proxy), determine which page to serve. Two approaches are supported: + +**Option A: Same URL (pageId query param)** — Keeps the visible URL stable. Best for A/B tests where users always see the same path. + +```ts +// proxy.ts +import {NextResponse} from 'next/server' +import type {NextRequest} from 'next/server' + +export async function proxy(request: NextRequest) { + const pathname = request.nextUrl.pathname + + // Get user's assigned variant from cookie + const variantId = request.cookies.get('ab-variant')?.value || 'control' + + // Fetch the experiment configuration + const data = await client.fetch(ROUTE_EXPERIMENT_QUERY, { + path: pathname, + experimentId: 'homepage-redesign', + variantId: variantId, + }) + + if (data?.page) { + // Rewrite to the selected page (same URL, pass pageId) + const url = request.nextUrl.clone() + url.searchParams.set('pageId', data.page._id) + return NextResponse.rewrite(url) + } + + return NextResponse.next() +} +``` + +**Option B: Slug-based path rewrite** — Rewrites the URL to the variant page's slug. Use when your app routes by slug (e.g. `app/[[...slug]]/page.tsx`) and you want the URL to reflect the variant. + +```ts +if (data?.page?.slug?.current) { + // Rewrite to the variant's slug path + const url = request.nextUrl.clone() + url.pathname = `/${data.page.slug.current}` + return NextResponse.rewrite(url) +} +// If slug is missing, the request continues without rewriting +``` + +## Validation of individual array items + +You may wish to validate individual fields for various reasons. From the variant array field, add a validation rule that can look through all the array items, and return item-specific validation messages at the path of that array item. + +```ts +defineField({ + name: 'title', + title: 'Title', + type: 'experimentString', + validation: (rule) => + rule.custom((experiment: ExperimentGeneric) => { + if (!experiment.default) { + return 'Default value is required' + } + + const invalidVariants = experiment.variants?.filter((variant) => !variant.value) + + if (invalidVariants?.length) { + return invalidVariants.map((item) => ({ + message: `Variant is missing a value`, + path: ['variants', {_key: item._key}, 'value'], + })) + } + return true + }), +}), +``` + +## Shape of stored data + +The custom input contains buttons which will add new array items with the experiment and variant already populated. Data returned from this field will look like this: + +```json +"title": { + "default": "Welcome to Our Platform", + "experimentId": "homepage-headline", + "variants": [ + { + "experimentId": "homepage-headline", + "variantId": "control", + "value": "Welcome to Our Platform" + }, + { + "experimentId": "homepage-headline", + "variantId": "emotional", + "value": "Transform Your Life Today" + } + ] +} +``` + +In this example: + +- `default` is shown to users not in an experiment +- `control` variant shows "Welcome to Our Platform" +- `emotional` variant shows "Transform Your Life Today" + +## Querying data + +Use GROQ's `coalesce` function to query for a specific variant with a fallback to the default value: + +```ts +// Fetch blog posts with experiment-aware title +*[_type == "post"] { + "title": coalesce( + title.variants[experimentId == $experimentId && variantId == $variantId][0].value, + title.default + ), + // ... other fields +} +``` + +On your frontend, pass the experiment and variant IDs as query parameters: + +```ts +const posts = await client.fetch(query, { + experimentId: 'homepage-headline', + variantId: userVariant, // e.g., 'control' or 'emotional' +}) +``` + +This pattern ensures: + +1. Users in the experiment see their assigned variant's content +2. Users not in an experiment see the default value +3. The query works even if no variants are defined (falls back to default) + +## Variant Assignment + +For experiments to work, your frontend must assign users to variants and pass the correct variant ID when querying content. + +### Variant ID Consistency + +**Important:** The variant IDs in your plugin configuration must match exactly what your frontend uses. + +```ts +// Studio config - these IDs must match your frontend +const experiment = { + id: 'homepage-headline', + variants: [ + {id: 'control', label: 'Control'}, // ID: 'control' + {id: 'variant-a', label: 'Variant A'}, // ID: 'variant-a' + ], +} +``` + +### Cookie-Based Assignment + +The most common approach is to assign variants via cookies on first visit. Using MurmurHash with a userId gives better distribution and deterministic assignment (the same user always gets the same variant): + +```ts +// In Next.js proxy (proxy.ts) +import {NextResponse} from 'next/server' +import type {NextRequest} from 'next/server' +import {v4} from 'uuid' +import MurmurHash3 from 'imurmurhash' + +const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days + +export function proxy(request: NextRequest) { + const response = NextResponse.next() + + // Check if user already has a variant + let variant = request.cookies.get('ab-variant')?.value + + if (!variant) { + // Use logged-in user ID if available, else persisted or new anonymous ID + const userId = + getUserIdFromSession(request) ?? // Implement: return session?.user?.id etc. + request.cookies.get('ab-user-id')?.value ?? + v4() + + // Deterministic variant from hash (same userId → same variant) + variant = MurmurHash3(userId).result() % 2 ? 'control' : 'variant-a' + + response.cookies.set('ab-variant', variant, {maxAge: COOKIE_MAX_AGE, path: '/'}) + // Persist anonymous ID when we created a new one (stable until user logs in) + if (!getUserIdFromSession(request) && !request.cookies.get('ab-user-id')?.value) { + response.cookies.set('ab-user-id', userId, {maxAge: COOKIE_MAX_AGE, path: '/'}) + } + } + + return response +} +``` + +> **Tip:** Install with `npm install uuid imurmurhash`. When a user logs in, update the `ab-user-id` cookie to their real user ID so variant assignment stays consistent across sessions. +> +> > **Auth integration:** Implement `getUserIdFromSession(request)` to return the logged-in user's ID (e.g. `getServerSession()?.user?.id` with NextAuth). If your app has no auth, leave it as a stub that returns `undefined` so anonymous users get a UUID-based assignment. + +### Reading Variants in Page Components + +In your page components, read the variant from cookies: + +```ts +import {cookies} from 'next/headers' + +async function getVariant(): Promise { + const cookieStore = await cookies() + const abCookie = cookieStore.get('ab-variant')?.value + return abCookie || 'control' +} + +export default async function Page() { + const variant = await getVariant() + + const data = await client.fetch(query, { + experimentId: 'homepage-headline', + variantId: variant, + }) + + // Render with experiment-aware content +} +``` + +### Third-Party Integration + +For advanced use cases, you can integrate with experimentation platforms like GrowthBook, LaunchDarkly, or Amplitude. These platforms handle variant assignment and provide analytics. See the [GrowthBook](/growthbook.md) and [LaunchDarkly](/launchdarkly.md) integration guides for details. + +## Split testing (URL-based) + +Split testing involves routing users at one URL to different pages. Use this when you want to test completely different page layouts, not just individual fields. + +### Studio Setup + +First, define a custom path type for URL validation: + +```ts +import {defineType} from 'sanity' + +export const path = defineType({ + name: 'path', + type: 'string', + validation: (Rule) => + Rule.required().custom(async (value: string | undefined) => { + if (!value) return true + if (!value.startsWith('/')) return 'Must start with "/"' + return true + }), +}) +``` + +Add the path type to your schema and plugin config: + +```ts +fieldLevelExperiments({ + fields: ['path', 'string'], // Include 'path' for URL experiments + experiments: [ + { + id: 'landing-page-test', + label: 'Landing Page A/B Test', + variants: [ + { id: 'control', label: 'Control' }, + { id: 'variant-a', label: 'Variant A' }, + ], + }, + ], +}), +``` + +Create a document type to store routing experiments: + +```ts +import {defineType, defineField} from 'sanity' + +export const routing = defineType({ + name: 'routing', + type: 'document', + title: 'URL Split Tests', + fields: [ + defineField({ + name: 'pathExperiment', + title: 'URL Path Experiment', + type: 'experimentPath', + initialValue: {active: true}, + description: 'Set the default URL and variant URLs for this test', + }), + ], + preview: { + select: { + path: 'pathExperiment.default', + experiment: 'pathExperiment.experimentId', + }, + prepare({path, experiment}) { + return { + title: `${path}`, + subtitle: `Experiment: ${experiment || 'None'}`, + } + }, + }, +}) +``` + +### Frontend usage + +Use a proxy to intercept requests and route users to the appropriate page based on their variant assignment: + +```ts +// proxy.ts +import {NextResponse} from 'next/server' +import type {NextRequest} from 'next/server' +import {v4} from 'uuid' +import MurmurHash3 from 'imurmurhash' +import {client} from './lib/sanity' + +const ROUTING_QUERY = `*[ + _type == "routing" && + pathExperiment.default == $path +][0]{ + "route": coalesce( + pathExperiment.variants[experimentId == $experimentId && variantId == $variantId][0].value, + pathExperiment.default + ) +}` + +const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days + +export async function proxy(request: NextRequest) { + const pathname = request.nextUrl.pathname + + // Get user's variant from cookie (set on first visit) + let variantId = request.cookies.get('ab-variant')?.value + + const response = NextResponse.next() + + if (!variantId) { + const userId = + getUserIdFromSession(request) ?? // Implement: return session?.user?.id etc. + request.cookies.get('ab-user-id')?.value ?? + v4() + variantId = MurmurHash3(userId).result() % 2 ? 'control' : 'variant-a' + if (!getUserIdFromSession(request) && !request.cookies.get('ab-user-id')?.value) { + response.cookies.set('ab-user-id', userId, {maxAge: COOKIE_MAX_AGE, path: '/'}) + } + } + response.cookies.set('ab-variant', variantId, {maxAge: COOKIE_MAX_AGE, path: '/'}) + + // Query for URL routing experiments + const data = await client.fetch(ROUTING_QUERY, { + path: pathname, + experimentId: 'landing-page-test', + variantId: variantId, + }) + + if (data?.route && data.route !== pathname) { + const url = request.nextUrl.clone() + url.pathname = data.route + const rewrite = NextResponse.rewrite(url) + // Preserve the cookie on the rewrite response + rewrite.cookies.set('ab-variant', variantId, {maxAge: COOKIE_MAX_AGE, path: '/'}) + return rewrite + } + + return response +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'], +} +``` + +**Tip:** For better performance, consider querying all routing experiments at build time and caching them, rather than fetching on every request. + +## Using experiment fields in an array + +You may want to add experiment fields as a type with in an array in oder to do this you would need to set an initial value for the experiment to active the schema would be something like: + +```ts +defineField({ + name: 'components', + type: 'array', + of: [ + defineArrayMember({type: 'cta', name: 'cta'}), + defineArrayMember({type: 'experimentCta', name: 'expCta', initialValue: {active: true}}), + defineArrayMember({type: 'hero', name: 'hero'}), + defineArrayMember({type: 'experimentHero', name: 'expHero', initialValue: {active: true}}), + ], + group: 'editorial', + }), +``` + +You can then use a groq filter to return the base version of you array member so you don't have to create an experiment specific version + +```ts +*[ + _type == "event" && + slug.current == $slug +][0]{ + ..., + components[]{ + _key, + ..., + string::startsWith(_type, "exp") => { + ...coalesce(@.variants[experimentId == $experiment && variantId == $variant][0].value, @.default), + }, + } +}`); +``` + +## Overwriting the experiment and variant field names + +If your use case doesn't match the "experiment/variant" terminology, you can rename these fields. This is useful for: + +- **Audience-based personalization**: Show different content to different user segments (e.g., "enterprise customers" vs "small business") +- **Locale-based content**: Display region-specific messaging +- **Feature flags**: Toggle content based on feature availability + +### Example: Audience Segmentation + +```ts +import {defineConfig} from 'sanity' +import {fieldLevelExperiments} from '@sanity/personalization-plugin' + +// Define your audiences and segments +const audiences = [ + { + id: 'customer-type', + label: 'Customer Type', + variants: [ + {id: 'enterprise', label: 'Enterprise'}, + {id: 'small-business', label: 'Small Business'}, + {id: 'individual', label: 'Individual'}, + ], + }, + { + id: 'subscription-tier', + label: 'Subscription Tier', + variants: [ + {id: 'free', label: 'Free Tier'}, + {id: 'pro', label: 'Pro Tier'}, + {id: 'enterprise', label: 'Enterprise Tier'}, + ], + }, +] + +export default defineConfig({ + //... + plugins: [ + fieldLevelExperiments({ + fields: ['string'], + experiments: audiences, + experimentNameOverride: 'audience', + variantNameOverride: 'segment', + }), + ], +}) +``` + +This creates two new fields in your schema: + +- `audienceString` - An object field with a `string` field called `default`, a `string` field called `audienceId`, and an array field called `segments` +- `segmentString` - An object field with a `string` field called `value`, a string field called `segmentId`, and a `string` field called `audienceId` + +### Stored Data Structure + +The data will be stored with your custom field names: + +```json +"headline": { + "default": "Welcome to Our Platform", + "audienceId": "customer-type", + "segments": [ + { + "audienceId": "customer-type", + "segmentId": "enterprise", + "value": "Enterprise-Grade Solutions for Your Team" + }, + { + "audienceId": "customer-type", + "segmentId": "small-business", + "value": "Grow Your Business with Powerful Tools" + }, + { + "audienceId": "customer-type", + "segmentId": "individual", + "value": "Your Personal Productivity Hub" + } + ] +} +``` + +### Querying with Custom Field Names + +Update your GROQ queries to use the renamed fields: + +```ts +*[_type == "landingPage"] { + "headline": coalesce( + headline.segments[audienceId == $audience && segmentId == $segment][0].value, + headline.default + ), +} +``` + +On your frontend, pass the audience and segment: + +```ts +const page = await client.fetch(query, { + audience: 'customer-type', + segment: userSegment, // e.g., 'enterprise', 'small-business', or 'individual' +}) +``` + +## License + +[MIT](LICENSE) © Jon Burbridge + +## Develop & test + +This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit) +with default configuration for build & watch scripts. + +See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio) +on how to run this plugin with hotreload in the studio. + +### Release new version + +Run ["CI & Release" workflow](https://github.com/sanity-io/sanity-plugin-personalization/actions/workflows/main.yml). +Make sure to select the main branch and check "Release new version". + +Semantic release will only release on configured branches, so it is safe to run release on any branch. diff --git a/plugins/@sanity/personalization-plugin/beaker.svg b/plugins/@sanity/personalization-plugin/beaker.svg new file mode 100644 index 000000000..8c0c0c4ad --- /dev/null +++ b/plugins/@sanity/personalization-plugin/beaker.svg @@ -0,0 +1 @@ + diff --git a/plugins/@sanity/personalization-plugin/field-experiment.png b/plugins/@sanity/personalization-plugin/field-experiment.png new file mode 100644 index 000000000..b090d2d5c Binary files /dev/null and b/plugins/@sanity/personalization-plugin/field-experiment.png differ diff --git a/plugins/@sanity/personalization-plugin/growthbook.md b/plugins/@sanity/personalization-plugin/growthbook.md new file mode 100644 index 000000000..336ea3ad4 --- /dev/null +++ b/plugins/@sanity/personalization-plugin/growthbook.md @@ -0,0 +1,78 @@ +# @sanity/personalization-plugin - GrowthBook + +## Previously know as @sanity/personalisation-plugin + +> This is a **Sanity Studio v3** plugin. + +This plugin allows users to add a/b/n testing experiments to individual fields connecting to the [Growthbook](https://www.growthbook.io/) A/B testing service. + +- [@sanity/personalization-plugin - GrowthBook](#sanitypersonalization-plugin---GrowthBook) + - [Installation](#installation) + - [Usage](#usage) + - [Loading Experiments](#loading-experiments) + +This plugin is built on top of the `fieldLevelExperiments` export so see the main readme for details of: + +- [Using complex field configurations](/#using-complex-field-configurations) +- [Validation of individual array items](/#validation-of-individual-array-items) +- [Shape of stored data](/#shape-of-stored-data) +- [Querying data](/#querying-data) +- [License](#license) +- [Develop \& test](#develop--test) + - [Release new version](#release-new-version) +- [License](#license-1) + +## Installation + +```sh +npm install @sanity/personalization-plugin +``` + +## Usage + +Add it as a plugin in `sanity.config.ts` (or .js): + +```ts +import {defineConfig} from 'sanity' +import {fieldLevelExperiments} from '@sanity/personalization-plugin/growthbook' + +export default defineConfig({ + //... + plugins: [ + //... + fieldLevelExperiments({ + fields: ['string'], + environment: 'production', // the growthbook environment + projectId: 'string', // optional filter parameter for fetching features/experiments + convertBooleans: true, // convert boolean experiments to store values of "control"/"variant" default to false + tags: ['string'], // optional filter, if included feature must have at least one of the tag specified + }), + ], +}) +``` + +This will register two new fields to the schema., based on the setting passed into `fields:` + +- `experimentString` an Object field with `string` field called `default`, a `string` field called `experimentId` and an array field of type: +- `varirantsString` an object field with a `string` field called `value`, a string field called `variantId`, a `string` field called `experimentId`. + +Use the experiment field in your schema like this: + +```ts +//for Example in post.ts + +fields: [ + defineField({ + name: 'title', + type: 'experimentString', + }), +] +``` + +## Loading Experiments + +This plugin uses [@sanity/studio-secrets](https://www.npmjs.com/package/@sanity/studio-secrets) for storing your GrowthBook API key. The first time you open a document that has an experiment you will be asked to provide your API key. This is stored in a private document on the dataset. + +Once you have entered you API key the plugin will fetch non archived features and variants of experiments for those features. If features/experiments are updated on Growthbook you will need to refresh the page. + +The values stored for an experiment will be the Feature Key amd the variants will stored the variation value. The exception to this is boolean values when the `convertBooleans` config flag is set to true. In that case `false` will be set as `control` and true will be set as `variant` diff --git a/plugins/@sanity/personalization-plugin/launchdarkly.md b/plugins/@sanity/personalization-plugin/launchdarkly.md new file mode 100644 index 000000000..5b63cff01 --- /dev/null +++ b/plugins/@sanity/personalization-plugin/launchdarkly.md @@ -0,0 +1,76 @@ +# @sanity/personalization-plugin - launchDarklyFieldLevel + +## Previously know as @sanity/personalisation-plugin + +> This is a **Sanity Studio v3** plugin. + +This plugin allows users to add a/b/n testing experiments to individual fields connecting to the [LaunchDarkly](https://launchdarkly.com//) A/B testing service. + +- [@sanity/personalization-plugin - launchDarklyFieldLevel](#sanitypersonalization-plugin---launchDarklyFieldLevel) + - [Installation](#installation) + - [Usage](#usage) + - [Loading Experiments](#loading-experiments) + +This plugin is built on top of the `fieldLevelExperiments` export so see the main readme for details of: + +- [Using complex field configurations](/#using-complex-field-configurations) +- [Validation of individual array items](/#validation-of-individual-array-items) +- [Shape of stored data](/#shape-of-stored-data) +- [Querying data](/#querying-data) +- [License](#license) +- [Develop \& test](#develop--test) + - [Release new version](#release-new-version) +- [License](#license-1) + +## Installation + +```sh +npm install @sanity/personalization-plugin +``` + +## Usage + +Add it as a plugin in `sanity.config.ts` (or .js): + +```ts +import {defineConfig} from 'sanity' +import {fieldLevelExperiments} from '@sanity/personalization-plugin/launchDarkly' + +export default defineConfig({ + //... + plugins: [ + //... + launchDarklyFieldLevel({ + fields: ['string'], + projectKey: 'string', // required filter parameter for fetching features/variants + tags: ['string'], //optional parameter that filters the list to flags that have all of the tags in the list + }), + ], +}) +``` + +This will register two new fields to the schema., based on the setting passed into `fields:` + +- `flagString` an Object field with `string` field called `default`, a `string` field called `flagId` and an array field of type: +- `variantsString` an object field with a `string` field called `value`, a string field called `variantId`, a `string` field called `flagId`. + +Use the flag field in your schema like this: + +```ts +//for Example in post.ts + +fields: [ + defineField({ + name: 'title', + type: 'flagString', + }), +] +``` + +## Loading Experiments + +This plugin uses [@sanity/studio-secrets](https://www.npmjs.com/package/@sanity/studio-secrets) for storing your Launch Darkly API key. The first time you open a document that has an experiment you will be asked to provide your API key. This is stored in a private document on the dataset. + +Once you have entered you API key the plugin will fetch feature flags and variants flags. If features/variants are updated on LaunchDarkly you will need to refresh the page. + +The values stored for an flag will be the Feature Key amd the variants will stored the variation value. diff --git a/plugins/@sanity/personalization-plugin/overview.gif b/plugins/@sanity/personalization-plugin/overview.gif new file mode 100644 index 000000000..d13907309 Binary files /dev/null and b/plugins/@sanity/personalization-plugin/overview.gif differ diff --git a/plugins/@sanity/personalization-plugin/package.config.ts b/plugins/@sanity/personalization-plugin/package.config.ts new file mode 100644 index 000000000..43da34cfa --- /dev/null +++ b/plugins/@sanity/personalization-plugin/package.config.ts @@ -0,0 +1,8 @@ +import config from '@repo/package.config' +import {defineConfig} from '@sanity/pkg-utils' + +export default defineConfig({ + ...config, + babel: {reactCompiler: true}, + reactCompilerOptions: {target: '19'}, +}) diff --git a/plugins/@sanity/personalization-plugin/package.json b/plugins/@sanity/personalization-plugin/package.json new file mode 100644 index 000000000..a0273df91 --- /dev/null +++ b/plugins/@sanity/personalization-plugin/package.json @@ -0,0 +1,83 @@ +{ + "name": "@sanity/personalization-plugin", + "version": "2.5.0", + "description": "Plugin to help with personalization, a/b testing when using Sanity", + "keywords": [ + "sanity", + "sanity-plugin" + ], + "homepage": "https://github.com/sanity-io/plugins/tree/main/plugins/@sanity/personalization-plugin#readme", + "bugs": { + "url": "https://github.com/sanity-io/plugins/issues" + }, + "license": "MIT", + "author": "Sanity.io ", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/sanity-io/plugins.git", + "directory": "plugins/@sanity/personalization-plugin" + }, + "files": [ + "dist" + ], + "type": "module", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "source": "./src/index.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + }, + "./launchDarkly": { + "source": "./src/launchDarkly/index.ts", + "development": "./src/launchDarkly/index.ts", + "default": "./dist/launchDarkly/index.js" + }, + "./growthbook": { + "source": "./src/growthbook/index.ts", + "development": "./src/growthbook/index.ts", + "default": "./dist/growthbook/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": "./dist/index.js", + "./launchDarkly": "./dist/launchDarkly/index.js", + "./growthbook": "./dist/growthbook/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "build": "pkg build --strict --check --clean", + "prepack": "turbo run build" + }, + "dependencies": { + "@sanity/icons": "^3.7.4", + "@sanity/studio-secrets": "workspace:*", + "@sanity/ui": "catalog:", + "@sanity/uuid": "^3.0.2", + "fast-deep-equal": "^3.1.3", + "react-icons": "^5.5.0", + "suspend-react": "^0.1.3" + }, + "devDependencies": { + "@repo/package.config": "workspace:*", + "@repo/tsconfig": "workspace:*", + "@sanity/pkg-utils": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "sanity": "catalog:" + }, + "peerDependencies": { + "react": "^19.2", + "react-dom": "^19.2", + "sanity": "^5 || ^6.0.0-0" + }, + "engines": { + "node": ">=20.19 <22 || >=22.12" + } +} diff --git a/plugins/@sanity/personalization-plugin/page-experiment.png b/plugins/@sanity/personalization-plugin/page-experiment.png new file mode 100644 index 000000000..d347ce2ae Binary files /dev/null and b/plugins/@sanity/personalization-plugin/page-experiment.png differ diff --git a/plugins/@sanity/personalization-plugin/src/components/Array.tsx b/plugins/@sanity/personalization-plugin/src/components/Array.tsx new file mode 100644 index 000000000..78f6467d6 --- /dev/null +++ b/plugins/@sanity/personalization-plugin/src/components/Array.tsx @@ -0,0 +1,68 @@ +import {Button, Inline, Stack} from '@sanity/ui' +import {uuid} from '@sanity/uuid' +import {useCallback} from 'react' +import {useFormValue} from 'sanity' + +import type {ArrayInputProps, VariantType} from '../types' +import {useExperimentContext} from './ExperimentContext' + +export const ArrayInput = (props: ArrayInputProps) => { + const fieldPath = props.path.slice(0, -1) + const {onItemAppend, variantName, variantId, experimentId} = props + const experimentValue = useFormValue([...fieldPath, experimentId]) + + const {experiments} = useExperimentContext() + + const handleClick = useCallback( + async (variant: VariantType) => { + const item = { + _key: uuid(), + [variantId]: variant.id, + [experimentId]: experimentValue, + _type: variantName, + } + + // Patch the document + onItemAppend(item) + }, + [variantId, experimentId, experimentValue, variantName, onItemAppend], + ) + + const filteredVariants = + experiments.find((option) => { + return option.id === experimentValue + })?.variants || [] + + type Value = { + value?: unknown + [key: string]: unknown + variantId: string + _key: string + _type: string + } + + // there is probably some better was of getting the type of this? + const values = (props.value as Value[]) || [] + + const usedVariants = values?.map((variant) => variant[variantId]) + + return ( + + {props.renderDefault({...props, arrayFunctions: () => null})} + + + {filteredVariants.map((variant) => { + return ( +