diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js
index f6a97ce..e44d3e5 100644
--- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js
+++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js
@@ -3,24 +3,16 @@
var PageMgr = require('dw/experience/PageMgr');
var HashMap = require('dw/util/HashMap');
var cloudinaryApi = require('*/cartridge/scripts/cloudinary/cloudinaryApi');
-var URLUtils = require('dw/web/URLUtils');
-var URLAction = require('dw/web/URLAction');
-var CSRF = require('dw/web/CSRFProtection');
module.exports.init = function (editor) {
- var conf = new HashMap();
- var csrf = new HashMap();
- csrf.put(CSRF.getTokenName(), CSRF.generateToken());
- var linkUrlAct = URLUtils.abs('Links-url').toString();
- editor.configuration.put('linkBuilderUrl', linkUrlAct);
- editor.configuration.put('csrf', csrf);
- conf.put('type', 'image');
editor.configuration.put('cloudName', cloudinaryApi.data.getCloudName());
editor.configuration.put('cname', cloudinaryApi.data.getCloudinaryCNAME());
- editor.configuration.put('globalTrans', cloudinaryApi.globalTransform());
- editor.configuration.put('iFrameEnv', cloudinaryApi.data.getIframeEnv());
- var videoSelector = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf);
- var adv = PageMgr.getCustomEditor('cloudinary.advancedImageForm', conf);
- editor.dependencies.put('advBreakout', adv);
- editor.dependencies.put('breakout', videoSelector);
+
+ var conf = new HashMap();
+ conf.put('type', 'image');
+ var mediaPicker = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf);
+ editor.dependencies.put('mediaPicker', mediaPicker);
+
+ var studioWidget = PageMgr.getCustomEditor('cloudinary.studioWidget', new HashMap());
+ editor.dependencies.put('studioWidget', studioWidget);
};
diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json
index 0176510..63399f6 100644
--- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json
+++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json
@@ -1,15 +1,13 @@
-
{
"name": "Cloudinary Image Config Form",
"description": "Configure Image",
"resources": {
"scripts": [
- "https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.2.10/iframeResizer.min.js",
- "/experience/editors/cloudinary/utils.js",
- "/experience/editors/cloudinary/imageForm.js"
+ "https://media-library.cloudinary.com/global/all.js",
+ "/experience/editors/cloudinary/imageFormWidget.js"
],
"styles": [
- "/experience/editors/cloudinary/form.css"
+ "/experience/editors/cloudinary/videoFormWidget.css"
]
}
}
diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.js b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.js
new file mode 100644
index 0000000..0e49c2d
--- /dev/null
+++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.js
@@ -0,0 +1,8 @@
+'use strict';
+
+var cloudinaryApi = require('*/cartridge/scripts/cloudinary/cloudinaryApi');
+
+module.exports.init = function (editor) {
+ editor.configuration.put('cloudName', cloudinaryApi.data.getCloudName());
+ editor.configuration.put('apiKey', cloudinaryApi.data.getAPIKey());
+};
diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.json b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.json
new file mode 100644
index 0000000..ccc8fa2
--- /dev/null
+++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.json
@@ -0,0 +1,14 @@
+{
+ "name": "Cloudinary Studio Widget",
+ "description": "Advanced image editor via Cloudinary Studio Widget",
+ "resources": {
+ "scripts": [
+ "https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.2.10/iframeResizer.contentWindow.min.js",
+ "https://studio-widget.cloudinary.com/latest/all.js",
+ "/experience/editors/cloudinary/studioWidget.js"
+ ],
+ "styles": [
+ "/experience/editors/cloudinary/studioWidget.css"
+ ]
+ }
+}
diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js
index ec1cb4a..567f502 100644
--- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js
+++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js
@@ -5,13 +5,32 @@ var HashMap = require('dw/util/HashMap');
var cloudinaryApi = require('*/cartridge/scripts/cloudinary/cloudinaryApi');
module.exports.init = function (editor) {
- var conf = new HashMap();
- conf.put('type', 'video');
editor.configuration.put('cloudName', cloudinaryApi.data.getCloudName());
editor.configuration.put('cname', cloudinaryApi.data.getCloudinaryCNAME());
- var videoSelector = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf);
- var adv = PageMgr.getCustomEditor('cloudinary.advancedVideoForm', conf);
- editor.dependencies.put('advBreakout', adv);
- editor.dependencies.put('breakout', videoSelector);
- editor.configuration.put('iFrameEnv', cloudinaryApi.data.getIframeEnv());
+
+ // Pass default player option values from site preference to the widget
+ var currentSite = require('dw/system/Site').getCurrent();
+ var playerOptionsRaw = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerVideoPlayerOptions');
+ var playerOptions = { autoplay: false, muted: false, loop: false, controls: true };
+ if (playerOptionsRaw) {
+ try {
+ var parsed = JSON.parse(playerOptionsRaw);
+ var keys = ['autoplay', 'muted', 'loop', 'controls'];
+ for (var i = 0; i < keys.length; i++) {
+ var k = keys[i];
+ if (k in parsed) {
+ playerOptions[k] = !!parsed[k];
+ }
+ }
+ } catch (e) { /* keep defaults on parse error */ }
+ }
+ editor.configuration.put('playerOptions', JSON.stringify(playerOptions));
+
+ var conf = new HashMap();
+ conf.put('type', 'video');
+ var mediaPicker = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf);
+ editor.dependencies.put('mediaPicker', mediaPicker);
+
+ var advancedConfig = PageMgr.getCustomEditor('cloudinary.advancedVideoForm', new HashMap());
+ editor.dependencies.put('advancedConfig', advancedConfig);
};
diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json
index fc1b510..46d8f9d 100644
--- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json
+++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json
@@ -1,15 +1,13 @@
-
{
- "name": "Cloudinary Image Config Form",
- "description": "Configure Image",
+ "name": "Cloudinary Video Config Form",
+ "description": "Configure Video",
"resources": {
"scripts": [
- "https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.2.10/iframeResizer.min.js",
- "/experience/editors/cloudinary/utils.js",
- "/experience/editors/cloudinary/videoForm.js"
+ "https://media-library.cloudinary.com/global/all.js",
+ "/experience/editors/cloudinary/videoFormWidget.js"
],
"styles": [
- "/experience/editors/cloudinary/form.css"
+ "/experience/editors/cloudinary/videoFormWidget.css"
]
}
}
diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js
index 49dc0ad..31479b2 100644
--- a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js
+++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js
@@ -1,7 +1,89 @@
+/**
+ * When the parent editor uses the new per-form-factor format
+ * ({ formValues: { desktop, mobile, tablet }, posterMode, playerOptions, transformationOverride })
+ * the iframe still expects formValues.video.asset.public_id to render its preview.
+ *
+ * cldUtils.cleanValue (called inside dehydrate) strips every top-level key except
+ * "formValues" and "breakpoints", so anything outside formValues is lost.
+ * We therefore map the resolved asset back into formValues.video.asset which is
+ * exactly what the iframe reads.
+ */
+function normalizeValueForIframe(value) {
+ if (!value || !value.formValues) return value;
+ var fv = value.formValues;
+
+ // Already in the old format – nothing to do
+ if (fv.video && fv.video.asset) return value;
+
+ // Resolve current asset from the new per-form-factor format
+ var entry = fv.desktop || fv.tablet || fv.mobile;
+ if (!entry || !entry.asset) return value;
+
+ var asset = entry.asset;
+
+ // If a full Advanced config was previously saved, restore it directly.
+ // This preserves every overlay field (font family, size, position, colour,
+ // text content, image overlay, player customisations, etc.) so the iframe
+ // can repopulate its UI exactly as the user left it.
+ if (value.advancedConfig) {
+ var restored = JSON.parse(JSON.stringify(value.advancedConfig));
+
+ // Always sync the asset to the current selection in case the user
+ // changed the video between Advanced sessions.
+ if (!restored.formValues) restored.formValues = {};
+ if (!restored.formValues.video) restored.formValues.video = {};
+ restored.formValues.video.asset = {
+ public_id: asset.public_id,
+ format: asset.format || '',
+ derived: asset.derived || []
+ };
+
+ // Ensure formValues.video.transStr is populated for the iframe preview.
+ // New saves already have it set (injected in the 'done' handler).
+ // For old saves (made before that fix), playerConf was stripped by SFCC
+ // storage so it won't be present here — fall back to the widget-level
+ // transformationOverride which is preserved in the parent value.
+ if (!restored.formValues.video.transStr) {
+ if (restored.playerConf) {
+ try {
+ var pc = JSON.parse(restored.playerConf);
+ if (pc.transStr) {
+ restored.formValues.video.transStr = pc.transStr;
+ }
+ } catch (e) { /* keep empty if playerConf is unparseable */ }
+ }
+ if (!restored.formValues.video.transStr && value.transformationOverride) {
+ restored.formValues.video.transStr = value.transformationOverride;
+ }
+ }
+
+ return restored;
+ }
+
+ // First-time open – no previous Advanced config, start from scratch.
+ return {
+ formValues: {
+ video: {
+ asset: {
+ public_id: asset.public_id,
+ format: asset.format || '',
+ derived: asset.derived || []
+ },
+ transStr: value.transformationOverride || ''
+ }
+ }
+ };
+}
+
(() => {
subscribe('sfcc:ready', async ({ value, config }) => {
+ console.log('[CLD AdvancedVideo] sfcc:ready raw value:', JSON.stringify(value));
let iFrame = document.createElement('iframe');
- let val = encodeURIComponent(JSON.stringify(cldUtils.dehydrate(value)));
+ let iframeValue = normalizeValueForIframe(value);
+ console.log('[CLD AdvancedVideo] iframeValue (before dehydrate):', JSON.stringify(iframeValue));
+ let dehydrated = cldUtils.dehydrate(iframeValue);
+ console.log('[CLD AdvancedVideo] dehydrated (goes into URL):', JSON.stringify(dehydrated));
+ let val = encodeURIComponent(JSON.stringify(dehydrated));
iFrame.src = config.iFrameEnv + '/video?cloudName=' + config.cloudName + '&value=' + val;
iFrame.id = 'video-form';
iFrame.setAttribute('frameborder', 0);
@@ -14,7 +96,9 @@
let ifrm = document.querySelector('iframe');
window.addEventListener('message', (event) => {
if (event.origin === config.iFrameEnv) {
- handleIframeMessage(event.data, ifrm, value, config);
+ // Pass iframeValue (old-format shape) so the 'ready' handler
+ // posts back data the iframe can actually parse for its UI.
+ handleIframeMessage(event.data, ifrm, iframeValue, config);
}
});
parentIFrame.getPageInfo((i) => {
@@ -45,11 +129,29 @@ const handleIframeMessage = (message, ifrm, value = null) => {
break;
case 'ready':
value.origin = 'ready';
+ console.log('[CLD AdvancedVideo] posting ready value to iframe:', JSON.stringify(value));
ifrm.contentWindow.postMessage(value, '*');
break;
case 'done':
delete message.action;
var val = Object.assign({}, message);
+ // playerConf.transStr is the authoritative transformation string.
+ // formValues.video.transStr is always empty in the iframe's done
+ // payload. SFCC strips every top-level key except formValues/
+ // breakpoints before delivering the value to the parent breakout
+ // callback, so playerConf itself disappears after storage.
+ // Inject transStr into formValues.video NOW, while playerConf is
+ // still available locally, so it survives the round-trip.
+ if (val.playerConf) {
+ try {
+ var doneConf = JSON.parse(val.playerConf);
+ if (doneConf.transStr) {
+ if (!val.formValues) val.formValues = {};
+ if (!val.formValues.video) val.formValues.video = {};
+ val.formValues.video.transStr = doneConf.transStr;
+ }
+ } catch (e) { /* keep as-is on parse error */ }
+ }
emit({
type: 'sfcc:value',
payload: val
diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/imageFormWidget.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/imageFormWidget.js
new file mode 100644
index 0000000..6e0e40f
--- /dev/null
+++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/imageFormWidget.js
@@ -0,0 +1,340 @@
+(() => {
+ var state = {
+ formValues: { mobile: null, tablet: null, desktop: null },
+ transformationOverrides: { mobile: '', tablet: '', desktop: '' },
+ activeFormFactor: 'desktop',
+ config: null,
+ ml: null
+ };
+
+ var FORM_FACTORS = ['mobile', 'tablet', 'desktop'];
+ var TAB_ORDER = ['desktop', 'mobile', 'tablet'];
+
+ // ── Inheritance ──────────────────────────────────────────────────────
+
+ function resolveAsset(formFactor) {
+ if (state.formValues[formFactor]) return state.formValues[formFactor];
+ for (var i = 0; i < FORM_FACTORS.length; i++) {
+ if (state.formValues[FORM_FACTORS[i]]) return state.formValues[FORM_FACTORS[i]];
+ }
+ return null;
+ }
+
+ function isInherited(formFactor) {
+ return !state.formValues[formFactor] && !!resolveAsset(formFactor);
+ }
+
+ // ── URL builders ─────────────────────────────────────────────────────
+
+ function buildDeliveryUrl(asset) {
+ if (!asset || !asset.public_id) return '';
+ var cname = state.config.cname;
+ var base = cname
+ ? 'https://' + cname.replace(/^https?:\/\//, '')
+ : 'https://res.cloudinary.com/' + state.config.cloudName;
+ var ext = asset.format ? '.' + asset.format : '';
+ return base + '/' + (asset.resource_type || 'image') + '/upload/' + asset.public_id + ext;
+ }
+
+ function buildThumbnailUrl(asset) {
+ if (!asset || !asset.public_id) return '';
+ var cloudName = asset.cloudName || state.config.cloudName;
+ var publicId = asset.public_id.replace(/\.[^/.]+$/, '');
+ return 'https://res.cloudinary.com/' + cloudName +
+ '/image/upload/w_400,h_160,c_fill,q_auto,f_jpg/' + publicId + '.jpg';
+ }
+
+ // ── SFCC emit ────────────────────────────────────────────────────────
+
+ function emitToSFCC() {
+ var hasAny = FORM_FACTORS.some(function (ff) { return !!state.formValues[ff]; });
+ emit({ type: 'sfcc:valid', payload: { valid: hasAny } });
+ emit({
+ type: 'sfcc:value',
+ payload: hasAny ? {
+ formValues: state.formValues,
+ transformationOverrides: state.transformationOverrides
+ } : null
+ });
+ }
+
+ // ── Viewport → form factor ───────────────────────────────────────────
+
+ function toFormFactor(viewport) {
+ if (!viewport) return 'desktop';
+ if (viewport.breakpoint) {
+ var bp = viewport.breakpoint.toLowerCase();
+ if (bp === 'mobile' || bp === 'tablet' || bp === 'desktop') return bp;
+ }
+ var w = viewport.width || 1024;
+ if (w <= 767) return 'mobile';
+ if (w <= 1023) return 'tablet';
+ return 'desktop';
+ }
+
+ // ── Initial value ────────────────────────────────────────────────────
+
+ function parseInitialValue(value) {
+ if (!value) return;
+ if (!value.formValues) return;
+ var fv = value.formValues;
+
+ // New per-form-factor format
+ if (fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined) {
+ state.formValues.mobile = fv.mobile || null;
+ state.formValues.tablet = fv.tablet || null;
+ state.formValues.desktop = fv.desktop || null;
+
+ // Restore per-device overrides (new map format)
+ if (value.transformationOverrides) {
+ FORM_FACTORS.forEach(function (ff) {
+ state.transformationOverrides[ff] = value.transformationOverrides[ff] || '';
+ });
+ return;
+ }
+
+ // Backward compat: single global transformationOverride → copy to all entries
+ var legacyOverride = value.transformationOverride ||
+ (value.studioConfig &&
+ value.studioConfig.transformation &&
+ value.studioConfig.transformation !== '[]'
+ ? value.studioConfig.transformation : '');
+
+ if (legacyOverride) {
+ FORM_FACTORS.forEach(function (ff) {
+ if (state.formValues[ff]) {
+ state.transformationOverrides[ff] = legacyOverride;
+ }
+ });
+ }
+ return;
+ }
+
+ // Legacy image.asset format → mobile slot
+ if (fv.image && fv.image.asset) {
+ var asset = Object.assign({}, fv.image.asset, { cloudName: state.config.cloudName });
+ state.formValues.mobile = { asset: asset, url: buildDeliveryUrl(asset) };
+ }
+ }
+
+ // ── Cloudinary MLW – SFCC breakout ───────────────────────────────────
+
+ function openMediaPicker() {
+ emit(
+ {
+ type: 'sfcc:breakout',
+ payload: { id: 'mediaPicker', title: 'Cloudinary Image' }
+ },
+ function (data) {
+ if (data && data.value) {
+ var asset = Object.assign({}, data.value, { cloudName: state.config.cloudName });
+ var url = buildDeliveryUrl(asset);
+ state.formValues[state.activeFormFactor] = { asset: asset, url: url };
+ render();
+ emitToSFCC();
+ }
+ }
+ );
+ }
+
+ // ── Render ───────────────────────────────────────────────────────────
+
+ function render() {
+ var root = document.getElementById('cld-widget-root');
+ if (!root) return;
+ root.innerHTML = buildHTML();
+ bindEvents();
+ }
+
+ function buildHTML() {
+ return buildTabsHTML() + buildPickerHTML() + buildFileRowHTML() + buildAdvancedSectionHTML();
+ }
+
+ function buildTabsHTML() {
+ var html = '
';
+ TAB_ORDER.forEach(function (ff) {
+ var isActive = ff === state.activeFormFactor;
+ var hasSelection = !!state.formValues[ff];
+ var label = ff.charAt(0).toUpperCase() + ff.slice(1);
+ html += '';
+ });
+ html += '
';
+ return html;
+ }
+
+ function buildPickerHTML() {
+ var entry = resolveAsset(state.activeFormFactor);
+ var asset = entry ? entry.asset : null;
+ var hasAsset = !!asset;
+ var inherited = isInherited(state.activeFormFactor);
+
+ return '';
+ }
+
+ function buildFileRowHTML() {
+ var entry = resolveAsset(state.activeFormFactor);
+ var asset = entry ? entry.asset : null;
+ var publicId = asset ? asset.public_id : '';
+
+ return '' +
+ '
' +
+ '
' +
+ (publicId
+ ? '
'
+ : '') +
+ '
';
+ }
+
+ function buildAdvancedSectionHTML() {
+ var override = state.transformationOverrides[state.activeFormFactor] || '';
+ var hasOverride = !!override;
+
+ return '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
';
+ }
+
+ // ── Studio Widget breakout ────────────────────────────────────────────
+
+ function openStudioWidget() {
+ var ff = state.activeFormFactor;
+ emit(
+ {
+ type: 'sfcc:breakout',
+ payload: { id: 'studioWidget', title: 'Cloudinary Studio Widget' }
+ },
+ function (data) {
+ console.log('[imageFormWidget] studioWidget callback:', JSON.stringify(data));
+ var val = data && data.value;
+ if (!val) return;
+
+ var studioResult = (val.formValues && val.formValues.studioResult)
+ || val.studioResult
+ || null;
+ if (!studioResult) return;
+
+ var trans = studioResult.transformation;
+ var override = (trans && trans !== '[]') ? trans : '';
+
+ state.transformationOverrides[ff] = override;
+ render();
+ emitToSFCC();
+ }
+ );
+ }
+
+ // ── Event binding ────────────────────────────────────────────────────
+
+ function bindEvents() {
+ document.querySelectorAll('.cld-ff-tab').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.activeFormFactor = btn.getAttribute('data-ff');
+ render();
+ });
+ });
+
+ var pickerBtn = document.getElementById('cld-picker-btn');
+ if (pickerBtn) pickerBtn.addEventListener('click', openMediaPicker);
+
+ var browseBtn = document.getElementById('cld-browse-btn');
+ if (browseBtn) browseBtn.addEventListener('click', openMediaPicker);
+
+ var advBtn = document.getElementById('cld-advanced-btn');
+ if (advBtn) {
+ advBtn.addEventListener('click', function () {
+ if (!advBtn.disabled) openStudioWidget();
+ });
+ }
+
+ // Transformation override textarea — stored per active form factor
+ var transInput = document.getElementById('cld-trans-override');
+ if (transInput) {
+ transInput.addEventListener('input', function () {
+ var val = transInput.value.trim();
+ state.transformationOverrides[state.activeFormFactor] = val;
+ var btn = document.getElementById('cld-advanced-btn');
+ if (btn) {
+ btn.disabled = !!val;
+ btn.setAttribute('aria-disabled', String(!!val));
+ }
+ emitToSFCC();
+ });
+ }
+
+ var clearBtn = document.getElementById('cld-clear-btn');
+ if (clearBtn) {
+ clearBtn.addEventListener('click', function () {
+ if (state.formValues[state.activeFormFactor]) {
+ state.formValues[state.activeFormFactor] = null;
+ } else {
+ for (var i = 0; i < FORM_FACTORS.length; i++) {
+ if (state.formValues[FORM_FACTORS[i]]) {
+ state.formValues[FORM_FACTORS[i]] = null;
+ break;
+ }
+ }
+ }
+ render();
+ emitToSFCC();
+ });
+ }
+ }
+
+ // ── Bootstrap ────────────────────────────────────────────────────────
+
+ subscribe('sfcc:ready', function (opts) {
+ state.config = opts.config;
+ state.activeFormFactor = toFormFactor(opts.viewport);
+ parseInitialValue(opts.value);
+
+ var root = document.createElement('div');
+ root.id = 'cld-widget-root';
+ document.body.appendChild(root);
+ render();
+ emitToSFCC();
+ });
+
+ subscribe('sfcc:viewport', function (viewport) {
+ state.activeFormFactor = toFormFactor(viewport);
+ render();
+ });
+})();
diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.css b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.css
new file mode 100644
index 0000000..75be694
--- /dev/null
+++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.css
@@ -0,0 +1,13 @@
+html,
+body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+#cld-studio-container {
+ width: 100%;
+ overflow: hidden;
+}
diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.js
new file mode 100644
index 0000000..d62478f
--- /dev/null
+++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.js
@@ -0,0 +1,173 @@
+/**
+ * studioWidget.js – SFCC Page Designer breakout editor
+ *
+ * Uses the official Cloudinary Studio Widget JS SDK:
+ * https://studio-widget.cloudinary.com/latest/all.js
+ */
+
+(() => {
+ subscribe('sfcc:ready', function ({ value, config }) {
+ // Capture emit at subscribe time — stable reference across async callbacks
+ var _emit = emit;
+
+ // Container the SDK will mount the widget into
+ var container = document.createElement('div');
+ container.id = 'cld-studio-container';
+ document.body.appendChild(container);
+
+ // Size the container to fill the modal viewport
+ parentIFrame.getPageInfo(function (info) {
+ var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
+ var chrome = 55 + 55 + (4 * rem);
+ var h = Math.max(Math.round(info.clientHeight - chrome), 400);
+
+ container.style.width = '100%';
+ container.style.height = h + 'px';
+ parentIFrame.size(h);
+
+ initWidget(_emit);
+ });
+
+ // ------------------------------------------------------------------
+ // Widget initialisation
+ // ------------------------------------------------------------------
+ function initWidget(emitFn) {
+ var widget = window.cloudinary.studioWidget({
+ cloudName: config.cloudName,
+ apiKey: config.apiKey,
+ appendTo: '#cld-studio-container'
+ });
+
+ var publicId = getPublicId(value);
+ if (publicId) {
+ widget.update({ publicIds: [publicId] });
+ }
+
+ widget.show();
+
+ // Destroy the widget cleanly when SFCC closes the breakout modal
+ window.addEventListener('pagehide', function () {
+ try { widget.destroy(); } catch (e) {}
+ });
+
+ // ── insert ────────────────────────────────────────────────────
+ widget.on('insert', function (payload) {
+ console.log('[CLD Studio] raw insert payload:', payload);
+
+ try {
+ var imageUrl = '';
+ var publicId = '';
+ var trans = '[]';
+
+ if (typeof payload === 'string') {
+ // SDK delivers the final delivery URL as a plain string
+ imageUrl = payload;
+ var parsed = parseCloudinaryUrl(payload);
+ publicId = parsed.publicId;
+ trans = parsed.transformation;
+ } else {
+ // Future-proof: handle object payload
+ var asset = payload;
+ if (payload && Array.isArray(payload.assets) && payload.assets.length) {
+ asset = payload.assets[0];
+ } else if (Array.isArray(payload) && payload.length) {
+ asset = payload[0];
+ }
+ imageUrl = asset.url || asset.imageUrl || asset.secure_url || '';
+ publicId = asset.public_id || asset.publicId || '';
+ trans = asset.transformation || asset.eager_transformation || '[]';
+ if (typeof trans !== 'string') {
+ try { trans = JSON.stringify(trans); } catch (e) { trans = '[]'; }
+ }
+ }
+
+ var result = {
+ formValues: {
+ studioResult: {
+ imageUrl: imageUrl,
+ transformation: trans,
+ public_id: publicId,
+ isTransformationOverride: true
+ }
+ }
+ };
+
+ emitFn({ type: 'sfcc:value', payload: result });
+
+ // Close the breakout modal by programmatically clicking
+ // SFCC's "Apply" button in the parent frame
+ setTimeout(function () {
+ try {
+ var buttons = window.parent.document.querySelectorAll('button');
+ for (var b = 0; b < buttons.length; b++) {
+ if (buttons[b].textContent.trim() === 'Apply') {
+ buttons[b].click();
+ break;
+ }
+ }
+ } catch (e) { /* cross-origin guard */ }
+ }, 50);
+
+ } catch (err) {
+ console.error('[CLD Studio] insert handler error:', err);
+ }
+ });
+
+ widget.on('close', function () {
+ console.log('[CLD Studio] widget closed by user');
+ });
+ }
+ });
+
+ function getPublicId(value) {
+ if (!value || !value.formValues) return '';
+ var fv = value.formValues;
+ var entry = fv.desktop || fv.tablet || fv.mobile;
+ if (entry && entry.asset && entry.asset.public_id) return entry.asset.public_id;
+ if (fv.image && fv.image.asset && fv.image.asset.public_id) return fv.image.asset.public_id;
+ return '';
+ }
+
+ /**
+ * Parse a Cloudinary delivery URL into its transformation string and public_id.
+ */
+ function parseCloudinaryUrl(url) {
+ var UPLOAD = '/upload/';
+ var idx = url.indexOf(UPLOAD);
+ if (idx === -1) return { publicId: '', transformation: '[]' };
+
+ var afterUpload = url.substring(idx + UPLOAD.length).split('?')[0];
+ var segments = afterUpload.split('/');
+
+ var transParts = [];
+ var pidParts = [];
+ var inPid = false;
+
+ for (var i = 0; i < segments.length; i++) {
+ var seg = segments[i];
+ if (!inPid && isTransformSegment(seg)) {
+ transParts.push(seg);
+ } else {
+ inPid = true;
+ pidParts.push(seg);
+ }
+ }
+
+ // Strip file extension from the last public_id segment
+ var last = pidParts[pidParts.length - 1] || '';
+ var dotIdx = last.lastIndexOf('.');
+ if (dotIdx !== -1) pidParts[pidParts.length - 1] = last.substring(0, dotIdx);
+
+ return {
+ publicId: pidParts.join('/'),
+ transformation: transParts.length ? transParts.join('/') : '[]'
+ };
+ }
+
+ function isTransformSegment(seg) {
+ var parts = seg.split(',');
+ return parts.every(function (p) {
+ return /^[a-z]{1,3}_/.test(p) || /^[a-z]{1,3}[A-Z]/.test(p);
+ });
+ }
+})();
diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.css b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.css
new file mode 100644
index 0000000..6a04d12
--- /dev/null
+++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.css
@@ -0,0 +1,291 @@
+/* ── Root ──────────────────────────────────────────────────────────────── */
+#cld-widget-root {
+ font-family: "Salesforce Sans", Arial, sans-serif;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: #080707;
+ box-sizing: border-box;
+}
+
+/* ── Form Factor Tabs ──────────────────────────────────────────────────── */
+.cld-ff-tabs {
+ display: flex;
+ padding: 0 8px;
+}
+
+.cld-ff-tab {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 7px 14px;
+ margin-bottom: 4px;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: none;
+ font-family: inherit;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #706e6b;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color 0.12s, border-color 0.12s;
+}
+
+.cld-ff-tab:first-child {
+ padding-left: 4px;
+}
+
+.cld-ff-tab:hover { color: #0070d2; }
+
+.cld-ff-tab.active {
+ color: #0070d2;
+ border-bottom-color: #0070d2;
+ font-weight: 700;
+}
+
+.cld-ff-tab-dot {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background-color: #4bca81;
+ flex-shrink: 0;
+}
+
+/* ── Media Picker Button ───────────────────────────────────────────────── */
+.cld-picker {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: calc(100% - 16px);
+ height: 200px;
+ margin: 0 8px 0;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ overflow: hidden;
+ transition: opacity 0.15s;
+}
+
+.cld-picker:hover { opacity: 0.9; }
+.cld-picker:focus-visible { outline: 2px solid #0070d2; outline-offset: -2px; }
+
+/* Empty state — dashed border hint */
+.cld-picker:not(.has-asset) {
+ border: 2px dashed #c9c7c5;
+ background-color: #fafaf9;
+ margin: 8px 0 0;
+}
+
+.cld-picker-thumb {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.cld-picker-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #fff;
+ font-size: 0.75rem;
+ font-weight: 600;
+ opacity: 0;
+ transition: opacity 0.15s;
+ pointer-events: none;
+}
+
+.cld-picker:not(.has-asset) .cld-picker-overlay,
+.cld-picker:hover .cld-picker-overlay,
+.cld-picker:focus-visible .cld-picker-overlay {
+ opacity: 1;
+}
+
+.cld-picker-badge {
+ position: absolute;
+ top: 6px;
+ font-size: 0.6rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ padding: 2px 7px;
+ border-radius: 3px;
+ pointer-events: none;
+}
+
+.cld-picker-badge--inherited {
+ right: 6px;
+ background: rgba(0, 0, 0, 0.55);
+ color: #fff;
+}
+
+.cld-picker-badge--type {
+ left: 6px;
+ background: rgba(0, 112, 210, 0.85);
+ color: #fff;
+}
+
+/* ── File row ──────────────────────────────────────────────────────────── */
+.cld-file-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px;
+}
+
+.cld-file-input {
+ flex: 1;
+ min-width: 0;
+ padding: 5px 8px;
+ border: 1px solid #dddbda;
+ border-radius: 4px;
+ background-color: #f3f2f2;
+ font-family: "Salesforce Sans", Arial, sans-serif;
+ font-size: 0.75rem;
+ color: #3e3e3c;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: default;
+}
+
+.cld-file-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 1px solid #dddbda;
+ border-radius: 4px;
+ background: #fff;
+ color: #706e6b;
+ cursor: pointer;
+ transition: border-color 0.12s, color 0.12s;
+}
+
+.cld-file-btn:hover {
+ border-color: #0070d2;
+ color: #0070d2;
+}
+
+.cld-file-btn--clear:hover {
+ border-color: #c23934;
+ color: #c23934;
+}
+
+/* ── Poster image & Player options sections ────────────────────────────── */
+.cld-section {
+ padding: 10px 8px 4px;
+ border-bottom: 1px solid #dddbda;
+}
+
+.cld-section-title {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: #080707;
+ margin-bottom: 8px;
+}
+
+.cld-radio-label,
+.cld-checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.8125rem;
+ color: #3e3e3c;
+ cursor: pointer;
+ padding: 3px 0;
+ user-select: none;
+}
+
+.cld-radio-label input[type="radio"],
+.cld-checkbox-label input[type="checkbox"] {
+ margin: 0;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+/* ── Advanced section ──────────────────────────────────────────────────── */
+.cld-section--advanced {
+ border-bottom: none;
+}
+
+.cld-advanced-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 32px;
+ padding: 0 16px;
+ margin-bottom: 12px;
+ border: 1px solid #dddbda;
+ border-radius: 4px;
+ background: #fff;
+ color: #0070d2;
+ font-family: inherit;
+ font-size: 0.8125rem;
+ font-weight: 400;
+ line-height: 1;
+ cursor: pointer;
+ transition: border-color 0.12s;
+}
+
+.cld-advanced-btn:hover:not(:disabled) {
+ border-color: #0070d2;
+}
+
+.cld-advanced-btn:disabled,
+.cld-advanced-btn[aria-disabled="true"] {
+ color: #c9c7c5;
+ cursor: not-allowed;
+ background: #fff;
+}
+
+.cld-adv-applied {
+ display: inline-block;
+ margin-bottom: 8px;
+ padding: 2px 8px;
+ border-radius: 3px;
+ background: #e3f3e3;
+ color: #2e7d32;
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+}
+
+.cld-adv-override {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.cld-adv-override-label {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: #080707;
+}
+
+.cld-adv-override-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 6px 8px;
+ border: 1px solid #dddbda;
+ border-radius: 4px;
+ background: #f3f2f2;
+ font-family: "Salesforce Sans", Arial, sans-serif;
+ font-size: 0.8125rem;
+ color: #3e3e3c;
+ resize: vertical;
+ min-height: 70px;
+}
+
diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.js
new file mode 100644
index 0000000..8f73320
--- /dev/null
+++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.js
@@ -0,0 +1,436 @@
+(() => {
+ var state = {
+ formValues: { mobile: null, tablet: null, desktop: null },
+ activeFormFactor: 'desktop',
+ config: null,
+ ml: null,
+ posterMode: 'auto', // 'auto' | 'first_frame'
+ playerOptions: { autoplay: false, muted: false, loop: false, controls: true },
+ transformationOverride: '', // manual URL-syntax override; mutually exclusive with Advanced
+ advancedConfig: null // full 'done' payload from the Advanced breakout
+ };
+
+ var FORM_FACTORS = ['mobile', 'tablet', 'desktop']; // inheritance fallback order
+ var TAB_ORDER = ['desktop', 'mobile', 'tablet']; // display order in the tab bar
+
+ // ── Inheritance ──────────────────────────────────────────────────────
+
+ function resolveAsset(formFactor) {
+ if (state.formValues[formFactor]) return state.formValues[formFactor];
+ for (var i = 0; i < FORM_FACTORS.length; i++) {
+ if (state.formValues[FORM_FACTORS[i]]) return state.formValues[FORM_FACTORS[i]];
+ }
+ return null;
+ }
+
+ function isInherited(formFactor) {
+ return !state.formValues[formFactor] && !!resolveAsset(formFactor);
+ }
+
+ // ── URL builders ─────────────────────────────────────────────────────
+
+ function buildDeliveryUrl(asset) {
+ if (!asset || !asset.public_id) return '';
+ var cname = state.config.cname;
+ var base = cname
+ ? 'https://' + cname.replace(/^https?:\/\//, '')
+ : 'https://res.cloudinary.com/' + state.config.cloudName;
+ var ext = asset.format ? '.' + asset.format : '';
+ return base + '/' + (asset.resource_type || 'image') + '/upload/' + asset.public_id + ext;
+ }
+
+ function buildThumbnailUrl(asset) {
+ if (!asset || !asset.public_id) return '';
+ var cloudName = asset.cloudName || state.config.cloudName;
+ var publicId = asset.public_id.replace(/\.[^/.]+$/, '');
+ var resourceType = asset.resource_type === 'video' ? 'video' : 'image';
+ return 'https://res.cloudinary.com/' + cloudName +
+ '/' + resourceType + '/upload/w_400,h_160,c_fill,q_auto,f_jpg/' + publicId + '.jpg';
+ }
+
+ // ── SFCC emit ────────────────────────────────────────────────────────
+
+ function emitToSFCC() {
+ var hasAny = FORM_FACTORS.some(function (ff) { return !!state.formValues[ff]; });
+ emit({ type: 'sfcc:valid', payload: { valid: hasAny } });
+ emit({
+ type: 'sfcc:value',
+ payload: hasAny ? {
+ formValues: state.formValues,
+ posterMode: state.posterMode,
+ playerOptions: state.playerOptions,
+ transformationOverride: state.transformationOverride,
+ advancedConfig: state.advancedConfig
+ } : null
+ });
+ }
+
+ // ── Viewport → form factor ───────────────────────────────────────────
+
+ function toFormFactor(viewport) {
+ if (!viewport) return 'desktop';
+ if (viewport.breakpoint) {
+ var bp = viewport.breakpoint.toLowerCase();
+ if (bp === 'mobile' || bp === 'tablet' || bp === 'desktop') return bp;
+ }
+ var w = viewport.width || 1024;
+ if (w <= 767) return 'mobile';
+ if (w <= 1023) return 'tablet';
+ return 'desktop';
+ }
+
+ // ── Initial value ────────────────────────────────────────────────────
+
+ function parseInitialValue(value) {
+ if (!value) return;
+
+ // Restore poster mode, player options and transformation override if previously saved
+ if (value.posterMode) {
+ state.posterMode = value.posterMode;
+ }
+ if (value.playerOptions) {
+ state.playerOptions = Object.assign({}, state.playerOptions, value.playerOptions);
+ }
+ if (value.transformationOverride) {
+ state.transformationOverride = value.transformationOverride;
+ }
+ if (value.advancedConfig) {
+ state.advancedConfig = value.advancedConfig;
+ }
+
+ if (!value.formValues) return;
+ var fv = value.formValues;
+
+ // New per-form-factor format
+ if (fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined) {
+ state.formValues.mobile = fv.mobile || null;
+ state.formValues.tablet = fv.tablet || null;
+ state.formValues.desktop = fv.desktop || null;
+ return;
+ }
+
+ // Legacy format: migrate video.asset → mobile slot
+ if (fv.video && fv.video.asset) {
+ var asset = Object.assign({}, fv.video.asset, { cloudName: state.config.cloudName });
+ state.formValues.mobile = { asset: asset, url: buildDeliveryUrl(asset) };
+ }
+ }
+
+ // ── Cloudinary MLW – SFCC breakout ───────────────────────────────────
+
+ function openMediaPicker() {
+ emit(
+ {
+ type: 'sfcc:breakout',
+ payload: { id: 'mediaPicker', title: 'Cloudinary Video' }
+ },
+ function (data) {
+ if (data && data.value) {
+ var asset = Object.assign({}, data.value, { cloudName: state.config.cloudName });
+ var url = buildDeliveryUrl(asset);
+ state.formValues[state.activeFormFactor] = { asset: asset, url: url };
+ render();
+ emitToSFCC();
+ }
+ }
+ );
+ }
+
+ // ── Render ───────────────────────────────────────────────────────────
+
+ function render() {
+ var root = document.getElementById('cld-widget-root');
+ if (!root) return;
+ root.innerHTML = buildHTML();
+ bindEvents();
+ }
+
+ function buildHTML() {
+ return (
+ buildTabsHTML() +
+ buildPickerHTML() +
+ buildFileRowHTML() +
+ buildPosterSectionHTML() +
+ buildPlayerOptionsSectionHTML() +
+ buildAdvancedSectionHTML()
+ );
+ }
+
+ function buildPosterSectionHTML() {
+ var isFirstFrame = state.posterMode === 'first_frame';
+ return '';
+ }
+
+ function buildPlayerOptionsSectionHTML() {
+ var opts = state.playerOptions;
+ var items = [
+ { key: 'autoplay', label: 'Autoplay' },
+ { key: 'muted', label: 'Start muted' },
+ { key: 'loop', label: 'Loop' },
+ { key: 'controls', label: 'Show controls' }
+ ];
+ var html = '' +
+ '
Player options
';
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+ html += '
';
+ }
+ html += '
';
+ return html;
+ }
+
+ function buildTabsHTML() {
+ var html = '';
+ TAB_ORDER.forEach(function (ff) {
+ var isActive = ff === state.activeFormFactor;
+ var hasSelection = !!state.formValues[ff];
+ var label = ff.charAt(0).toUpperCase() + ff.slice(1);
+ html += '';
+ });
+ html += '
';
+ return html;
+ }
+
+ function buildPickerHTML() {
+ var entry = resolveAsset(state.activeFormFactor);
+ var asset = entry ? entry.asset : null;
+ var hasAsset = !!asset;
+ var inherited = isInherited(state.activeFormFactor);
+ var isVideo = asset && asset.resource_type === 'video';
+
+ return '';
+ }
+
+ function buildFileRowHTML() {
+ var entry = resolveAsset(state.activeFormFactor);
+ var asset = entry ? entry.asset : null;
+ var publicId = asset ? asset.public_id : '';
+ var inherited = isInherited(state.activeFormFactor);
+
+ return '' +
+ '
' +
+ '
' +
+ (publicId
+ ? '
'
+ : '') +
+ '
';
+ }
+
+ function buildAdvancedSectionHTML() {
+ var hasOverride = !!state.transformationOverride;
+ return '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
';
+ }
+
+ function openAdvancedConfig() {
+ // Build a minimal value object the advancedVideoForm iframe can use
+ var resolved = resolveAsset(state.activeFormFactor);
+ var asset = resolved ? resolved.asset : null;
+ var breakoutValue = {
+ cloudName: state.config.cloudName,
+ publicId: asset ? asset.public_id : '',
+ transStr: state.transformationOverride || ''
+ };
+ emit(
+ {
+ type: 'sfcc:breakout',
+ payload: { id: 'advancedConfig', title: 'Cloudinary Video Advanced Configuration', value: breakoutValue }
+ },
+ function (data) {
+ console.log('[CLD Widget] Advanced breakout callback data:', JSON.stringify(data));
+ if (data && data.value) {
+ var returned = data.value;
+ console.log('[CLD Widget] Advanced returned value:', JSON.stringify(returned));
+
+ // Store the full payload so the iframe can restore all overlay
+ // settings (font family, size, position, etc.) on re-open.
+ state.advancedConfig = returned;
+
+ // Extract the transformation string for server-side application.
+ // Priority: formValues.video.transStr (injected in 'done' handler,
+ // survives SFCC storage) > playerConf.transStr (still present in
+ // the raw payload before SFCC strips it) > top-level transStr.
+ var transStr = '';
+ if (returned.formValues && returned.formValues.video && returned.formValues.video.transStr) {
+ transStr = returned.formValues.video.transStr;
+ } else if (returned.playerConf) {
+ try { transStr = JSON.parse(returned.playerConf).transStr || ''; } catch (e) { /* ignore */ }
+ } else if (typeof returned.transStr === 'string') {
+ transStr = returned.transStr;
+ }
+ state.transformationOverride = transStr;
+ render();
+ emitToSFCC();
+ }
+ }
+ );
+ }
+
+ // ── Event binding ────────────────────────────────────────────────────
+
+ function bindEvents() {
+ // Tab clicks
+ document.querySelectorAll('.cld-ff-tab').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ state.activeFormFactor = btn.getAttribute('data-ff');
+ render();
+ });
+ });
+
+ // Picker button (thumbnail area)
+ var pickerBtn = document.getElementById('cld-picker-btn');
+ if (pickerBtn) {
+ pickerBtn.addEventListener('click', openMediaPicker);
+ }
+
+ // Browse button (folder icon)
+ var browseBtn = document.getElementById('cld-browse-btn');
+ if (browseBtn) {
+ browseBtn.addEventListener('click', openMediaPicker);
+ }
+
+ // Advanced button
+ var advBtn = document.getElementById('cld-advanced-btn');
+ if (advBtn) {
+ advBtn.addEventListener('click', function () {
+ if (!advBtn.disabled) openAdvancedConfig();
+ });
+ }
+
+ // Transformation override textarea — disables Advanced while it has a value
+ var transInput = document.getElementById('cld-trans-override');
+ if (transInput) {
+ transInput.addEventListener('input', function () {
+ state.transformationOverride = transInput.value.trim();
+ var btn = document.getElementById('cld-advanced-btn');
+ if (btn) {
+ btn.disabled = !!state.transformationOverride;
+ btn.setAttribute('aria-disabled', String(!!state.transformationOverride));
+ }
+ emitToSFCC();
+ });
+ }
+
+ // Poster image radios
+ document.querySelectorAll('input[name="cld-poster"]').forEach(function (radio) {
+ radio.addEventListener('change', function () {
+ state.posterMode = radio.value;
+ emitToSFCC();
+ });
+ });
+
+ // Player option checkboxes
+ document.querySelectorAll('input[data-opt]').forEach(function (cb) {
+ cb.addEventListener('change', function () {
+ state.playerOptions[cb.getAttribute('data-opt')] = cb.checked;
+ emitToSFCC();
+ });
+ });
+
+ // Clear button (trash icon)
+ var clearBtn = document.getElementById('cld-clear-btn');
+ if (clearBtn) {
+ clearBtn.addEventListener('click', function () {
+ // If this slot has its own value, clear it directly.
+ // If it's inherited, clear the source slot instead.
+ if (state.formValues[state.activeFormFactor]) {
+ state.formValues[state.activeFormFactor] = null;
+ } else {
+ for (var i = 0; i < FORM_FACTORS.length; i++) {
+ if (state.formValues[FORM_FACTORS[i]]) {
+ state.formValues[FORM_FACTORS[i]] = null;
+ break;
+ }
+ }
+ }
+ render();
+ emitToSFCC();
+ });
+ }
+ }
+
+ // ── Bootstrap ────────────────────────────────────────────────────────
+
+ subscribe('sfcc:ready', function (opts) {
+ var value = opts.value;
+ var config = opts.config;
+ var viewport = opts.viewport;
+
+ state.config = config;
+ state.activeFormFactor = toFormFactor(viewport);
+
+ // Seed player option defaults from site preference (passed via editor.configuration)
+ if (config && config.playerOptions) {
+ try {
+ var prefDefaults = JSON.parse(config.playerOptions);
+ state.playerOptions = Object.assign({}, state.playerOptions, prefDefaults);
+ } catch (e) { /* keep hard-coded defaults */ }
+ }
+
+ parseInitialValue(value);
+
+ var root = document.createElement('div');
+ root.id = 'cld-widget-root';
+ document.body.appendChild(root);
+ render();
+ emitToSFCC();
+ });
+
+ subscribe('sfcc:viewport', function (viewport) {
+ state.activeFormFactor = toFormFactor(viewport);
+ render();
+ });
+})();
diff --git a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js
index b902ec3..afdb09e 100644
--- a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js
+++ b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js
@@ -1,5 +1,48 @@
'use strict';
+/**
+ * Returns true when the value was saved by the new per-form-factor widget.
+ * @param {Object} val SFCC value
+ * @returns {boolean}
+ */
+function isNewFormat(val) {
+ if (!val || !val.formValues) return false;
+ var fv = val.formValues;
+ return fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined;
+}
+
+/**
+ * Resolves the active form-factor based on the current request device.
+ * @returns {'mobile'|'tablet'|'desktop'}
+ */
+function getActiveFormFactor() {
+ try {
+ var device = request.getHttpHeaders().get('cloudinary-form-factor');
+ if (device) return device;
+ if (request.device) {
+ if (request.device.mobile) return 'mobile';
+ if (request.device.tablet) return 'tablet';
+ }
+ } catch (e) { // eslint-disable-line no-empty-blocks
+ }
+ return 'desktop';
+}
+
+/**
+ * Resolves the { asset, url } entry for the current device using the
+ * Mobile → Tablet → Desktop inheritance fallback.
+ * @param {Object} formValues
+ * @returns {{ asset: Object, url: string }|null}
+ */
+function resolveEntry(formValues) {
+ var order = ['mobile', 'tablet', 'desktop'];
+ var ff = getActiveFormFactor();
+ if (formValues[ff]) return formValues[ff];
+ for (var i = 0; i < order.length; i++) {
+ if (formValues[order[i]]) return formValues[order[i]];
+ }
+ return null;
+}
/**
* Replaces the global transformations so if they change
@@ -79,8 +122,94 @@ module.exports.preRender = function (context, editorId) {
var URLUtils = require('dw/web/URLUtils');
var constants = require('~/cartridge/experience/utils/cloudinaryPDConstants').cloudinaryPDConstants;
var currentSite = require('dw/system/Site').getCurrent();
-
+
var viewmodel = {};
+ var val = context.content[editorId];
+
+ if (empty(val)) return viewmodel;
+
+ // ── New per-form-factor format ────────────────────────────────────────
+ if (isNewFormat(val)) {
+ var entry = resolveEntry(val.formValues);
+ if (!entry || !entry.asset) return viewmodel;
+
+ var asset = entry.asset;
+ var publicId = asset.public_id;
+ var cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName');
+ var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME');
+
+ var baseUrl = (cname && cname !== 'res.cloudinary.com')
+ ? 'https://' + cname.replace(/^https?:\/\//, '')
+ : 'https://res.cloudinary.com/' + cloudName;
+
+ // Transformation override: fall back through save-format generations
+ var legacyStudioTrans = val.studioConfig &&
+ val.studioConfig.transformation &&
+ val.studioConfig.transformation !== '[]'
+ ? val.studioConfig.transformation : '';
+ var transformationOverride = val.transformationOverride
+ || legacyStudioTrans
+ || '';
+ var isOverride = !!transformationOverride;
+
+ // Build transformation string for the delivery URL
+ var transPart = '';
+ var transformationArr = [];
+ if (isOverride) {
+ transPart = transformationOverride;
+ transformationArr = [{ raw_transformation: transformationOverride }];
+ } else {
+ // Apply global image transformations from site preferences
+ var globalObj = {};
+ var dprPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformationsDPR');
+ var fmtPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformationsFormat');
+ var qualPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformationsQuality');
+ var rawPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformations');
+
+ if (dprPref && dprPref.getValue() !== 'none') {
+ globalObj.dpr = dprPref.getValue();
+ }
+ if (fmtPref && fmtPref.getValue() !== 'none') {
+ globalObj.fetchFormat = fmtPref.getValue();
+ }
+ if (qualPref && qualPref.getValue() !== 'none') {
+ globalObj.quality = qualPref.getValue();
+ }
+ if (rawPref) {
+ globalObj.raw_transformation = rawPref;
+ }
+
+ // Build URL-syntax string for the delivery URL
+ var urlParts = [];
+ if (globalObj.dpr) urlParts.push('dpr_' + globalObj.dpr);
+ if (globalObj.fetchFormat) urlParts.push('f_' + globalObj.fetchFormat);
+ if (globalObj.quality) urlParts.push('q_' + globalObj.quality);
+ if (rawPref) urlParts.push(rawPref);
+ transPart = urlParts.join(',');
+ transformationArr = [globalObj];
+ }
+
+ var ext = asset.format ? '.' + asset.format : '';
+ var imageUrl = baseUrl + '/image/upload/' + (transPart ? transPart + '/' : '') + publicId + ext;
+
+ viewmodel.id = idSafeString(publicId.replace(/\//g, '-') + randomString(12));
+ viewmodel.publicId = publicId;
+ viewmodel.cloudName = cloudName;
+ if (cname && cname !== 'res.cloudinary.com') {
+ viewmodel.cname = cname;
+ }
+ viewmodel.CloudinaryPageDesignerCNAME = constants.SITE_PREFS.CloudinaryPageDesignerCNAME;
+
+ viewmodel.src = imageUrl + constants.CLD_TRACKING_PARAM;
+ viewmodel.placeholder = imageUrl + constants.CLD_TRACKING_PARAM;
+ viewmodel.transformation = JSON.stringify(transformationArr);
+ viewmodel.isTransformationOverride = isOverride;
+ viewmodel.srcset = generateBreakPoints(viewmodel);
+
+ return viewmodel;
+ }
+
+ // ── Legacy format ─────────────────────────────────────────────────────
if (context.content[editorId] && context.content[editorId].imageUrl) {
var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME');
viewmodel.id = idSafeString(context.content[editorId].public_id + randomString(12));
diff --git a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js
index 99fe538..1bbcdf4 100644
--- a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js
+++ b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js
@@ -167,6 +167,50 @@ function hasVideo(val) {
return val.formValues && val.formValues.video && val.formValues.video.asset;
}
+/**
+ * Returns true when the value was saved by the new per-form-factor widget.
+ * @param {Object} val SFCC value
+ * @returns {boolean}
+ */
+function isNewFormat(val) {
+ if (!val || !val.formValues) return false;
+ var fv = val.formValues;
+ return fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined;
+}
+
+/**
+ * Resolves the active form-factor string based on the current request device.
+ * @returns {'mobile'|'tablet'|'desktop'}
+ */
+function getActiveFormFactor() {
+ try {
+ var device = request.getHttpHeaders().get('cloudinary-form-factor');
+ if (device) return device;
+ if (request.device) {
+ if (request.device.mobile) return 'mobile';
+ if (request.device.tablet) return 'tablet';
+ }
+ } catch (e) { // eslint-disable-line no-empty-blocks
+ }
+ return 'desktop';
+}
+
+/**
+ * Resolves the { asset, url } entry for the current device, applying the
+ * Mobile → Tablet → Desktop inheritance fallback.
+ * @param {Object} formValues
+ * @returns {{ asset: Object, url: string }|null}
+ */
+function resolveEntry(formValues) {
+ var order = ['mobile', 'tablet', 'desktop'];
+ var ff = getActiveFormFactor();
+ if (formValues[ff]) return formValues[ff];
+ for (var i = 0; i < order.length; i++) {
+ if (formValues[order[i]]) return formValues[order[i]];
+ }
+ return null;
+}
+
/**
* Get cloudinary video transformations.
*
@@ -257,13 +301,16 @@ function mergePlayerConfig(videoPlayerOptions, configurations, overrideGlobalCon
return mergedConfigs;
}
-module.exports.preRender = function (context, editorId) {
- var currentSite = require('dw/system/Site').getCurrent();
- var constants = require('~/cartridge/experience/utils/cloudinaryPDConstants').cloudinaryPDConstants;
-
- var val = context.content[editorId];
- var viewmodel = {};
- if (!empty(val) && !val.playerConf.empty && hasVideo(val)) {
+/**
+ * Handles pre-render for the legacy playerConf format
+ * @param {Object} context - component context
+ * @param {Object} val - the editor value
+ * @param {Object} viewmodel - the viewmodel to populate
+ * @param {Object} currentSite - the current SFCC site
+ * @param {Object} constants - cloudinary PD constants
+ */
+function preRenderLegacy(context, val, viewmodel, currentSite, constants) {
+ if (!val.playerConf.empty && hasVideo(val)) {
var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME');
var conf = JSON.parse(val.playerConf);
var publicId = conf.publicId;
@@ -272,18 +319,18 @@ module.exports.preRender = function (context, editorId) {
conf.sourceType = format;
}
if (cname !== 'res.cloudinary.com') {
- viewmodel.cname = cname;
+ viewmodel.cname = cname; // eslint-disable-line no-param-reassign
}
conf = videoPlayerConfigs(conf);
conf.playerConfig.posterOptions = {};
- const queryParams = {};
+ var queryParams = {};
queryParams[constants.CLD_TRACKING_PARAM.slice(1).split('=')[0]] = constants.CLD_TRACKING_PARAM.slice(1).split('=')[1];
conf.sourceConfig.queryParams = queryParams;
- viewmodel.cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName');
- viewmodel.public_id = publicId;
- viewmodel.id = idSafeString(randomString(16));
- const videoPosterTrans = getCloudinaryVideoTransformation(context);
- const videoPlayerOptions = getContentVideoPlayerOptions();
+ viewmodel.cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName'); // eslint-disable-line no-param-reassign
+ viewmodel.public_id = publicId; // eslint-disable-line no-param-reassign
+ viewmodel.id = idSafeString(randomString(16)); // eslint-disable-line no-param-reassign
+ var videoPosterTrans = getCloudinaryVideoTransformation(context);
+ var videoPlayerOptions = getContentVideoPlayerOptions();
if (videoPosterTrans) {
conf.playerConfig.posterOptions.transformation = videoPosterTrans;
delete conf.sourceConfig.poster;
@@ -291,10 +338,112 @@ module.exports.preRender = function (context, editorId) {
if ('videoAspectRatio' in context.content) {
conf.playerConfig.aspectRatio = context.content.videoAspectRatio;
}
- const mergedConfig = mergePlayerConfig(videoPlayerOptions, conf.playerConfig, context.content.overrideGlobalConfigs);
- var widgetOptions = { playerConfig: mergedConfig, sourceConfig: conf.sourceConfig };
- viewmodel.widgetOptions = JSON.stringify(widgetOptions);
+ var mergedConfig = mergePlayerConfig(videoPlayerOptions, conf.playerConfig, context.content.overrideGlobalConfigs);
+ viewmodel.widgetOptions = JSON.stringify({ playerConfig: mergedConfig, sourceConfig: conf.sourceConfig }); // eslint-disable-line no-param-reassign
}
+}
+
+module.exports.preRender = function (context, editorId) {
+ var currentSite = require('dw/system/Site').getCurrent();
+ var constants = require('~/cartridge/experience/utils/cloudinaryPDConstants').cloudinaryPDConstants;
+
+ var val = context.content[editorId];
+ var viewmodel = {};
+
+ if (empty(val)) return viewmodel;
+
+ // ── New per-form-factor format ────────────────────────────────────────
+ if (isNewFormat(val)) {
+ var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME');
+ if (cname !== 'res.cloudinary.com') {
+ viewmodel.cname = cname;
+ }
+
+ var videoPosterTrans = getCloudinaryVideoTransformation(context);
+ var videoPlayerOptions = getContentVideoPlayerOptions();
+
+ // Per-component transformation override (raw URL-syntax string)
+ var transformationOverride = val.transformationOverride || '';
+
+ // Per-component poster mode: 'first_frame' appends a so_0 transformation
+ var posterMode = val.posterMode || 'auto';
+ var effectivePosterTrans = (posterMode === 'first_frame')
+ ? videoPosterTrans.concat([{ raw_transformation: 'so_0' }])
+ : videoPosterTrans;
+
+ // Per-component player options saved by the widget
+ var componentPlayerOpts = val.playerOptions || {};
+ var playerOptKeys = ['autoplay', 'muted', 'loop', 'controls'];
+
+ var ffOrder = ['mobile', 'tablet', 'desktop'];
+ var ffOptions = {};
+
+ for (var fi = 0; fi < ffOrder.length; fi++) {
+ var ff = ffOrder[fi];
+ var ffEntry = val.formValues[ff];
+ if (!ffEntry || !ffEntry.asset) continue;
+
+ var ffAsset = ffEntry.asset;
+ var ffConf = {
+ publicId: ffAsset.public_id,
+ transStr: transformationOverride,
+ isTransformationOverride: !!transformationOverride,
+ playerConfig: {},
+ sourceConfig: { transformation: transformationOverride ? [{ raw_transformation: transformationOverride }] : [] }
+ };
+
+ var ffFormat = currentSite.getCustomPreferenceValue('CloudinaryVideoFormat');
+ if (ffFormat !== null && ffFormat.value !== 'none') {
+ ffConf.sourceType = ffFormat;
+ }
+
+ ffConf = videoPlayerConfigs(ffConf);
+ ffConf.playerConfig.posterOptions = {};
+
+ var ffQueryParams = {};
+ ffQueryParams[constants.CLD_TRACKING_PARAM.slice(1).split('=')[0]] = constants.CLD_TRACKING_PARAM.slice(1).split('=')[1];
+ ffConf.sourceConfig.queryParams = ffQueryParams;
+
+ if (effectivePosterTrans && effectivePosterTrans.length) {
+ ffConf.playerConfig.posterOptions.transformation = effectivePosterTrans;
+ }
+ if ('videoAspectRatio' in context.content) {
+ ffConf.playerConfig.aspectRatio = context.content.videoAspectRatio;
+ }
+
+ // Apply per-component player options (autoplay, muted, loop, controls)
+ for (var pi = 0; pi < playerOptKeys.length; pi++) {
+ var pk = playerOptKeys[pi];
+ if (pk in componentPlayerOpts) {
+ ffConf.playerConfig[pk] = !!componentPlayerOpts[pk];
+ }
+ }
+
+ var ffMergedConfig = mergePlayerConfig(videoPlayerOptions, ffConf.playerConfig, context.content.overrideGlobalConfigs);
+ ffOptions[ff] = {
+ public_id: ffAsset.public_id,
+ widgetOptions: JSON.stringify({ playerConfig: ffMergedConfig, sourceConfig: ffConf.sourceConfig })
+ };
+ }
+
+ // Check at least one form factor has an asset
+ var hasAny = ffOptions.mobile || ffOptions.tablet || ffOptions.desktop;
+ if (!hasAny) return viewmodel;
+
+ // Server-side default: prefer desktop → tablet → mobile (client-side JS will override)
+ var defaultOpt = ffOptions.desktop || ffOptions.tablet || ffOptions.mobile;
+
+ viewmodel.cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName');
+ viewmodel.public_id = defaultOpt.public_id;
+ viewmodel.id = idSafeString(randomString(16));
+ viewmodel.widgetOptions = defaultOpt.widgetOptions;
+ viewmodel.formFactorOptions = JSON.stringify(ffOptions);
+
+ return viewmodel;
+ }
+
+ // ── Legacy format ─────────────────────────────────────────────────────
+ preRenderLegacy(context, val, viewmodel, currentSite, constants);
return viewmodel;
};
diff --git a/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js b/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js
index eb1ac7b..531bd4a 100644
--- a/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js
+++ b/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js
@@ -1,3 +1,35 @@
+/**
+ * Resolves the correct widgetOptions and public_id for the current viewport
+ * when per-form-factor options are present.
+ * Breakpoints match the editor widget: mobile ≤ 767, tablet ≤ 1023, desktop > 1023.
+ * Falls back Mobile → Tablet → Desktop (matching editor inheritance order).
+ */
+function resolvePlayerOptions(player) {
+ var widgetOptions = player.widgetOptions;
+ var publicId = player.public_id;
+
+ if (player.formFactorOptions) {
+ try {
+ var ffOptions = JSON.parse(player.formFactorOptions);
+ var w = window.innerWidth;
+ var ff = w <= 767 ? 'mobile' : (w <= 1023 ? 'tablet' : 'desktop');
+ // Apply same inheritance fallback as the editor (Mobile → Tablet → Desktop)
+ var selected = ffOptions[ff] ||
+ ffOptions['mobile'] ||
+ ffOptions['tablet'] ||
+ ffOptions['desktop'];
+ if (selected) {
+ widgetOptions = selected.widgetOptions;
+ publicId = selected.public_id;
+ }
+ } catch (e) {
+ // fall through to defaults already set above
+ }
+ }
+
+ return { widgetOptions: widgetOptions, publicId: publicId };
+}
+
function initializeCloudinaryPlayers() {
let conf = {
cloud_name: window.cloudName
@@ -10,9 +42,10 @@ function initializeCloudinaryPlayers() {
window.players.forEach(player => {
if (player && player.widgetOptions) {
- const pCnf = JSON.parse(player.widgetOptions);
+ const resolved = resolvePlayerOptions(player);
+ const pCnf = JSON.parse(resolved.widgetOptions);
const p = cld.videoPlayer(player.id, pCnf.playerConfig);
- p.source(player.public_id, pCnf.sourceConfig);
+ p.source(resolved.publicId, pCnf.sourceConfig);
p.on('error', function (e) {
const error = e.Player.videojs.error();
if (error && error.code === 10) {