diff --git a/.changeset/every-ravens-look.md b/.changeset/every-ravens-look.md new file mode 100644 index 000000000..a204bdc12 --- /dev/null +++ b/.changeset/every-ravens-look.md @@ -0,0 +1,5 @@ +--- +"@adobe/alloy": patch +--- + +Passes inbox item after user subscribes to ruleset items diff --git a/packages/core/src/components/Personalization/createPersonalizationDetails.js b/packages/core/src/components/Personalization/createPersonalizationDetails.js index ca3d6c843..94f7a0ccc 100644 --- a/packages/core/src/components/Personalization/createPersonalizationDetails.js +++ b/packages/core/src/components/Personalization/createPersonalizationDetails.js @@ -21,6 +21,7 @@ import { REDIRECT_ITEM, RULESET_ITEM, MESSAGE_CONTENT_CARD, + INBOX_ITEM, } from "../../constants/schema.js"; import { buildPageSurface, @@ -102,6 +103,7 @@ export default ({ RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, ]; if (scopes.includes(PAGE_WIDE_SCOPE)) { diff --git a/packages/core/src/components/RulesEngine/createOnResponseHandler.js b/packages/core/src/components/RulesEngine/createOnResponseHandler.js index 6fbfbac84..bd928107c 100644 --- a/packages/core/src/components/RulesEngine/createOnResponseHandler.js +++ b/packages/core/src/components/RulesEngine/createOnResponseHandler.js @@ -10,9 +10,18 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ +import { INBOX_ITEM } from "../../constants/schema.js"; import { PERSONALIZATION_DECISIONS_HANDLE } from "../../constants/decisionProvider.js"; import flattenObject from "../../utils/flattenObject.js"; +const extractInboxPropositions = (payloads) => + payloads.filter( + (payload) => + Array.isArray(payload.items) && + payload.items.length > 0 && + payload.items.every((item) => item.schema === INBOX_ITEM), + ); + export default ({ renderDecisions, decisionProvider, @@ -27,16 +36,20 @@ export default ({ }; return ({ response }) => { - decisionProvider.addPayloads( - response.getPayloadsByType(PERSONALIZATION_DECISIONS_HANDLE), + const personalizationPayloads = response.getPayloadsByType( + PERSONALIZATION_DECISIONS_HANDLE, ); + decisionProvider.addPayloads(personalizationPayloads); // only evaluate events that include a personalization query if (!event.hasQuery()) { return { propositions: [] }; } - const propositions = decisionProvider.evaluate(context); + const inboxPropositions = extractInboxPropositions(personalizationPayloads); + + const evaluatedPropositions = decisionProvider.evaluate(context); + const propositions = [...evaluatedPropositions, ...inboxPropositions]; return applyResponse({ renderDecisions, diff --git a/packages/core/src/constants/schema.js b/packages/core/src/constants/schema.js index 2df5c5546..f559a89f5 100644 --- a/packages/core/src/constants/schema.js +++ b/packages/core/src/constants/schema.js @@ -18,6 +18,7 @@ export const HTML_CONTENT_ITEM = export const JSON_CONTENT_ITEM = "https://ns.adobe.com/personalization/json-content-item"; export const RULESET_ITEM = "https://ns.adobe.com/personalization/ruleset-item"; +export const INBOX_ITEM = "https://ns.adobe.com/personalization/inbox-item"; export const REDIRECT_ITEM = "https://ns.adobe.com/personalization/redirect-item"; diff --git a/packages/core/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js b/packages/core/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js index 21bbad23c..f13879824 100644 --- a/packages/core/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/createPersonalizationDetails.spec.js @@ -22,6 +22,7 @@ import { REDIRECT_ITEM, RULESET_ITEM, MESSAGE_CONTENT_CARD, + INBOX_ITEM, } from "../../../../../src/constants/schema.js"; describe("Personalization::createPersonalizationDetails", () => { @@ -162,6 +163,7 @@ describe("Personalization::createPersonalizationDetails", () => { RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, DOM_ACTION, ], decisionScopes: expectedDecisionScopes, @@ -201,6 +203,7 @@ describe("Personalization::createPersonalizationDetails", () => { RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, DOM_ACTION, ], decisionScopes: expectedDecisionScopes, @@ -240,6 +243,7 @@ describe("Personalization::createPersonalizationDetails", () => { RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, DOM_ACTION, ], decisionScopes: expectedDecisionScopes, @@ -279,6 +283,7 @@ describe("Personalization::createPersonalizationDetails", () => { RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, ], decisionScopes: expectedDecisionScopes, surfaces: [], @@ -319,6 +324,7 @@ describe("Personalization::createPersonalizationDetails", () => { RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, ], decisionScopes: expectedDecisionScopes, surfaces: ["web://test1.com/"], @@ -360,6 +366,7 @@ describe("Personalization::createPersonalizationDetails", () => { RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, ], decisionScopes: expectedDecisionScopes, surfaces: ["web://test1.com/"], @@ -487,6 +494,7 @@ describe("Personalization::createPersonalizationDetails", () => { RULESET_ITEM, MESSAGE_IN_APP, MESSAGE_CONTENT_CARD, + INBOX_ITEM, DOM_ACTION, ], decisionScopes: expectedDecisionScopes, diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js index c0795fa41..c6b9773e7 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js @@ -37,6 +37,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], @@ -172,6 +173,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/mergedMetricDecisions.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/mergedMetricDecisions.spec.js index b1e631360..53ed30605 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/mergedMetricDecisions.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/mergedMetricDecisions.spec.js @@ -35,6 +35,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/mixedPropositions.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/mixedPropositions.spec.js index 95c0ffb2a..5eb0eebdf 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/mixedPropositions.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/mixedPropositions.spec.js @@ -38,6 +38,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js index 5093dbd5f..cdac0e8ea 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js @@ -36,6 +36,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisions.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisions.spec.js index a2623925b..4c22ad806 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisions.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisions.spec.js @@ -37,6 +37,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisionsWithoutDomActionSchemaItems.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisionsWithoutDomActionSchemaItems.spec.js index e4c644292..a3d75e436 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisionsWithoutDomActionSchemaItems.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/pageWideScopeDecisionsWithoutDomActionSchemaItems.spec.js @@ -37,6 +37,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/productsViewDecisions.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/productsViewDecisions.spec.js index 666dafa24..b287b866a 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/productsViewDecisions.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/productsViewDecisions.spec.js @@ -35,6 +35,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/redirectPageWideScopeDecision.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/redirectPageWideScopeDecision.spec.js index 8f9bddc91..f9bc166e3 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/redirectPageWideScopeDecision.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/redirectPageWideScopeDecision.spec.js @@ -35,6 +35,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/Personalization/topLevel/scopesFoo1Foo2Decisions.spec.js b/packages/core/test/unit/specs/components/Personalization/topLevel/scopesFoo1Foo2Decisions.spec.js index 44d91ab9c..b82436332 100644 --- a/packages/core/test/unit/specs/components/Personalization/topLevel/scopesFoo1Foo2Decisions.spec.js +++ b/packages/core/test/unit/specs/components/Personalization/topLevel/scopesFoo1Foo2Decisions.spec.js @@ -35,6 +35,7 @@ describe("PersonalizationComponent", () => { "https://ns.adobe.com/personalization/ruleset-item", "https://ns.adobe.com/personalization/message/in-app", "https://ns.adobe.com/personalization/message/content-card", + "https://ns.adobe.com/personalization/inbox-item", "https://ns.adobe.com/personalization/dom-action", ], decisionScopes: ["__view__"], diff --git a/packages/core/test/unit/specs/components/RulesEngine/createDecisionProvider.spec.js b/packages/core/test/unit/specs/components/RulesEngine/createDecisionProvider.spec.js index 88c6d22d0..e7a346922 100644 --- a/packages/core/test/unit/specs/components/RulesEngine/createDecisionProvider.spec.js +++ b/packages/core/test/unit/specs/components/RulesEngine/createDecisionProvider.spec.js @@ -444,27 +444,42 @@ describe("RulesEngine:createDecisionProvider", () => { }, ]); }); - it("ignores payloads that aren't json-ruleset type", () => { - decisionProvider.addPayload({ + it("does not add payloads that contain only inbox items (handled in response handler)", () => { + const inboxPayload = { + id: "66e05490-5e91-45c4-8eee-339784032940", + scope: "mobileapp://com.app/trendingnow", + scopeDetails: { + decisionProvider: "AJO", + activity: { id: "99db8aff4-82af-460e-8524-73e1441afdfa#id" }, + }, + items: [ + { + id: "569d1166-d3e0-4aea-b9a7-6de8ebdf3aec", + schema: "https://ns.adobe.com/personalization/inbox-item", + data: { + content: { + heading: { content: "Trending Now Inbox" }, + layout: { orientation: "horizontal" }, + capacity: 10, + }, + }, + }, + ], + }; + decisionProvider.addPayload(inboxPayload); + expect(decisionProvider.evaluate()).toEqual([]); + }); + + it("does not add payloads that have items but no inbox or ruleset items (e.g. TGT dom-action only)", () => { + const tgtPayload = { id: "AT:eyJhY3Rpdml0eUlkIjoiMTQxMDY0IiwiZXhwZXJpZW5jZUlkIjoiMCJ9", scope: "__view__", scopeDetails: { decisionProvider: "TGT", - activity: { - id: "141064", - }, - experience: { - id: "0", - }, - strategies: [ - { - algorithmID: "0", - trafficType: "0", - }, - ], - characteristics: { - eventToken: "abc", - }, + activity: { id: "141064" }, + experience: { id: "0" }, + strategies: [{ algorithmID: "0", trafficType: "0" }], + characteristics: { eventToken: "abc" }, correlationID: "141064:0:0:0", }, items: [ @@ -480,7 +495,8 @@ describe("RulesEngine:createDecisionProvider", () => { }, }, ], - }); + }; + decisionProvider.addPayload(tgtPayload); expect(decisionProvider.evaluate()).toEqual([]); }); }); diff --git a/packages/core/test/unit/specs/components/RulesEngine/createOnResponseHandler.spec.js b/packages/core/test/unit/specs/components/RulesEngine/createOnResponseHandler.spec.js index d434d18fe..ff5093b7b 100644 --- a/packages/core/test/unit/specs/components/RulesEngine/createOnResponseHandler.spec.js +++ b/packages/core/test/unit/specs/components/RulesEngine/createOnResponseHandler.spec.js @@ -396,4 +396,65 @@ describe("RulesEngine:createOnResponseHandler", () => { identityMap: undefined, }); }); + + it("converts inbox-only payloads to propositions and passes them to applyResponse with evaluated propositions", () => { + const event = { + getViewName: () => undefined, + getUserIdentityMap: () => undefined, + hasQuery: () => true, + getContent: () => ({ query: {}, xdm: {}, data: {} }), + }; + const responseHandler = createOnResponseHandler({ + renderDecisions: true, + decisionProvider, + applyResponse, + event, + personalization: {}, + decisionContext: {}, + }); + const inboxPayload = { + id: "66e05490-5e91-45c4-8eee-339784032940", + scope: "mobileapp://com.app/trendingnow", + scopeDetails: { + decisionProvider: "AJO", + activity: { id: "99db8aff4-82af-460e-8524-73e1441afdfa#id" }, + }, + items: [ + { + id: "569d1166-d3e0-4aea-b9a7-6de8ebdf3aec", + schema: "https://ns.adobe.com/personalization/inbox-item", + data: { + content: { + heading: { content: "Trending Now Inbox" }, + capacity: 10, + }, + }, + }, + ], + }; + const response = { + getPayloadsByType: () => [inboxPayload], + }; + responseHandler({ response }); + expect(lifecycle.onDecision).toHaveBeenCalledWith( + expect.objectContaining({ + propositions: [ + expect.objectContaining({ + id: inboxPayload.id, + items: [ + expect.objectContaining({ + schema: "https://ns.adobe.com/personalization/inbox-item", + data: expect.objectContaining({ + content: { + heading: { content: "Trending Now Inbox" }, + capacity: 10, + }, + }), + }), + ], + }), + ], + }), + ); + }); }); diff --git a/sandboxes/browser/src/App.jsx b/sandboxes/browser/src/App.jsx index 648c33b59..0f82bd7cf 100755 --- a/sandboxes/browser/src/App.jsx +++ b/sandboxes/browser/src/App.jsx @@ -32,6 +32,7 @@ import AlloyVersion from "./components/AlloyVersion"; import ConfigOverrides from "./ConfigOverrides"; import InAppMessages from "./components/InAppMessagesDemo/InAppMessages"; import ContentCards from "./components/ContentCardsDemo/ContentCards"; +import MessageInbox from "./components/MessageInboxDemo/MessageInbox"; import PushNotifications from "./PushNotifications"; import ReferrerTest from "./ReferrerTest"; import Advertising from "./Advertising"; @@ -108,6 +109,9 @@ const BasicExample = () => {
  • Content Cards
  • +
  • + Message Inbox +
  • Push Notifications
  • @@ -149,6 +153,7 @@ const BasicExample = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/sandboxes/browser/src/components/ContentCardsDemo/ContentCards.css b/sandboxes/browser/src/components/ContentCardsDemo/ContentCards.css index 3fbd057da..0c91c1d2a 100644 --- a/sandboxes/browser/src/components/ContentCardsDemo/ContentCards.css +++ b/sandboxes/browser/src/components/ContentCardsDemo/ContentCards.css @@ -1,15 +1,15 @@ /* Card.css */ .pretty-card { - color: black; - border: 1px solid #ccc; - border-radius: 8px; - padding: 16px; - margin: 16px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - background-color: lightblue; - transition: transform 0.2s; + color: black; + border: 1px solid #ccc; + border-radius: 8px; + padding: 16px; + margin: 16px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + background-color: lightblue; + transition: transform 0.2s; } .pretty-card:hover { - transform: scale(1.05); + transform: scale(1.05); } diff --git a/sandboxes/browser/src/components/MessageInboxDemo/MessageInbox.css b/sandboxes/browser/src/components/MessageInboxDemo/MessageInbox.css new file mode 100644 index 000000000..2eb0705e3 --- /dev/null +++ b/sandboxes/browser/src/components/MessageInboxDemo/MessageInbox.css @@ -0,0 +1,256 @@ +/* Message Inbox page */ +.message-inbox-page { + padding: 24px; + max-width: 640px; +} + +.message-inbox-page__title { + margin: 0 0 8px 0; + font-size: 1.5rem; + font-weight: 600; +} + +.message-inbox-page__intro { + margin: 0 0 24px 0; + color: #6b7280; + font-size: 0.875rem; +} + +/* Inbox trigger button */ +.message-inbox-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + font-size: 1rem; + font-weight: 500; + color: #fff; + background-color: #2563eb; + border: none; + border-radius: 8px; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.message-inbox-trigger:hover { + background-color: #1d4ed8; +} + +.message-inbox-trigger__badge { + min-width: 20px; + padding: 2px 6px; + font-size: 0.75rem; + font-weight: 600; + color: #2563eb; + background-color: #fff; + border-radius: 10px; +} + +/* Toasts */ +.message-inbox-toasts { + position: fixed; + top: 24px; + right: 24px; + z-index: 999; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 320px; + pointer-events: none; +} + +.message-inbox-toast { + pointer-events: auto; + padding: 12px 16px; + background-color: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + transition: opacity 0.2s, transform 0.2s; + animation: message-inbox-toast-in 0.25s ease-out; +} + +.message-inbox-toast:hover { + opacity: 0.95; +} + +.message-inbox-toast__title { + margin: 0 0 4px 0; + font-size: 0.875rem; + font-weight: 600; + color: #111827; +} + +.message-inbox-toast__body { + margin: 0; + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@keyframes message-inbox-toast-in { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* Sidebar overlay and panel */ +.message-inbox-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + z-index: 1000; +} + +.message-inbox-sidebar { + position: fixed; + top: 0; + right: 0; + width: 380px; + max-width: 100%; + height: 100%; + background-color: #fff; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.15); + z-index: 1001; + display: flex; + flex-direction: column; +} + +.message-inbox-sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px; + border-bottom: 1px solid #e5e7eb; + flex-shrink: 0; +} + +.message-inbox-sidebar__heading { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #111827; +} + +.message-inbox-sidebar__close { + padding: 6px; + font-size: 1.25rem; + line-height: 1; + color: #6b7280; + background: none; + border: none; + cursor: pointer; + border-radius: 4px; +} + +.message-inbox-sidebar__close:hover { + color: #111; + background-color: #f3f4f6; +} + +.message-inbox-sidebar__list { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.message-inbox-sidebar__empty { + padding: 32px 16px; + text-align: center; + color: #9ca3af; + font-size: 0.875rem; +} + +/* Inbox message card (from content-card) */ +.inbox-message { + padding: 14px 16px; + margin-bottom: 12px; + background-color: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.15s; +} + +.inbox-message:hover { + background-color: #f3f4f6; +} + +.inbox-message--read { + opacity: 0.75; +} + +.inbox-message--read .inbox-message__title { + font-weight: 500; +} + +.inbox-message__title { + margin: 0 0 4px 0; + font-size: 0.9375rem; + font-weight: 600; + color: #111827; +} + +.inbox-message__body { + margin: 0; + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.4; +} + +.inbox-message__meta { + margin-top: 8px; + font-size: 0.75rem; + color: #9ca3af; +} + +/* Send messages section */ +.send-messages { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid #e5e7eb; +} + +.send-messages__title { + margin: 0 0 12px 0; + font-size: 1rem; + font-weight: 600; +} + +.send-messages__hint { + margin: 0 0 16px 0; + font-size: 0.8125rem; + color: #6b7280; +} + +.send-messages__buttons { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.send-messages__btn { + padding: 8px 16px; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + background-color: #fff; + border: 1px solid #d1d5db; + border-radius: 6px; + cursor: pointer; +} + +.send-messages__btn:hover { + background-color: #f9fafb; + border-color: #9ca3af; +} diff --git a/sandboxes/browser/src/components/MessageInboxDemo/MessageInbox.jsx b/sandboxes/browser/src/components/MessageInboxDemo/MessageInbox.jsx new file mode 100644 index 000000000..30152a6d8 --- /dev/null +++ b/sandboxes/browser/src/components/MessageInboxDemo/MessageInbox.jsx @@ -0,0 +1,448 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations in the License. +*/ +import { useEffect, useState, useRef, useCallback } from "react"; +import ContentSecurityPolicy from "../ContentSecurityPolicy"; +import "./MessageInbox.css"; +import { getAlloyTestConfigs } from "../utils"; +import useAlloy from "../../helpers/useAlloy"; + +const configKey = localStorage.getItem("iam-configKey") || "stage"; +const config = getAlloyTestConfigs(); +const { datastreamId, orgId, edgeDomain } = config[configKey]; + +const SURFACE = "web://aepdemo.com/messageInbox"; +const CONTENT_CARD_SCHEMA = + "https://ns.adobe.com/personalization/message/content-card"; +const INBOX_ITEM_SCHEMA = "https://ns.adobe.com/personalization/inbox-item"; +const RULESET_ITEM_SCHEMA = "https://ns.adobe.com/personalization/ruleset-item"; + +const getContentString = (value) => { + if (value == null) return ""; + if (typeof value === "string") return value; + if (typeof value === "object" && value.content != null) return value.content; + return String(value); +}; + +const createContentCard = (proposition, item) => { + const { data = {}, id: itemId } = item; + const { + content = {}, + meta = {}, + publishedDate, + qualifiedDate, + displayedDate, + } = data; + const flat = { + id: itemId ?? proposition?.id, + ...content, + meta, + qualifiedDate, + displayedDate, + publishedDate, + getProposition: () => proposition, + }; + if (flat.title != null && typeof flat.title === "object") { + flat.title = getContentString(flat.title); + } + if (flat.body != null && typeof flat.body === "object") { + flat.body = getContentString(flat.body); + } + return flat; +}; + +const extractContentCards = (propositions) => + propositions.reduce((all, prop) => { + const { items = [] } = prop; + return [ + ...all, + ...items + .filter((item) => item.schema === CONTENT_CARD_SCHEMA) + .map((item) => createContentCard(prop, item)), + ]; + }, []); + +const extractInboxConfig = (propositions) => { + for (const prop of propositions) { + const { items = [] } = prop; + const inboxItem = items.find((item) => item.schema === INBOX_ITEM_SCHEMA); + if (inboxItem?.data?.content) return inboxItem.data.content; + } + return null; +}; + +const buildInboxItemProposition = (overrides = {}) => ({ + id: "inbox-config-1", + scope: SURFACE, + scopeDetails: { + decisionProvider: "AJO", + correlationID: "inbox-demo-0", + activity: { id: "inbox-demo#config" }, + }, + items: [ + { + id: "inbox-item-1", + schema: INBOX_ITEM_SCHEMA, + data: { + content: { + heading: { content: "Message Inbox" }, + layout: { orientation: "vertical" }, + capacity: 20, + isUnreadEnabled: true, + emptyStateSettings: { + message: { + content: "No messages yet. Send one using the buttons below.", + }, + }, + ...overrides, + }, + }, + }, + ], +}); + +const buildContentCardProposition = ({ id, title, body }) => { + const ts = Date.now(); + const cardId = `card-${id}-${ts}`; + return { + id: `msg-${id}-${ts}`, + scope: SURFACE, + scopeDetails: { + decisionProvider: "AJO", + correlationID: `msg-${id}-${ts}`, + activity: { id: `msg-${id}-${ts}#activity` }, + }, + items: [ + { + id: `ruleset-${id}-${ts}`, + schema: RULESET_ITEM_SCHEMA, + data: { + version: 1, + rules: [ + { + condition: { + definition: { + conditions: [ + { + definition: { + key: "~type", + matcher: "eq", + values: ["always"], + }, + type: "matcher", + }, + ], + logic: "and", + }, + type: "group", + }, + consequences: [ + { + type: "schema", + detail: { + schema: CONTENT_CARD_SCHEMA, + id: cardId, + data: { + content: { + title: { content: title }, + body: { content: body }, + }, + contentType: "application/json", + publishedDate: Date.now(), + expiryDate: Math.floor(Date.now() / 1000) + 86400 * 30, + meta: { surface: SURFACE }, + }, + }, + id: cardId, + }, + ], + }, + ], + }, + }, + ], + }; +}; + +const buildHandle = (propositions) => ({ + requestId: `mock-${Date.now()}`, + handle: [ + { + type: "personalization:decisions", + eventIndex: 0, + payload: propositions, + }, + ], +}); + +const INITIAL_INBOX_RESPONSE = buildHandle([buildInboxItemProposition()]); + +const TOAST_DURATION_MS = 4000; + +const MESSAGE_TEMPLATES = [ + { + id: "welcome", + title: "Welcome", + body: "Thanks for visiting. Here’s a quick tip: open the Message Inbox to see notifications from this demo.", + }, + { + id: "promo", + title: "Limited-time offer", + body: "Get 20% off your next order. Use code DEMO20 at checkout.", + }, + { + id: "alert", + title: "Reminder", + body: "Your session will expire in 15 minutes. Save your work if needed.", + }, + { + id: "update", + title: "Update", + body: "We’ve added a new Message Inbox demo. Check it out from the sidebar.", + }, +]; + +export default function MessageInbox() { + const [messages, setMessages] = useState([]); + const [toasts, setToasts] = useState([]); + const [inboxConfig, setInboxConfig] = useState(null); + const [inboxOpen, setInboxOpen] = useState(false); + const unsubscribeRef = useRef(null); + const toastTimeoutsRef = useRef({}); + const dismissToastRef = useRef(() => {}); + const messagesRef = useRef([]); + messagesRef.current = messages; + + useAlloy({ + configurations: { + alloy: { + defaultConsent: "in", + datastreamId, + orgId, + edgeDomain, + thirdPartyCookiesEnabled: false, + targetMigrationEnabled: false, + personalizationStorageEnabled: true, + debugEnabled: true, + }, + }, + }); + + useEffect(() => { + if (!window.alloy) return; + + const sub = window.alloy("subscribeRulesetItems", { + surfaces: [SURFACE], + schemas: [CONTENT_CARD_SCHEMA, INBOX_ITEM_SCHEMA], + callback: (result, collectEvent) => { + const { propositions = [] } = result; + const newCards = extractContentCards(propositions); + const configFromPayload = extractInboxConfig(propositions); + + const prev = messagesRef.current; + const existingIds = new Set(prev.map((m) => m.id).filter(Boolean)); + const toAdd = newCards + .filter((m) => m.id == null || !existingIds.has(m.id)) + .map((m) => ({ ...m, read: false })); + + if (toAdd.length > 0) { + setMessages((p) => [...p, ...toAdd]); + setToasts((t) => [...t, ...toAdd]); + toAdd.forEach((card) => { + const timeoutId = setTimeout(() => { + dismissToastRef.current(card.id); + }, TOAST_DURATION_MS); + toastTimeoutsRef.current[card.id] = timeoutId; + }); + } + if (configFromPayload) setInboxConfig(configFromPayload); + collectEvent("display", propositions); + }, + }); + + unsubscribeRef.current = sub; + + window.alloy("applyResponse", { + renderDecisions: true, + responseBody: INITIAL_INBOX_RESPONSE, + personalization: { + decisionContext: { "~type": "always" }, + }, + }); + + return () => { + sub.then((r) => r?.unsubscribe?.()); + Object.values(toastTimeoutsRef.current).forEach(clearTimeout); + toastTimeoutsRef.current = {}; + }; + }, []); + + const dismissToast = useCallback((messageId) => { + const timeoutId = toastTimeoutsRef.current[messageId]; + if (timeoutId) clearTimeout(timeoutId); + delete toastTimeoutsRef.current[messageId]; + setToasts((prev) => prev.filter((t) => t.id !== messageId)); + }, []); + + dismissToastRef.current = dismissToast; + + const sendMessage = (template) => { + if (!window.alloy) return; + const proposition = buildContentCardProposition(template); + window.alloy("applyResponse", { + renderDecisions: true, + responseBody: buildHandle([proposition]), + personalization: { + decisionContext: { "~type": "always" }, + surfaces: [SURFACE], + }, + }); + }; + + const markMessageRead = (msgId) => { + setMessages((prev) => + prev.map((m) => (m.id === msgId ? { ...m, read: true } : m)), + ); + }; + + const unreadCount = messages.filter((m) => !m.read).length; + + const heading = + inboxConfig?.heading != null + ? getContentString(inboxConfig.heading) + : "Message Inbox"; + const emptyMessage = + inboxConfig?.emptyStateSettings?.message != null + ? getContentString(inboxConfig.emptyStateSettings.message) + : "No messages yet. Send one using the buttons below."; + + return ( +
    + +

    Message Inbox

    +

    + This page demonstrates a "message inbox" fed by content cards + and configured by an inbox-item. Open the inbox to see messages from + this session; use the buttons below to add mock messages. +

    + + + +
    + {toasts.map((toast) => ( +
    { + markMessageRead(toast.id); + dismissToast(toast.id); + }} + > +

    {toast.title}

    +

    {toast.body}

    +
    + ))} +
    + +
    +

    Send a message

    +

    + Each button adds a new message to the inbox for this session (like + notifications that would come from the SDK when content cards are + returned). +

    +
    + {MESSAGE_TEMPLATES.map((t) => ( + + ))} +
    +
    + + {inboxOpen && ( + <> +
    setInboxOpen(false)} + onKeyDown={(e) => e.key === "Escape" && setInboxOpen(false)} + /> + + + )} +
    + ); +}