diff --git a/src/Lifecycle/PluginDataRemover.php b/src/Lifecycle/PluginDataRemover.php new file mode 100644 index 00000000..efdffa75 --- /dev/null +++ b/src/Lifecycle/PluginDataRemover.php @@ -0,0 +1,76 @@ + + */ + public const PLUGIN_TABLES = [ + DoctrineDbalUcpOAuthStore::ACCESS_TOKEN_TABLE, + DoctrineDbalUcpOAuthStore::REFRESH_TOKEN_TABLE, + DoctrineDbalUcpOAuthStore::CODE_TABLE, + DoctrineDbalCheckoutCompletionStore::TABLE, + DoctrineDbalUcpConfigRepository::TABLE, + ]; + + /** + * @var list + */ + public const CONFIG_DOMAINS = [ + UcpConfigService::DOMAIN, + SwagAgenticCommerce::OPEN_AI_PRODUCT_EXPORT_CONFIG_DOMAIN, + SwagAgenticCommerce::GOOGLE_PRODUCT_EXPORT_CONFIG_DOMAIN, + ]; + + public static function removeAll(Connection $connection): void + { + foreach (self::PLUGIN_TABLES as $table) { + $connection->executeStatement(\sprintf('DROP TABLE IF EXISTS `%s`', $table)); + } + + foreach (self::CONFIG_DOMAINS as $domain) { + $connection->executeStatement( + 'DELETE FROM `system_config` WHERE `configuration_key` LIKE :domain', + ['domain' => $domain.'%'], + ); + } + + OnboardingDismissal::reset($connection); + } +} diff --git a/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js b/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js index 0bee8883..cdd2c411 100644 --- a/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js +++ b/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js @@ -32,6 +32,24 @@ class UcpAdminApiService extends ApiService { return this.httpClient.post(this.basePath(salesChannelId, '/profile-preview'), payload, this.options()); } + // Applies one exposure payload to several channels; responds 200 with a + // per-channel outcome even when some of them failed. + bulkEnable(payload) { + return this.httpClient.post('/_admin/ucp/onboarding/bulk-enable', payload, this.options()); + } + + // Creates one agentic feed channel per { storefrontSalesChannelId, provider } + // pair; responds 200 with a per-pair outcome even when some of them failed. + createFeedChannels(payload) { + return this.httpClient.post('/_admin/ucp/onboarding/feed-channels', payload, this.options()); + } + + // The whole settings page in one read: status, the three setup steps, and + // every offerable channel with its outstanding findings. + getReadiness() { + return this.httpClient.get('/_admin/ucp/onboarding/readiness', this.options()); + } + basePath(salesChannelId, suffix = '') { return `/_admin/ucp/sales-channels/${encodeURIComponent(salesChannelId)}${suffix}`; } diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js b/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js index fbec29eb..9b18dc53 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/acl/index.js @@ -52,6 +52,14 @@ Shopware.Service('privileges').addPrivilegeMappingEntry({ editor: { privileges: [ 'system_config:update', + 'system_config:create', + // Creating agentic feed channels from the settings wizard. + 'sales_channel:create', + 'sales_channel:update', + 'product_export:create', + 'product_export:read', + 'product_stream:create', + 'product_stream:read', ], dependencies: [ 'ucp.viewer', diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-i18n.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-i18n.js new file mode 100644 index 00000000..ce89776a --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-i18n.js @@ -0,0 +1,33 @@ +/** + * Translates outside a component. + * + * `$t` is installed on the Vue app's `globalProperties` (Vue 3) or on the root + * instance itself (Vue 2), and code running in a router hook has neither `this` + * nor a component context. Falling back to the key keeps a missing translator + * from throwing inside a navigation guard, where it would surface as nothing at + * all. + */ +export function translateAdmin(key, params = undefined) { + const root = Shopware.Application?.getApplicationRoot?.(); + + if (!root) { + return key; + } + + const translate = root.config?.globalProperties?.$t + ?? root.config?.globalProperties?.$tc + ?? root.$t + ?? root.$tc; + + if (typeof translate !== 'function') { + return key; + } + + try { + const translated = params === undefined ? translate.call(root, key) : translate.call(root, key, params); + + return typeof translated === 'string' && translated !== '' ? translated : key; + } catch { + return key; + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-notification.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-notification.js new file mode 100644 index 00000000..4f286f1f --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-notification.js @@ -0,0 +1,25 @@ +import { readAdminStore } from './admin-store'; + +/** + * Raises an administration notification from outside a component. + * + * The notification mixin is not available in a router hook, and the store moved + * from a Vuex module to a Pinia store across the supported lanes: Pinia exposes + * `createNotification` on the store, Vuex only through a dispatch. + */ +export function createAdminNotification(notification) { + const store = readAdminStore('notification'); + if (typeof store?.createNotification === 'function') { + return store.createNotification(notification); + } + + try { + if (typeof Shopware.State?.dispatch === 'function') { + return Shopware.State.dispatch('notification/createNotification', notification); + } + } catch { + // Nothing can raise it; the banners remain the way in. + } + + return null; +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-session.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-session.js new file mode 100644 index 00000000..c659317e --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-session.js @@ -0,0 +1,20 @@ +import { readAdminStore } from './admin-store'; + +/** + * Reads the logged-in admin user, wherever this lane keeps the session store. + * + * An unreadable session is reported as "nobody logged in" rather than as an + * exception; see admin-store.js for why the read has to be guarded at all. + */ + +export function adminSession() { + return readAdminStore('session'); +} + +export function currentAdminUserId() { + return adminSession()?.currentUser?.id ?? null; +} + +export function isAdminLoggedIn() { + return Boolean(adminSession()?.currentUser); +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-store.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-store.js new file mode 100644 index 00000000..fa94ceba --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/admin-store.js @@ -0,0 +1,29 @@ +/** + * Reads an administration store across the Vuex and Pinia registries. + * + * `Shopware.Store.get(id)` THROWS for an id it does not know rather than + * returning undefined, so `Shopware.Store?.get?.(id) ?? Shopware.State?.get?.(id)` + * does not fall through on a lane that has the Pinia registry but keeps that + * store in Vuex: it throws out of the caller, and inside a router hook or a + * module registration that surfaces as nothing at all. Every read is guarded + * individually and an unreadable store is reported as null. + */ +export function readAdminStore(id) { + const readers = [ + () => Shopware.Store?.get?.(id), + () => Shopware.State?.get?.(id), + ]; + + for (const read of readers) { + try { + const store = read(); + if (store) { + return store; + } + } catch { + // Registry without this id on this lane; try the next one. + } + } + + return null; +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/feed-flow.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/feed-flow.js new file mode 100644 index 00000000..b194e511 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/feed-flow.js @@ -0,0 +1,226 @@ +export { hasFailures } from './prepare-flow'; + +/** + * The Choose / Review / Result flow that creates agentic feed channels. + * + * Pure functions over the readiness payload and the endpoint result, so the + * page stays a thin renderer. A selection entry is one + * `{ storefrontSalesChannelId, provider }` pair, the exact shape the endpoint + * takes. + */ + +const SNIPPET_ROOT = 'swagAgenticCommerce.feeds'; + +export const PROVIDER_OPEN_AI = 'open-ai'; +export const PROVIDER_GOOGLE = 'google'; + +export const FEED_PROVIDERS = [ + { + key: PROVIDER_OPEN_AI, + mark: 'AI', + labelKey: `${SNIPPET_ROOT}.providers.openAi.label`, + descriptionKey: `${SNIPPET_ROOT}.providers.openAi.description`, + format: 'JSONL', + }, + { + key: PROVIDER_GOOGLE, + mark: 'G', + labelKey: `${SNIPPET_ROOT}.providers.google.label`, + descriptionKey: `${SNIPPET_ROOT}.providers.google.description`, + format: 'XML', + }, +]; + +export const STEP_CHOOSE = 'choose'; +export const STEP_REVIEW = 'review'; +export const STEP_RESULT = 'result'; + +export const FEED_STEPS = [STEP_CHOOSE, STEP_REVIEW, STEP_RESULT]; + +export function providerByKey(key) { + return FEED_PROVIDERS.find((provider) => provider.key === key) ?? null; +} + +export function providerLabelKey(key) { + return providerByKey(key)?.labelKey ?? `${SNIPPET_ROOT}.providers.unknown`; +} + +export function feedStepItems(currentStep) { + const current = Math.max(FEED_STEPS.indexOf(currentStep), 0); + + return FEED_STEPS.map((step, index) => { + let state = 'todo'; + + if (index < current || (step === STEP_RESULT && currentStep === STEP_RESULT)) { + state = 'done'; + } else if (index === current) { + state = 'active'; + } + + return { key: step, number: index + 1, state, label: `${SNIPPET_ROOT}.steps.${step}` }; + }); +} + +function feedsByProvider(feeds) { + const list = Array.isArray(feeds) ? feeds : []; + + return FEED_PROVIDERS.reduce((carry, provider) => { + carry[provider.key] = list.find((feed) => feed?.provider === provider.key) ?? null; + + return carry; + }, {}); +} + +/** + * One row per Storefront channel. Headless and feed channels cannot back a + * product export, so they are never offered. + */ +export function feedRows(readiness) { + const channels = Array.isArray(readiness?.channels) ? readiness.channels : []; + + return channels + .filter((channel) => channel?.storefront === true && typeof channel?.id === 'string' && channel.id !== '') + .map((channel) => { + const domains = Array.isArray(channel.domains) ? channel.domains : []; + + return { + id: channel.id, + name: channel.name ?? '', + primaryDomain: domains[0]?.url ?? null, + hasDomain: domains.length > 0, + feeds: feedsByProvider(channel.feeds), + }; + }); +} + +export function isFeedSelectable(row, provider) { + return row?.hasDomain === true && !row?.feeds?.[provider]; +} + +export function defaultFeedSelection(rows = []) { + return rows.flatMap((row) => FEED_PROVIDERS + .filter((provider) => isFeedSelectable(row, provider.key)) + .map((provider) => ({ storefrontSalesChannelId: row.id, provider: provider.key }))); +} + +export function isFeedSelected(selection = [], storefrontSalesChannelId, provider) { + return selection.some((entry) => entry.storefrontSalesChannelId === storefrontSalesChannelId + && entry.provider === provider); +} + +export function toggleFeed(selection = [], storefrontSalesChannelId, provider, selected) { + const next = selection.filter((entry) => !(entry.storefrontSalesChannelId === storefrontSalesChannelId + && entry.provider === provider)); + + if (selected) { + next.push({ storefrontSalesChannelId, provider }); + } + + return next; +} + +export function canLeaveFeedStep(step, selection = []) { + return step === STEP_RESULT || selection.length > 0; +} + +export function buildFeedPayload(selection = []) { + return { + feeds: selection.map((entry) => ({ + storefrontSalesChannelId: entry.storefrontSalesChannelId, + provider: entry.provider, + })), + }; +} + +export function outcomeKey(outcome) { + return `${outcome?.storefrontSalesChannelId ?? ''}:${outcome?.provider ?? ''}`; +} + +/** + * The selection in row order, with the names the review step shows. + */ +export function selectedFeedItems(rows = [], selection = []) { + return rows.flatMap((row) => FEED_PROVIDERS + .filter((provider) => isFeedSelected(selection, row.id, provider.key)) + .map((provider) => ({ + key: outcomeKey({ storefrontSalesChannelId: row.id, provider: provider.key }), + storefrontName: row.name, + providerLabelKey: provider.labelKey, + }))); +} + +export function outcomesByKey(result) { + const results = Array.isArray(result?.results) ? result.results : []; + + return results.reduce((carry, outcome) => { + carry[outcomeKey(outcome)] = outcome; + + return carry; + }, {}); +} + +/** + * Result rows ready to render. The storefront name falls back to the readiness + * rows, since a `not_found` outcome cannot name the channel it did not find. + */ +export function feedResultRows(result, rows = []) { + const results = Array.isArray(result?.results) ? result.results : []; + + return results.map((outcome) => { + const row = rows.find((entry) => entry.id === outcome?.storefrontSalesChannelId); + + return { + key: outcomeKey(outcome), + status: outcome?.status ?? 'failed', + storefrontName: outcome?.storefrontSalesChannelName || row?.name || outcome?.storefrontSalesChannelId || '', + providerLabelKey: providerLabelKey(outcome?.provider), + salesChannelId: outcome?.salesChannelId ?? null, + salesChannelName: outcome?.salesChannelName ?? '', + feedUrl: outcome?.feedUrl ?? null, + message: outcome?.message ?? '', + }; + }); +} + + +/** + * Every existing feed, flattened for the settings page overview. + */ +export function connectedFeeds(readiness) { + return feedRows(readiness).flatMap((row) => FEED_PROVIDERS + .filter((provider) => row.feeds[provider.key] !== null) + .map((provider) => { + const feed = row.feeds[provider.key]; + + return { + key: outcomeKey({ storefrontSalesChannelId: row.id, provider: provider.key }), + storefrontName: row.name, + providerKey: provider.key, + providerLabelKey: provider.labelKey, + salesChannelId: feed.salesChannelId ?? null, + salesChannelName: feed.salesChannelName || row.name, + feedUrl: feed.feedUrl ?? null, + active: feed.active !== false, + }; + })); +} + +/** + * True while some storefront with a domain still lacks a provider feed, which + * keeps the settings task clickable after the first feed exists. + */ +export function hasMissingFeeds(readiness) { + return defaultFeedSelection(feedRows(readiness)).length > 0; +} + +/** + * Rejects when the Clipboard API is missing (non-secure context), so the + * caller can show one error path for both cases. + */ +export function copyText(text, clipboard = globalThis.navigator?.clipboard) { + if (typeof text !== 'string' || text === '' || typeof clipboard?.writeText !== 'function') { + return Promise.reject(new Error('Clipboard unavailable')); + } + + return clipboard.writeText(text); +} diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/onboarding-dismissal.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/onboarding-dismissal.js new file mode 100644 index 00000000..341cdd57 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/onboarding-dismissal.js @@ -0,0 +1,67 @@ +import { ONBOARDING_DISMISSAL_KEY } from './onboarding-state'; +import { currentAdminUserId } from './admin-session'; + +/** + * Per-user "do not offer the onboarding dialog again", in core's `user_config`. + * + * Mirrors what core's own UserConfigClass does, which the plugin cannot import: + * it is core TypeScript, and this administration bundle must stay esbuild-safe + * (no bare imports). Every path degrades to "not dismissed" rather than + * throwing, because a user without the `user_config` privileges should still be + * able to use the dialog; they just do not get their dismissal remembered. + */ + +function repository() { + return Shopware.Service('repositoryFactory')?.create?.('user_config') ?? null; +} + +function can(privilege) { + return Shopware.Service('acl')?.can?.(privilege) === true; +} + +export function readOnboardingDismissed() { + const userId = currentAdminUserId(); + const repo = repository(); + + if (!userId || !repo || !can('user_config:read')) { + return Promise.resolve(false); + } + + const criteria = new Shopware.Data.Criteria(1, 1); + criteria.addFilter(Shopware.Data.Criteria.equals('key', ONBOARDING_DISMISSAL_KEY)); + criteria.addFilter(Shopware.Data.Criteria.equals('userId', userId)); + + return repo + .search(criteria, Shopware.Context.api) + .then((result) => { + const entry = result?.first?.() ?? null; + + return Array.isArray(entry?.value) ? entry.value.includes(true) : Boolean(entry?.value); + }) + .catch(() => false); +} + +export function writeOnboardingDismissed() { + const userId = currentAdminUserId(); + const repo = repository(); + + if (!userId || !repo || !can('user_config:create') || !can('user_config:update')) { + return Promise.resolve(false); + } + + const entity = repo.create(Shopware.Context.api); + if (!entity) { + return Promise.resolve(false); + } + + Object.assign(entity, { + userId, + key: ONBOARDING_DISMISSAL_KEY, + value: [true], + }); + + return repo + .save(entity, Shopware.Context.api) + .then(() => true) + .catch(() => false); +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/onboarding-state.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/onboarding-state.js new file mode 100644 index 00000000..23adf498 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/onboarding-state.js @@ -0,0 +1,49 @@ +/** + * Whether this shop still needs to be offered onboarding, and where to send a + * merchant who accepts. + * + * Derived from the sales-channel list rather than stored, so a shop that removes + * its UCP setup is offered the flow again. The only persisted piece is the + * per-user dismissal in onboarding-dismissal.js. + */ + +const DISMISSAL_KEY = 'swag-agentic-commerce.onboarding.dismissed'; + +export const ONBOARDING_DISMISSAL_KEY = DISMISSAL_KEY; + +export const SETTINGS_ROUTE = 'sw.settings.agentic.commerce.index'; + +/** + * Onboarding is pending while no channel exposes UCP. + * + * @param {Array<{ ucp?: { active?: boolean } }>} channels + */ +export function needsOnboarding(channels = []) { + if (!Array.isArray(channels) || channels.length === 0) { + return false; + } + + return !channels.some((channel) => channel?.ucp?.active === true); +} + +/** + * Whether the boot hook should send the merchant to the settings page. + * + * Kept pure so the decision is testable without a router: the caller supplies + * the state, and separately remembers that it already offered once so an + * in-session route change cannot re-trigger it. + * + * @param {{ dismissed?: boolean, channels?: Array, currentRouteName?: string, alreadyOffered?: boolean }} state + */ +export function shouldOfferOnboarding({ dismissed = false, channels = [], currentRouteName = null, alreadyOffered = false } = {}) { + if (alreadyOffered || dismissed) { + return false; + } + + // Already looking at the page the offer would send them to. + if (currentRouteName === SETTINGS_ROUTE) { + return false; + } + + return needsOnboarding(channels); +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/prepare-flow.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/prepare-flow.js new file mode 100644 index 00000000..bbeff8c6 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/prepare-flow.js @@ -0,0 +1,115 @@ +/** + * The Understand / Select / Review flow that prepares sales channels. + * + * Kept as pure functions so the step machine, the payload and the result + * summary are testable without mounting the page. The page itself only holds + * `step`, `selectedIds` and the response. + */ + +import { toggleArrayValue } from './ucp-form-state'; + +export const STEP_UNDERSTAND = 'understand'; +export const STEP_SELECT = 'select'; +export const STEP_REVIEW = 'review'; + +export const PREPARE_STEPS = [STEP_UNDERSTAND, STEP_SELECT, STEP_REVIEW]; + +export function stepIndex(step) { + const index = PREPARE_STEPS.indexOf(step); + + return index === -1 ? 0 : index; +} + +export function nextStep(step) { + return PREPARE_STEPS[Math.min(stepIndex(step) + 1, PREPARE_STEPS.length - 1)]; +} + +export function previousStep(step) { + return PREPARE_STEPS[Math.max(stepIndex(step) - 1, 0)]; +} + +export function isFirstStep(step) { + return stepIndex(step) === 0; +} + +/** + * 'done' for a step already passed, 'active' for the current one, 'todo' ahead. + */ +export function stepState(step, currentStep) { + const position = stepIndex(step); + const current = stepIndex(currentStep); + + if (position < current) { + return 'done'; + } + + return position === current ? 'active' : 'todo'; +} + +export function stepItems(currentStep) { + return PREPARE_STEPS.map((step, index) => ({ + key: step, + number: index + 1, + state: stepState(step, currentStep), + label: `swagAgenticCommerce.prepare.steps.${step}`, + })); +} + +/** + * Only the channel ids travel. Capabilities and transports are deliberately + * omitted so the endpoint applies its own defaults, which is why the merchant + * never sees a capability picker. + */ +export function buildPreparePayload(selectedIds = []) { + return { salesChannelIds: [...selectedIds] }; +} + +export function canLeaveStep(step, selectedIds = []) { + if (step === STEP_SELECT || step === STEP_REVIEW) { + return selectedIds.length > 0; + } + + return true; +} + +export function toggleSelection(selectedIds = [], id, selected) { + return toggleArrayValue(selectedIds, id, selected); +} + +export function selectedRows(rows = [], selectedIds = []) { + return rows.filter((row) => selectedIds.includes(row.id)); +} + +/** + * Index the endpoint's per-channel outcomes by id so a row can show its own + * result without scanning the list. + */ +export function outcomesById(result) { + const results = Array.isArray(result?.results) ? result.results : []; + + return results.reduce((carry, outcome) => { + if (typeof outcome?.salesChannelId === 'string') { + carry[outcome.salesChannelId] = outcome; + } + + return carry; + }, {}); +} + +/** + * Findings worth showing after a run. INFO notes are dropped: they would bury + * the ones that need an action. + */ +export function actionableOutcomeFindings(result) { + const results = Array.isArray(result?.results) ? result.results : []; + + return results.flatMap((outcome) => { + const findings = Array.isArray(outcome?.findings) ? outcome.findings : []; + + return findings.filter((finding) => finding?.severity === 'warning' || finding?.severity === 'error'); + }); +} + +export function hasFailures(result) { + return (result?.failed ?? 0) > 0; +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/readiness-state.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/readiness-state.js new file mode 100644 index 00000000..32d34bb4 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/readiness-state.js @@ -0,0 +1,186 @@ +/** + * View model for the Agentic Commerce settings page. + * + * Everything here is a pure function of the `readiness` payload, so the page + * component stays a thin renderer and the rules that decide what a merchant is + * told are testable without mounting anything. + */ + +const SNIPPET_ROOT = 'swagAgenticCommerce.settings'; + +export const STATUS_SETUP_NEEDED = 'setup_needed'; +export const STATUS_ACTION_NEEDED = 'action_needed'; +export const STATUS_READY = 'ready'; + +export const TASK_INSTALLED = 'installed'; +export const TASK_PREPARED = 'prepared'; +export const TASK_CONNECTED = 'connected'; + +export function emptyReadiness() { + return { + status: STATUS_SETUP_NEEDED, + completedSteps: 0, + totalSteps: 3, + steps: { + installed: { done: false }, + prepared: { done: false, count: 0 }, + connected: { done: false, count: 0 }, + }, + channels: [], + }; +} + +export function normalizeReadiness(payload) { + const fallback = emptyReadiness(); + + if (!payload || typeof payload !== 'object') { + return fallback; + } + + return { + ...fallback, + ...payload, + steps: { ...fallback.steps, ...(payload.steps ?? {}) }, + channels: Array.isArray(payload.channels) ? payload.channels : [], + }; +} + +export function isReady(readiness) { + return readiness?.status === STATUS_READY; +} + +/** + * The pill reads `success` only when the backend says every step is done AND no + * channel has an error-level finding. Deliberately not derived from the step + * count alone: an exposed channel with no domain or no signing key completes the + * step and still cannot be reached by any agent. + */ +export function statusVariant(readiness) { + return isReady(readiness) ? 'success' : 'warning'; +} + +export function statusSnippet(readiness) { + const status = readiness?.status ?? STATUS_SETUP_NEEDED; + + return { + heading: `${SNIPPET_ROOT}.status.${status}.heading`, + copy: `${SNIPPET_ROOT}.status.${status}.copy`, + pill: `${SNIPPET_ROOT}.status.${status}.pill`, + }; +} + +export function progressPercent(readiness) { + const total = readiness?.totalSteps || 3; + const completed = Math.min(readiness?.completedSteps ?? 0, total); + + return Math.round((completed / total) * 100); +} + +/** + * The three rows of the readiness list. `connected` stays locked until at least + * one channel is prepared, because an Agentic Commerce channel feeds from a + * prepared storefront and creating one first leads nowhere. + */ +export function readinessTasks(readiness) { + const steps = readiness?.steps ?? emptyReadiness().steps; + const preparedDone = steps.prepared?.done === true; + + return [ + { + key: TASK_INSTALLED, + done: steps.installed?.done === true, + locked: false, + count: null, + pending: 0, + }, + { + key: TASK_PREPARED, + done: preparedDone, + locked: false, + count: steps.prepared?.count ?? 0, + // Channels added after the first run are still preparable, so the + // step stays actionable even once it counts as done. + pending: unpreparedChannels(readiness).length, + }, + { + key: TASK_CONNECTED, + done: steps.connected?.done === true, + locked: !preparedDone, + count: steps.connected?.count ?? 0, + pending: 0, + }, + ]; +} + +/** + * Which description a task row shows. A prepared step with channels still + * waiting gets its own line rather than the plain "done" one, otherwise a + * merchant who adds a storefront later is told everything is complete. + */ +export function taskDescriptionKey(task) { + if (task?.key === TASK_PREPARED && task?.done && (task?.pending ?? 0) > 0) { + return `${SNIPPET_ROOT}.tasks.prepared.pendingDescription`; + } + + return `${SNIPPET_ROOT}.tasks.${task?.key}.${task?.done ? 'doneDescription' : 'description'}`; +} + +export function taskActionKey(task) { + return task?.done + ? `${SNIPPET_ROOT}.tasks.${task?.key}.actionMore` + : `${SNIPPET_ROOT}.tasks.${task?.key}.action`; +} + +export function preparedChannels(readiness) { + return (readiness?.channels ?? []).filter((channel) => channel?.prepared === true); +} + +export function unpreparedChannels(readiness) { + return (readiness?.channels ?? []).filter((channel) => channel?.prepared !== true); +} + +/** + * Findings across every prepared channel that a merchant can act on. INFO notes + * are dropped: they would bury the ones that need attention. + */ +export function actionableChannelFindings(readiness) { + return (readiness?.channels ?? []).flatMap((channel) => { + const findings = Array.isArray(channel?.findings) ? channel.findings : []; + + return findings + .filter((finding) => finding?.severity === 'warning' || finding?.severity === 'error') + .map((finding) => ({ ...finding, salesChannelName: finding.salesChannelName || channel?.name || '' })); + }); +} + +export function hasChannelsToPrepare(readiness) { + return unpreparedChannels(readiness).length > 0; +} + +/** + * The rows the prepare flow offers. + * + * Only unprepared channels appear: a channel that already has UCP and its AI + * files is nothing for this flow to do, and offering it invites a merchant to + * rewrite a configuration they tuned. + */ +export function prepareRows(readiness) { + return unpreparedChannels(readiness) + .map((channel) => { + const domains = Array.isArray(channel?.domains) ? channel.domains : []; + + return { + id: channel?.id, + name: channel?.name ?? '', + activeProductCount: channel?.activeProductCount ?? 0, + domainCount: domains.length, + hasDomain: domains.length > 0, + primaryDomain: domains[0]?.url ?? null, + }; + }) + .filter((row) => typeof row.id === 'string' && row.id !== ''); +} + +export function defaultPrepareSelection(rows = []) { + return rows.map((row) => row.id); +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/settings-group.js b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/settings-group.js new file mode 100644 index 00000000..e411df17 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/agentic-commerce/settings-group.js @@ -0,0 +1,28 @@ +import { readAdminStore } from './admin-store'; +import { currentAdminVersion, isVersionAtLeast } from './admin-version'; + +/** + * Which Settings group the Agentic Commerce item belongs to. + * + * The groups differ by lane: `commerce` exists from 6.6 on, and older lanes keep + * shop-level items under `shop`. The store creates an unknown group on demand + * rather than rejecting it, so picking the wrong one does not error, it just + * files the item under a heading nothing else uses. + * + * Probing the store is the accurate signal, but module registration can run + * before the store exists, so the version is the fallback. Both answers agree on + * every supported lane. + */ + +export const GROUP_COMMERCE = 'commerce'; +export const GROUP_SHOP = 'shop'; + +export function settingsGroup() { + const groups = readAdminStore('settingsItems')?.settingsGroups; + + if (groups && typeof groups === 'object') { + return Object.prototype.hasOwnProperty.call(groups, GROUP_COMMERCE) ? GROUP_COMMERCE : GROUP_SHOP; + } + + return isVersionAtLeast(currentAdminVersion(), 6, 6) ? GROUP_COMMERCE : GROUP_SHOP; +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/index.js b/src/Resources/app/administration/src/extension/sw-sales-channel/index.js index 6230c5c3..aa4a1171 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/index.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/index.js @@ -12,3 +12,4 @@ import './view/sw-sales-channel-detail-agentic-commerce-integration'; import './view/sw-sales-channel-detail-agentic-commerce-statistics'; import './view/sw-sales-channel-detail-product-comparison'; import './routes.init'; +import './onboarding.init'; diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/onboarding.init.js b/src/Resources/app/administration/src/extension/sw-sales-channel/onboarding.init.js new file mode 100644 index 00000000..73b53c58 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/onboarding.init.js @@ -0,0 +1,131 @@ +import { shouldOfferOnboarding, SETTINGS_ROUTE } from './agentic-commerce/onboarding-state'; +import { readOnboardingDismissed, writeOnboardingDismissed } from './agentic-commerce/onboarding-dismissal'; +import { isAdminLoggedIn } from './agentic-commerce/admin-session'; +import { createAdminNotification } from './agentic-commerce/admin-notification'; +import { translateAdmin } from './agentic-commerce/admin-i18n'; + +/** + * Tells a merchant, once, that Agentic Commerce is installed and needs setting up. + * + * Shopware has no "plugin was just installed" event the administration can react + * to, so this derives the same thing: nothing exposes UCP yet, and this user has + * not dismissed the offer. It then raises a notification offering to open + * Settings > Agentic Commerce, rather than navigating there uninvited. + * + * The check runs from a router `afterEach`, not from `viewInitialized`, because + * the session's `currentUser` is still empty when the view initialises and + * `AclService.can()` answers false for everything until it is loaded. Hooking + * the first navigation that carries a logged-in user is what makes the ACL gate + * meaningful; evaluating earlier silently skipped the offer forever. + * + * Costs nothing for a user who cannot edit UCP config, one request for one who + * already dismissed the offer, and two while the shop is genuinely unconfigured. + */ + +const SNIPPET_ROOT = 'swagAgenticCommerce.onboarding.notification'; + +let evaluated = false; + +export function resetOnboardingOfferForTesting() { + evaluated = false; +} + +function canEditUcp() { + return Shopware.Service('acl')?.can?.('ucp.editor') === true; +} + +function currentRouteName(router) { + // Vue Router 4 exposes a ref, Vue Router 3 the route object itself. + return router.currentRoute?.value?.name ?? router.currentRoute?.name ?? null; +} + +function fetchSalesChannels() { + const service = Shopware.Service('ucpAdminApiService'); + if (!service?.getSalesChannels) { + return Promise.resolve([]); + } + + return service + .getSalesChannels() + .then((response) => response?.data?.data ?? []) + .catch(() => []); +} + +/** + * Stays open until the merchant answers it: an offer that disappears on a timer + * is one they cannot act on. + */ +export function buildOnboardingNotification() { + return { + variant: 'success', + growl: true, + autoClose: false, + title: translateAdmin(`${SNIPPET_ROOT}.title`), + message: translateAdmin(`${SNIPPET_ROOT}.message`), + actions: [ + { + label: translateAdmin(`${SNIPPET_ROOT}.setUpNow`), + route: { name: SETTINGS_ROUTE }, + }, + { + label: translateAdmin(`${SNIPPET_ROOT}.doItLater`), + method: () => { + void writeOnboardingDismissed(); + }, + }, + ], + }; +} + +export function offerOnboarding(router) { + // Not yet logged in: stay unevaluated so the next navigation tries again. + if (!router || evaluated || !isAdminLoggedIn()) { + return Promise.resolve(false); + } + + if (!canEditUcp()) { + evaluated = true; + + return Promise.resolve(false); + } + + evaluated = true; + + return readOnboardingDismissed() + .then((dismissed) => { + if (dismissed) { + return false; + } + + return fetchSalesChannels().then((channels) => { + if (!shouldOfferOnboarding({ + dismissed, + channels, + currentRouteName: currentRouteName(router), + })) { + return false; + } + + createAdminNotification(buildOnboardingNotification()); + + return true; + }); + }) + .catch(() => false); +} + +export function installOnboardingHook(router) { + if (!router || typeof router.afterEach !== 'function') { + return false; + } + + router.afterEach(() => { + void offerOnboarding(router); + }); + + return true; +} + +void Shopware.Application.viewInitialized.then(() => { + installOnboardingHook(Shopware.Application.view?.router); +}); \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/index.js b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/index.js index a6222b09..a446ed85 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/index.js +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/index.js @@ -1,4 +1,6 @@ import template from './sw-sales-channel-list.html.twig'; +import './sw-sales-channel-list.scss'; +import { needsOnboarding } from '../../agentic-commerce/onboarding-state'; const { Component } = Shopware; @@ -9,6 +11,10 @@ const { Component } = Shopware; * instead we fetch the per-channel active state via the existing * `ucpAdminApiService.getSalesChannels()` endpoint and look it up by id in the * `#column-ucpActive` slot. + * + * The same response decides whether to show the hint row that points at + * Settings > Agentic Commerce. It appears only while nothing is exposed, so it + * disappears once the merchant is set up. */ export const swSalesChannelListOverride = { template, @@ -18,6 +24,7 @@ export const swSalesChannelListOverride = { data() { return { ucpActiveMap: {}, + ucpSalesChannels: [], }; }, @@ -26,6 +33,17 @@ export const swSalesChannelListOverride = { return this.acl?.can?.('ucp.viewer') === true; }, + canManageUcpOnboarding() { + return this.acl?.can?.('ucp.editor') === true; + }, + + // Only while the shop has nothing exposed, so the row disappears once + // the merchant is set up. needsOnboarding() is false for an empty list, + // which also keeps it hidden until the channels have loaded. + showUcpOnboardingHint() { + return this.canManageUcpOnboarding && needsOnboarding(this.ucpSalesChannels); + }, + salesChannelColumns() { const columns = this.$super('salesChannelColumns'); if (!this.canViewUcpStatus || columns.some((column) => column.property === 'ucpActive')) { @@ -70,18 +88,20 @@ export const swSalesChannelListOverride = { return; } - service + return service .getSalesChannels() .then((response) => { + const salesChannels = response?.data?.data ?? []; const map = {}; - (response?.data?.data ?? []).forEach((salesChannel) => { + salesChannels.forEach((salesChannel) => { map[salesChannel.id] = Boolean(salesChannel.ucp?.active); }); this.ucpActiveMap = map; + this.ucpSalesChannels = salesChannels; }) .catch(() => {}); }, }, }; -Component.override('sw-sales-channel-list', swSalesChannelListOverride); +Component.override('sw-sales-channel-list', swSalesChannelListOverride); \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/sw-sales-channel-list.html.twig b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/sw-sales-channel-list.html.twig index 95851a1d..6ba8dbb1 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/sw-sales-channel-list.html.twig +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/sw-sales-channel-list.html.twig @@ -31,3 +31,23 @@ {% endblock %} + +{# Sits in sw-card's default slot, which mt-card renders above the #grid slot, + so the hint reads as part of the card instead of floating above it. #} +{% block sw_sales_channel_list_grid %} +
+ + {{ $t('swagAgenticCommerce.onboarding.hintNothingExposed') }} + + {{ $t('swagAgenticCommerce.onboarding.reopenAction') }} +
+ +{% parent %} +{% endblock %} + diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/sw-sales-channel-list.scss b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/sw-sales-channel-list.scss new file mode 100644 index 00000000..7946d1ff --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/page/sw-sales-channel-list/sw-sales-channel-list.scss @@ -0,0 +1,29 @@ +.sw-sales-channel-list { + &__ucp-onboarding { + align-items: center; + background: #f9fafb; + border: 1px solid #d1d9e0; + border-radius: 4px; + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + margin-bottom: 16px; + padding: 12px 16px; + } + + &__ucp-onboarding-text { + color: #52667a; + font-size: 13px; + } + + &__ucp-onboarding-link { + color: #0870ff; + font-size: 13px; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/de.json b/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/de.json index 99c2ceea..d4d259d1 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/de.json +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/de.json @@ -7,6 +7,27 @@ "columnUcp": "UCP", "ucpExposed": "Bereitgestellt", "ucpOff": "Aus" + }, + "onboarding": { + "title": "Agentic Commerce aktivieren", + "intro": "Wähle aus, in welchen Verkaufskanälen KI-Agenten einkaufen dürfen. Jeder ausgewählte Kanal wird über das Universal Commerce Protocol mit den unten stehenden Funktionen bereitgestellt und erhält automatisch einen Signaturschlüssel. Du kannst alles später unter Agentic Commerce im jeweiligen Kanal ändern.", + "channelsLabel": "Verkaufskanäle", + "noChannels": "Es kann noch kein Verkaufskanal bereitgestellt werden. UCP benötigt einen Storefront- oder Headless-Kanal, der einen Kauf abschließen kann.", + "hintNothingExposed": "Noch kein Verkaufskanal ist für KI-Agenten geöffnet.", + "noDomainWarning": "Keine Domain, Agenten erreichen diesen Kanal nicht", + "notNow": "Jetzt nicht", + "enableAction": "{count} Kanal/Kanäle aktivieren", + "reopenAction": "Agentic Commerce einrichten", + "resultSummary": "{enabled} aktiviert, {skipped} bereits eingerichtet, {failed} fehlgeschlagen.", + "statusEnabled": "Aktiviert", + "statusSkipped": "Bereits eingerichtet", + "warningsTitle": "Bitte prüfen", + "notification": { + "title": "Agentic Commerce erfolgreich installiert", + "message": "Die Erweiterung ist installiert und sollte jetzt konfiguriert werden. Du kannst sie jetzt einrichten oder später unter Einstellungen > Agentic Commerce.", + "setUpNow": "Jetzt einrichten", + "doItLater": "Später" + } } }, "sw-sales-channel": { diff --git a/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/en.json b/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/en.json index 1ca0e93f..ebf426b6 100644 --- a/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/en.json +++ b/src/Resources/app/administration/src/extension/sw-sales-channel/snippet/en.json @@ -7,6 +7,27 @@ "columnUcp": "UCP", "ucpExposed": "Exposed", "ucpOff": "Off" + }, + "onboarding": { + "title": "Enable Agentic Commerce", + "intro": "Choose which sales channels AI agents may shop from. Each selected channel is exposed over the Universal Commerce Protocol with the capabilities below, and gets a signing key automatically. You can change all of it later under Agentic Commerce on the channel.", + "channelsLabel": "Sales channels", + "noChannels": "No sales channel can be exposed yet. UCP needs a storefront or headless channel that can complete a purchase.", + "hintNothingExposed": "No sales channel is open to AI agents yet.", + "noDomainWarning": "No domain, agents cannot reach this channel", + "notNow": "Not now", + "enableAction": "Enable {count} channel(s)", + "reopenAction": "Set up Agentic Commerce", + "resultSummary": "{enabled} enabled, {skipped} already set up, {failed} failed.", + "statusEnabled": "Enabled", + "statusSkipped": "Already set up", + "warningsTitle": "Worth checking", + "notification": { + "title": "Agentic Commerce installed successfully", + "message": "The extension is installed and should now be configured. You can set it up now, or later under Settings > Agentic Commerce.", + "setUpNow": "Set up now", + "doItLater": "Do it later" + } } }, "sw-sales-channel": { diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js index 95c39bdb..c918a056 100644 --- a/src/Resources/app/administration/src/main.js +++ b/src/Resources/app/administration/src/main.js @@ -5,6 +5,7 @@ import './init/defaults.init'; import './core/service/api/ucp-admin.api.service.js'; import './extension/sw-customer/acl'; import './extension/sw-sales-channel'; +import './module/sw-settings-agentic-commerce'; // Core ships the list-column tracking module natively from 6.7.10+. if (!coreShipsAgenticCommerce) { diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/index.js b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/index.js new file mode 100644 index 00000000..1b5abf7c --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/index.js @@ -0,0 +1,57 @@ +import './page/sw-settings-agentic-commerce-index'; +import './page/sw-settings-agentic-commerce-prepare'; +import './page/sw-settings-agentic-commerce-feeds'; +import { settingsGroup } from '../../extension/sw-sales-channel/agentic-commerce/settings-group'; + +/** + * Settings > Agentic Commerce, the single place that answers whether this shop + * is ready for AI agents and what is left to do. + * + * Registered as a plugin module rather than by extending an existing settings + * page, so the route survives across the Vue Router 3 and 4 lanes without the + * child-route juggling routes.init.js needs for the sales-channel detail tabs. + */ +Shopware.Module.register('sw-settings-agentic-commerce', { + type: 'plugin', + name: 'settings-agentic-commerce', + title: 'swagAgenticCommerce.settings.title', + description: 'swagAgenticCommerce.settings.description', + color: '#9AA8B5', + icon: 'regular-sparkle', + favicon: 'icon-module-settings.png', + + routes: { + index: { + component: 'sw-settings-agentic-commerce-index', + path: 'index', + meta: { + parentPath: 'sw.settings.index', + privilege: 'ucp.viewer', + }, + }, + prepare: { + component: 'sw-settings-agentic-commerce-prepare', + path: 'prepare', + meta: { + parentPath: 'sw.settings.agentic.commerce.index', + privilege: 'ucp.editor', + }, + }, + feeds: { + component: 'sw-settings-agentic-commerce-feeds', + path: 'feeds', + meta: { + parentPath: 'sw.settings.agentic.commerce.index', + privilege: 'ucp.editor', + }, + }, + }, + + settingsItem: { + group: settingsGroup(), + to: 'sw.settings.agentic.commerce.index', + icon: 'regular-sparkle', + privilege: 'ucp.viewer', + label: 'swagAgenticCommerce.settings.title', + }, +}); \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/index.js b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/index.js new file mode 100644 index 00000000..7ca39911 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/index.js @@ -0,0 +1,234 @@ +import templateMt from './sw-settings-agentic-commerce-feeds.mt.html.twig'; +import templateSw from './sw-settings-agentic-commerce-feeds.sw.html.twig'; +import './sw-settings-agentic-commerce-feeds.scss'; +import { registerOrOverride } from '../../../../helper/register-or-override'; +import { useMtComponents } from '../../../../extension/sw-sales-channel/agentic-commerce/admin-version'; +import { extractApiErrorMessage } from '../../../../extension/sw-sales-channel/agentic-commerce/error-message.util'; +import { normalizeReadiness, emptyReadiness } from '../../../../extension/sw-sales-channel/agentic-commerce/readiness-state'; +import { + FEED_PROVIDERS, + STEP_CHOOSE, + STEP_REVIEW, + STEP_RESULT, + feedStepItems, + feedRows, + isFeedSelectable, + defaultFeedSelection, + isFeedSelected, + toggleFeed, + canLeaveFeedStep, + buildFeedPayload, + selectedFeedItems, + feedResultRows, + copyText, +} from '../../../../extension/sw-sales-channel/agentic-commerce/feed-flow'; + +const { Mixin } = Shopware; + +/** + * Settings > Agentic Commerce > Connect AI provider. + * + * One route with its own step state, like the prepare flow. Applying posts only + * storefront id and provider pairs: the endpoint copies everything else from + * the storefront, which is why the merchant is asked nothing beyond the click. + */ +registerOrOverride('sw-settings-agentic-commerce-feeds', { + template: useMtComponents() ? templateMt : templateSw, + + inject: ['ucpAdminApiService', 'acl'], + + mixins: [Mixin.getByName('notification')], + + data() { + return { + readiness: emptyReadiness(), + step: STEP_CHOOSE, + selection: [], + isLoading: true, + isApplying: false, + result: null, + }; + }, + + computed: { + canCreate() { + return this.acl?.can?.('ucp.editor') === true; + }, + + providers() { + return FEED_PROVIDERS; + }, + + steps() { + return feedStepItems(this.step); + }, + + isChooseStep() { + return this.step === STEP_CHOOSE; + }, + + isReviewStep() { + return this.step === STEP_REVIEW; + }, + + hasResult() { + return this.step === STEP_RESULT && this.result !== null; + }, + + rows() { + return feedRows(this.readiness); + }, + + selectableCount() { + return defaultFeedSelection(this.rows).length; + }, + + selectedCount() { + return this.selection.length; + }, + + selectedItems() { + return selectedFeedItems(this.rows, this.selection); + }, + + canContinue() { + return canLeaveFeedStep(this.step, this.selection); + }, + + applyDisabled() { + return !this.canCreate || this.isApplying || this.selection.length === 0; + }, + + resultRows() { + return feedResultRows(this.result, this.rows); + }, + + resultCounts() { + return { + created: this.result?.created ?? 0, + skipped: this.result?.skipped ?? 0, + failed: this.result?.failed ?? 0, + }; + }, + + }, + + created() { + this.loadReadiness(); + }, + + methods: { + loadReadiness() { + this.isLoading = true; + + return this.ucpAdminApiService + .getReadiness() + .then((response) => { + this.readiness = normalizeReadiness(response?.data?.data); + this.selection = defaultFeedSelection(this.rows); + }) + .catch((error) => { + this.createNotificationError({ message: extractApiErrorMessage(error) }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + + feedOf(row, provider) { + return row.feeds[provider.key] ?? null; + }, + + isSelectable(row, provider) { + return this.canCreate && isFeedSelectable(row, provider.key); + }, + + isSelected(row, provider) { + return isFeedSelected(this.selection, row.id, provider.key); + }, + + tileClasses(row, provider) { + return { + 'sw-agentic-feeds__tile--connected': this.feedOf(row, provider) !== null, + 'sw-agentic-feeds__tile--disabled': this.feedOf(row, provider) === null && !this.isSelectable(row, provider), + 'sw-agentic-feeds__tile--selected': this.isSelected(row, provider), + }; + }, + + onToggleTile(row, provider) { + if (!this.isSelectable(row, provider)) { + return; + } + + this.selection = toggleFeed(this.selection, row.id, provider.key, !this.isSelected(row, provider)); + }, + + selectAll() { + this.selection = defaultFeedSelection(this.rows); + }, + + deselectAll() { + this.selection = []; + }, + + channelRoute(salesChannelId) { + return { name: 'sw.sales.channel.detail', params: { id: salesChannelId } }; + }, + + copyFeedUrl(url) { + return copyText(url) + .then(() => { + this.createNotificationSuccess({ message: this.$t('swagAgenticCommerce.feeds.copied') }); + }) + .catch(() => { + this.createNotificationError({ message: this.$t('swagAgenticCommerce.feeds.copyFailed') }); + }); + }, + + onNext() { + if (!this.canContinue) { + return; + } + + this.step = STEP_REVIEW; + }, + + onBack() { + if (this.isChooseStep) { + this.onClose(); + + return; + } + + this.step = STEP_CHOOSE; + }, + + onClose() { + this.$router.push({ name: 'sw.settings.agentic.commerce.index' }); + }, + + apply() { + if (this.applyDisabled) { + return; + } + + this.isApplying = true; + + return this.ucpAdminApiService + .createFeedChannels(buildFeedPayload(this.selection)) + .then((response) => { + this.result = response?.data?.data ?? null; + + if (this.result !== null) { + this.step = STEP_RESULT; + } + }) + .catch((error) => { + this.createNotificationError({ message: extractApiErrorMessage(error) }); + }) + .finally(() => { + this.isApplying = false; + }); + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.mt.html.twig b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.mt.html.twig new file mode 100644 index 00000000..cd85f4a1 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.mt.html.twig @@ -0,0 +1,295 @@ +{% block sw_settings_agentic_commerce_feeds %} + + + {% block sw_settings_agentic_commerce_feeds_header %} + + {% endblock %} + + {% block sw_settings_agentic_commerce_feeds_content %} + + {% endblock %} + +{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.scss b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.scss new file mode 100644 index 00000000..fd0567e0 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.scss @@ -0,0 +1,485 @@ +$sw-agentic-border: #dfe3e8; +$sw-agentic-muted: #667085; +$sw-agentic-text: #344054; +$sw-agentic-brand: #189eff; +$sw-agentic-brand-bg: #eef7ff; +$sw-agentic-success: #11845b; +$sw-agentic-success-bg: #edf8f3; +$sw-agentic-error: #b42318; +$sw-agentic-error-bg: #fef3f2; +$sw-agentic-warning: #a8400f; +$sw-agentic-warning-bg: #fbe8e0; + +// sw-card-view caps its cards at this width and centres them. Anything rendered +// alongside a card has to match, or it floats out to the content edge. +$sw-agentic-card-width: 960px; + +.sw-agentic-feeds { + &__steps { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px 24px; + list-style: none; + margin: 0 auto 20px; + max-width: $sw-agentic-card-width; + padding: 0; + width: 100%; + } + + &__step { + align-items: center; + color: #98a2b3; + display: flex; + font-size: 13px; + gap: 8px; + + &--active { + color: $sw-agentic-text; + font-weight: 650; + + .sw-agentic-feeds__step-num { + background: $sw-agentic-brand; + color: #fff; + } + } + + &--done .sw-agentic-feeds__step-num { + background: $sw-agentic-success; + color: #fff; + } + } + + &__step-num { + align-items: center; + background: #eef1f4; + border-radius: 50%; + color: $sw-agentic-muted; + display: flex; + font-weight: 650; + height: 26px; + justify-content: center; + width: 26px; + } + + &__lede { + color: #475467; + line-height: 1.55; + margin: 0 0 20px; + } + + &__empty { + color: $sw-agentic-muted; + margin: 0; + } + + &__toolbar { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 16px; + } + + &__count { + background: $sw-agentic-brand-bg; + border-radius: 999px; + color: #1769aa; + font-size: 12px; + font-weight: 650; + padding: 5px 10px; + } + + &__toolbar-actions { + display: flex; + gap: 14px; + + a { + color: #0870ff; + font-size: 13px; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__storefront { + border: 1px solid $sw-agentic-border; + border-radius: 10px; + margin-bottom: 14px; + padding: 16px; + + &:last-child { + margin-bottom: 0; + } + } + + &__storefront-head { + align-items: baseline; + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + margin-bottom: 14px; + } + + &__storefront-name { + font-size: 15px; + font-weight: 700; + } + + &__storefront-domain { + color: $sw-agentic-muted; + font-size: 13px; + } + + &__warning { + background: $sw-agentic-warning-bg; + border-radius: 4px; + color: $sw-agentic-warning; + font-size: 12px; + padding: 2px 8px; + } + + &__tiles { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + } + + &__tile { + background: #fff; + border: 1px solid $sw-agentic-border; + border-radius: 8px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 14px; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; + + &:hover { + border-color: #9fd3fb; + } + + &:focus-visible { + outline: 2px solid $sw-agentic-brand; + outline-offset: 2px; + } + + &--selected { + background: #f7fbff; + border-color: $sw-agentic-brand; + box-shadow: 0 0 0 3px rgba(24, 158, 255, 0.12); + + &:hover { + border-color: $sw-agentic-brand; + } + + .sw-agentic-feeds__check { + background: $sw-agentic-brand; + border-color: $sw-agentic-brand; + color: #fff; + } + } + + &--connected { + background: #fbfefc; + border-color: #b7e4cf; + cursor: default; + + &:hover { + border-color: #b7e4cf; + } + } + + &--disabled { + background: #f9fafb; + cursor: not-allowed; + + &:hover { + border-color: $sw-agentic-border; + } + + .sw-agentic-feeds__tile-mark, + .sw-agentic-feeds__tile-text { + opacity: 0.55; + } + + .sw-agentic-feeds__check { + background: #f2f4f7; + } + } + } + + &__tile-head { + align-items: flex-start; + display: flex; + gap: 12px; + } + + &__tile-mark { + align-items: center; + border-radius: 8px; + display: flex; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + height: 36px; + justify-content: center; + width: 36px; + + &--open-ai { + background: #ecfdf3; + color: #067647; + } + + &--google { + background: $sw-agentic-brand-bg; + color: #0877c9; + } + } + + &__tile-text { + flex: 1; + min-width: 0; + } + + &__tile-title { + font-size: 14px; + font-weight: 650; + margin-bottom: 3px; + } + + &__tile-desc { + color: $sw-agentic-muted; + font-size: 13px; + line-height: 1.4; + } + + &__check { + align-items: center; + border: 1.5px solid #c4cdd6; + border-radius: 5px; + color: transparent; + display: flex; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + height: 20px; + justify-content: center; + transition: background-color 0.15s ease, border-color 0.15s ease; + width: 20px; + } + + &__tile-foot { + align-items: center; + display: flex; + gap: 10px; + } + + &__format { + background: #f2f4f7; + border-radius: 4px; + color: #475467; + font-family: monospace; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.02em; + padding: 2px 6px; + } + + &__hint { + color: $sw-agentic-muted; + font-size: 12px; + + &--selected { + color: #1769aa; + font-weight: 600; + } + } + + &__pill { + border-radius: 999px; + flex-shrink: 0; + font-size: 12px; + font-weight: 650; + padding: 4px 10px; + white-space: nowrap; + + &--success { + background: $sw-agentic-success-bg; + color: $sw-agentic-success; + } + + &--neutral { + background: #f2f4f7; + color: #475467; + } + + &--error { + background: $sw-agentic-error-bg; + color: $sw-agentic-error; + } + } + + &__tile-feed { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__url-row { + align-items: center; + background: #f9fafb; + border: 1px solid #eaecf0; + border-radius: 6px; + display: flex; + gap: 8px; + min-width: 0; + padding: 4px 4px 4px 10px; + } + + &__url { + color: #475467; + flex: 1; + font-family: monospace; + font-size: 12px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__copy { + align-items: center; + background: #fff; + border: 1px solid $sw-agentic-border; + border-radius: 5px; + color: $sw-agentic-text; + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + gap: 5px; + padding: 4px 8px; + + &:hover { + border-color: $sw-agentic-brand; + color: #0870ff; + } + + &:focus-visible { + outline: 2px solid $sw-agentic-brand; + outline-offset: 1px; + } + } + + &__link { + align-self: flex-start; + color: #0870ff; + font-size: 13px; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__review-head { + font-weight: 650; + margin-bottom: 12px; + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__tag { + background: #f2f4f7; + border-radius: 6px; + color: #475467; + font-size: 13px; + padding: 5px 9px; + } + + &__notice { + background: #fafcff; + border: 1px solid #dfe7ef; + border-radius: 8px; + color: #475467; + font-size: 13px; + line-height: 1.5; + margin-top: 18px; + padding: 13px 14px; + } + + &__results { + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr); + list-style: none; + margin: 0; + padding: 0; + } + + &__result { + border: 1px solid $sw-agentic-border; + border-left-width: 3px; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + padding: 12px 14px; + + &--created { + border-left-color: $sw-agentic-success; + } + + &--skipped { + border-left-color: #98a2b3; + } + + &--failed { + border-left-color: $sw-agentic-error; + } + } + + &__result-head { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; + } + + &__result-title { + font-weight: 650; + } + + &__result-channel { + color: $sw-agentic-muted; + font-size: 13px; + } + + &__result-message { + color: $sw-agentic-error; + font-size: 13px; + margin: 0; + } + + &__actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin: 8px auto 0; + max-width: $sw-agentic-card-width; + padding-bottom: 8px; + width: 100%; + } +} diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.sw.html.twig b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.sw.html.twig new file mode 100644 index 00000000..52991b2b --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-feeds/sw-settings-agentic-commerce-feeds.sw.html.twig @@ -0,0 +1,295 @@ +{% block sw_settings_agentic_commerce_feeds %} + + + {% block sw_settings_agentic_commerce_feeds_header %} + + {% endblock %} + + {% block sw_settings_agentic_commerce_feeds_content %} + + {% endblock %} + +{% endblock %} diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/index.js b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/index.js new file mode 100644 index 00000000..ec7e828b --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/index.js @@ -0,0 +1,157 @@ +import templateMt from './sw-settings-agentic-commerce-index.mt.html.twig'; +import templateSw from './sw-settings-agentic-commerce-index.sw.html.twig'; +import './sw-settings-agentic-commerce-index.scss'; +import { registerOrOverride } from '../../../../helper/register-or-override'; +import { useMtComponents } from '../../../../extension/sw-sales-channel/agentic-commerce/admin-version'; +import { extractApiErrorMessage } from '../../../../extension/sw-sales-channel/agentic-commerce/error-message.util'; +import { + normalizeReadiness, + emptyReadiness, + statusVariant, + statusSnippet, + progressPercent, + readinessTasks, + preparedChannels, + actionableChannelFindings, + hasChannelsToPrepare, + taskDescriptionKey, + taskActionKey, +} from '../../../../extension/sw-sales-channel/agentic-commerce/readiness-state'; +import { + connectedFeeds, + hasMissingFeeds, + copyText, +} from '../../../../extension/sw-sales-channel/agentic-commerce/feed-flow'; + +const { Mixin } = Shopware; + +/** + * Settings > Agentic Commerce. + * + * A thin renderer over the readiness endpoint: one read gives the status, the + * three setup steps and every offerable channel with its outstanding findings. + * All the rules about what that means live in readiness-state.js. + * + * The prepare and connect actions route to their own flows; this page re-reads + * the endpoint when it is entered again, so it reflects what was applied. + */ +registerOrOverride('sw-settings-agentic-commerce-index', { + template: useMtComponents() ? templateMt : templateSw, + + inject: ['ucpAdminApiService', 'acl'], + + mixins: [Mixin.getByName('notification')], + + data() { + return { + readiness: emptyReadiness(), + isLoading: true, + }; + }, + + computed: { + canPrepare() { + return this.acl?.can?.('ucp.editor') === true; + }, + + statusVariant() { + return statusVariant(this.readiness); + }, + + statusSnippets() { + return statusSnippet(this.readiness); + }, + + progressPercent() { + return progressPercent(this.readiness); + }, + + tasks() { + return readinessTasks(this.readiness); + }, + + preparedChannels() { + return preparedChannels(this.readiness); + }, + + findings() { + return actionableChannelFindings(this.readiness); + }, + + hasChannelsToPrepare() { + return hasChannelsToPrepare(this.readiness); + }, + + preparedCount() { + return this.readiness.steps?.prepared?.count ?? 0; + }, + + connectedFeeds() { + return connectedFeeds(this.readiness); + }, + + hasMissingFeeds() { + return hasMissingFeeds(this.readiness); + }, + + agenticChannelCount() { + return this.readiness.steps?.connected?.count ?? 0; + }, + }, + + created() { + this.loadReadiness(); + }, + + methods: { + loadReadiness() { + this.isLoading = true; + + return this.ucpAdminApiService + .getReadiness() + .then((response) => { + this.readiness = normalizeReadiness(response?.data?.data); + }) + .catch((error) => { + this.createNotificationError({ message: extractApiErrorMessage(error) }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + + taskLabel(task, suffix) { + return `swagAgenticCommerce.settings.tasks.${task.key}.${suffix}`; + }, + + taskDescription(task) { + return taskDescriptionKey(task); + }, + + taskAction(task) { + return taskActionKey(task); + }, + + openPrepare() { + this.$router.push({ name: 'sw.settings.agentic.commerce.prepare' }); + }, + + openFeeds() { + this.$router.push({ name: 'sw.settings.agentic.commerce.feeds' }); + }, + + channelRoute(salesChannelId) { + return { name: 'sw.sales.channel.detail', params: { id: salesChannelId } }; + }, + + copyFeedUrl(url) { + return copyText(url) + .then(() => { + this.createNotificationSuccess({ message: this.$t('swagAgenticCommerce.feeds.copied') }); + }) + .catch(() => { + this.createNotificationError({ message: this.$t('swagAgenticCommerce.feeds.copyFailed') }); + }); + }, + }, +}); \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.mt.html.twig b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.mt.html.twig new file mode 100644 index 00000000..c0d3c7ab --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.mt.html.twig @@ -0,0 +1,245 @@ +{% block sw_settings_agentic_commerce_index %} + + + {% block sw_settings_agentic_commerce_index_header %} + + {% endblock %} + + {% block sw_settings_agentic_commerce_index_content %} + + {% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.scss b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.scss new file mode 100644 index 00000000..5a9bd39e --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.scss @@ -0,0 +1,349 @@ +$sw-agentic-border: #dfe3e8; +$sw-agentic-muted: #667085; +$sw-agentic-success: #11845b; +$sw-agentic-success-bg: #edf8f3; +$sw-agentic-warning: #b54708; +$sw-agentic-warning-bg: #fff5eb; + +.sw-settings-agentic-commerce { + &__status-top { + align-items: flex-start; + display: flex; + gap: 20px; + justify-content: space-between; + } + + &__status-identity { + align-items: flex-start; + display: flex; + gap: 14px; + } + + &__status-icon { + align-items: center; + border-radius: 50%; + display: flex; + flex-shrink: 0; + font-size: 20px; + font-weight: 700; + height: 48px; + justify-content: center; + width: 48px; + + &--success { + background: $sw-agentic-success-bg; + color: $sw-agentic-success; + } + + &--warning { + background: $sw-agentic-warning-bg; + color: $sw-agentic-warning; + } + } + + &__status-heading { + font-size: 20px; + font-weight: 700; + margin-bottom: 6px; + } + + &__status-copy { + color: $sw-agentic-muted; + line-height: 1.5; + margin: 0; + max-width: 620px; + } + + &__pill { + border-radius: 999px; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + padding: 6px 10px; + white-space: nowrap; + + &--success { + background: $sw-agentic-success-bg; + color: $sw-agentic-success; + } + + &--warning { + background: $sw-agentic-warning-bg; + color: $sw-agentic-warning; + } + } + + &__readiness { + border-top: 1px solid #edf0f3; + margin-top: 22px; + padding-top: 18px; + } + + &__readiness-head { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 12px; + } + + &__readiness-title { + font-weight: 700; + } + + &__readiness-count { + color: $sw-agentic-muted; + font-size: 12px; + font-weight: 600; + } + + &__progress { + background: #eef1f4; + border-radius: 999px; + height: 8px; + margin-bottom: 16px; + overflow: hidden; + + span { + background: #189eff; + display: block; + height: 100%; + transition: width 0.2s ease; + } + } + + &__tasks { + display: grid; + gap: 10px; + list-style: none; + margin: 0; + padding: 0; + } + + &__task { + align-items: center; + border: 1px solid $sw-agentic-border; + border-radius: 8px; + display: flex; + gap: 12px; + padding: 13px 14px; + } + + &__task-dot { + align-items: center; + border-radius: 50%; + display: flex; + flex-shrink: 0; + font-weight: 700; + height: 28px; + justify-content: center; + width: 28px; + + &--done { + background: $sw-agentic-success-bg; + color: $sw-agentic-success; + } + + &--todo { + background: $sw-agentic-warning-bg; + color: $sw-agentic-warning; + } + } + + &__task-body { + flex: 1; + min-width: 0; + } + + &__task-title { + font-size: 14px; + font-weight: 650; + } + + &__task-desc { + color: $sw-agentic-muted; + font-size: 12px; + line-height: 1.4; + margin-top: 3px; + } + + &__task-state { + color: $sw-agentic-muted; + flex-shrink: 0; + font-size: 12px; + font-weight: 650; + } + + &__findings { + margin-top: 20px; + + ul { + margin: 0; + padding-left: 18px; + } + + li + li { + margin-top: 4px; + } + } + + &__prepared { + margin-top: 22px; + } + + &__prepared-title { + font-weight: 700; + margin-bottom: 8px; + } + + &__prepared-row { + align-items: center; + border-bottom: 1px solid #edf0f3; + display: flex; + gap: 16px; + justify-content: space-between; + padding: 12px 0; + + &:last-child { + border-bottom: 0; + } + } + + &__prepared-name { + font-size: 14px; + font-weight: 650; + } + + &__prepared-meta { + color: $sw-agentic-muted; + font-size: 12px; + margin-top: 3px; + } + + &__ready-pill { + background: $sw-agentic-success-bg; + border-radius: 6px; + color: $sw-agentic-success; + flex-shrink: 0; + font-size: 12px; + font-weight: 650; + padding: 5px 8px; + } + + &__connect-action { + flex-shrink: 0; + } + + &__feed-main { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + min-width: 0; + } + + &__feed-name { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__feed-link { + color: #0870ff; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__provider-badge { + border-radius: 999px; + font-size: 11px; + font-weight: 650; + padding: 3px 8px; + + &--open-ai { + background: #ecfdf3; + color: #067647; + } + + &--google { + background: #eef7ff; + color: #0877c9; + } + } + + &__inactive { + background: $sw-agentic-warning-bg; + border-radius: 999px; + color: $sw-agentic-warning; + font-size: 11px; + font-weight: 650; + padding: 3px 8px; + } + + &__feed-url-row { + align-items: center; + background: #f9fafb; + border: 1px solid #eaecf0; + border-radius: 6px; + display: flex; + gap: 8px; + max-width: 640px; + min-width: 0; + padding: 4px 4px 4px 10px; + } + + &__feed-url { + color: #475467; + flex: 1; + font-family: monospace; + font-size: 12px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__copy { + align-items: center; + background: #fff; + border: 1px solid $sw-agentic-border; + border-radius: 5px; + color: #344054; + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + gap: 5px; + padding: 4px 8px; + + &:hover { + border-color: #189eff; + color: #0870ff; + } + + &:focus-visible { + outline: 2px solid #189eff; + outline-offset: 1px; + } + } + + &__metric { + border-bottom: 1px solid #edf0f3; + display: flex; + font-size: 13px; + justify-content: space-between; + padding: 12px 0; + + &:last-child { + border-bottom: 0; + } + + span { + color: $sw-agentic-muted; + } + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.sw.html.twig b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.sw.html.twig new file mode 100644 index 00000000..2c2a1255 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-index/sw-settings-agentic-commerce-index.sw.html.twig @@ -0,0 +1,246 @@ +{% block sw_settings_agentic_commerce_index %} + + + {% block sw_settings_agentic_commerce_index_header %} + + {% endblock %} + + {% block sw_settings_agentic_commerce_index_content %} + + {% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/index.js b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/index.js new file mode 100644 index 00000000..d9022b42 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/index.js @@ -0,0 +1,199 @@ +import templateMt from './sw-settings-agentic-commerce-prepare.mt.html.twig'; +import templateSw from './sw-settings-agentic-commerce-prepare.sw.html.twig'; +import './sw-settings-agentic-commerce-prepare.scss'; +import { registerOrOverride } from '../../../../helper/register-or-override'; +import { useMtComponents } from '../../../../extension/sw-sales-channel/agentic-commerce/admin-version'; +import { extractApiErrorMessage } from '../../../../extension/sw-sales-channel/agentic-commerce/error-message.util'; +import { + normalizeReadiness, + emptyReadiness, + prepareRows, + defaultPrepareSelection, +} from '../../../../extension/sw-sales-channel/agentic-commerce/readiness-state'; +import { + STEP_UNDERSTAND, + STEP_SELECT, + STEP_REVIEW, + stepItems, + nextStep, + previousStep, + isFirstStep, + buildPreparePayload, + canLeaveStep, + toggleSelection, + selectedRows, + outcomesById, + actionableOutcomeFindings, + hasFailures, +} from '../../../../extension/sw-sales-channel/agentic-commerce/prepare-flow'; + +const { Mixin } = Shopware; + +/** + * Settings > Agentic Commerce > Prepare sales channels. + * + * One route holding its own step state rather than three routes: the same + * breadcrumb covers all three screens, and this plugin has already paid once for + * the Vue Router 3 versus 4 split (see routes.init.js), so one registration is + * one place for that to go wrong. + * + * Applying posts only the channel ids. Capabilities, transports and the profile + * domain are the endpoint's defaults, which is why none of them are on screen. + */ +registerOrOverride('sw-settings-agentic-commerce-prepare', { + template: useMtComponents() ? templateMt : templateSw, + + inject: ['ucpAdminApiService', 'acl'], + + mixins: [Mixin.getByName('notification')], + + data() { + return { + readiness: emptyReadiness(), + step: STEP_UNDERSTAND, + selectedIds: [], + isLoading: true, + isApplying: false, + result: null, + }; + }, + + computed: { + canPrepare() { + return this.acl?.can?.('ucp.editor') === true; + }, + + steps() { + return stepItems(this.step); + }, + + isUnderstandStep() { + return this.step === STEP_UNDERSTAND; + }, + + isSelectStep() { + return this.step === STEP_SELECT; + }, + + isReviewStep() { + return this.step === STEP_REVIEW; + }, + + rows() { + return prepareRows(this.readiness); + }, + + selectedRows() { + return selectedRows(this.rows, this.selectedIds); + }, + + selectedCount() { + return this.selectedIds.length; + }, + + canContinue() { + return canLeaveStep(this.step, this.selectedIds); + }, + + applyDisabled() { + return !this.canPrepare || this.isApplying || this.selectedIds.length === 0; + }, + + hasResult() { + return this.result !== null; + }, + + outcomes() { + return outcomesById(this.result); + }, + + findings() { + return actionableOutcomeFindings(this.result); + }, + + hasFailures() { + return hasFailures(this.result); + }, + }, + + created() { + this.loadReadiness(); + }, + + methods: { + loadReadiness() { + this.isLoading = true; + + return this.ucpAdminApiService + .getReadiness() + .then((response) => { + this.readiness = normalizeReadiness(response?.data?.data); + this.selectedIds = defaultPrepareSelection(this.rows); + }) + .catch((error) => { + this.createNotificationError({ message: extractApiErrorMessage(error) }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + + isSelected(id) { + return this.selectedIds.includes(id); + }, + + onToggle(id, selected) { + this.selectedIds = toggleSelection(this.selectedIds, id, selected); + }, + + selectAll() { + this.selectedIds = defaultPrepareSelection(this.rows); + }, + + deselectAll() { + this.selectedIds = []; + }, + + onNext() { + if (!this.canContinue) { + return; + } + + this.step = nextStep(this.step); + }, + + onBack() { + if (isFirstStep(this.step)) { + this.onClose(); + + return; + } + + this.step = previousStep(this.step); + }, + + onClose() { + this.$router.push({ name: 'sw.settings.agentic.commerce.index' }); + }, + + apply() { + if (this.applyDisabled) { + return; + } + + this.isApplying = true; + + return this.ucpAdminApiService + .bulkEnable(buildPreparePayload(this.selectedIds)) + .then((response) => { + this.result = response?.data?.data ?? null; + }) + .catch((error) => { + this.createNotificationError({ message: extractApiErrorMessage(error) }); + }) + .finally(() => { + this.isApplying = false; + }); + }, + }, +}); \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.mt.html.twig b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.mt.html.twig new file mode 100644 index 00000000..6d303a6b --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.mt.html.twig @@ -0,0 +1,224 @@ +{% block sw_settings_agentic_commerce_prepare %} + + + {% block sw_settings_agentic_commerce_prepare_header %} + + {% endblock %} + + {% block sw_settings_agentic_commerce_prepare_content %} + + {% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.scss b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.scss new file mode 100644 index 00000000..7d2ecdc8 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.scss @@ -0,0 +1,233 @@ +$sw-agentic-border: #dfe3e8; +$sw-agentic-muted: #667085; +$sw-agentic-warning: #a8400f; +$sw-agentic-warning-bg: #fbe8e0; + +// sw-card-view caps its cards at this width and centres them. Anything rendered +// alongside a card has to match, or it floats out to the content edge. +$sw-agentic-card-width: 960px; + +.sw-agentic-prepare { + &__steps { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px 24px; + list-style: none; + margin: 0 auto 20px; + max-width: $sw-agentic-card-width; + padding: 0; + width: 100%; + } + + &__step { + align-items: center; + color: #98a2b3; + display: flex; + font-size: 13px; + gap: 8px; + + &--active { + color: #344054; + font-weight: 650; + + .sw-agentic-prepare__step-num { + background: #189eff; + color: #fff; + } + } + + &--done .sw-agentic-prepare__step-num { + background: #11845b; + color: #fff; + } + } + + &__step-num { + align-items: center; + background: #eef1f4; + border-radius: 50%; + color: $sw-agentic-muted; + display: flex; + font-weight: 650; + height: 26px; + justify-content: center; + width: 26px; + } + + &__lede { + color: #475467; + line-height: 1.55; + margin: 0 0 20px; + } + + &__change { + border: 1px solid $sw-agentic-border; + border-radius: 8px; + display: flex; + gap: 12px; + margin-bottom: 12px; + padding: 16px; + } + + &__change-icon { + align-items: center; + background: #eef7ff; + border-radius: 8px; + color: #0877c9; + display: flex; + flex-shrink: 0; + font-size: 11px; + font-weight: 700; + height: 32px; + justify-content: center; + width: 36px; + } + + &__change-title { + font-weight: 650; + margin-bottom: 4px; + } + + &__change-desc { + color: $sw-agentic-muted; + line-height: 1.45; + } + + &__notice { + background: #fafcff; + border: 1px solid #dfe7ef; + border-radius: 8px; + color: #475467; + font-size: 13px; + line-height: 1.5; + margin-top: 18px; + padding: 13px 14px; + } + + &__toolbar { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 12px; + } + + &__count { + background: #eef7ff; + border-radius: 999px; + color: #1769aa; + font-size: 12px; + font-weight: 650; + padding: 5px 10px; + } + + &__toolbar-actions { + display: flex; + gap: 14px; + + a { + color: #0870ff; + font-size: 13px; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__channels { + list-style: none; + margin: 0; + max-height: 320px; + overflow-y: auto; + padding: 0; + } + + &__channel { + align-items: center; + border: 1px solid $sw-agentic-border; + border-radius: 8px; + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 8px; + padding: 12px 14px; + + &--selected { + border-color: #7cc7fb; + box-shadow: 0 0 0 2px rgba(24, 158, 255, 0.08); + } + + .mt-field--checkbox, + .sw-field--checkbox { + margin-bottom: 0; + } + } + + &__channel-meta { + align-items: center; + color: $sw-agentic-muted; + display: flex; + flex-shrink: 0; + font-size: 13px; + gap: 12px; + } + + &__warning { + background: $sw-agentic-warning-bg; + border-radius: 4px; + color: $sw-agentic-warning; + font-size: 12px; + padding: 2px 8px; + white-space: nowrap; + } + + &__empty { + color: $sw-agentic-muted; + margin: 0; + } + + &__review-head { + font-weight: 650; + margin-bottom: 12px; + } + + &__tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__tag { + background: #f2f4f7; + border-radius: 6px; + color: #475467; + font-size: 13px; + padding: 5px 9px; + } + + &__findings { + margin-top: 18px; + + ul { + margin: 0; + padding-left: 18px; + } + + li + li { + margin-top: 4px; + } + } + + &__actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin: 8px auto 0; + max-width: $sw-agentic-card-width; + padding-bottom: 8px; + width: 100%; + } +} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.sw.html.twig b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.sw.html.twig new file mode 100644 index 00000000..1aac237d --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/page/sw-settings-agentic-commerce-prepare/sw-settings-agentic-commerce-prepare.sw.html.twig @@ -0,0 +1,224 @@ +{% block sw_settings_agentic_commerce_prepare %} + + + {% block sw_settings_agentic_commerce_prepare_header %} + + {% endblock %} + + {% block sw_settings_agentic_commerce_prepare_content %} + + {% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/snippet/de.json b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/snippet/de.json new file mode 100644 index 00000000..8c208a58 --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/snippet/de.json @@ -0,0 +1,159 @@ +{ + "swagAgenticCommerce": { + "settings": { + "title": "Agentic Commerce", + "description": "Prüfe, ob dein Shop für die KI-Discovery vorbereitet ist, und schließe die restliche Einrichtung ab.", + "readinessTitle": "KI-Bereitschaft", + "stepsComplete": "{completed} von {total} Schritten abgeschlossen", + "taskComplete": "Abgeschlossen", + "findingsTitle": "Bitte prüfen", + "preparedTitle": "Vorbereitete Verkaufskanäle", + "preparedMeta": "UCP und KI-Dateien aktiviert. {count} aktive Produkte.", + "preparedPill": "Vorbereitet", + "metricsTitle": "Shop-Bereitschaft", + "metricExtension": "Erweiterung", + "metricInstalled": "Installiert", + "metricPrepared": "Vorbereitete Verkaufskanäle", + "metricAgentic": "Agentic-Verkaufskanäle", + "status": { + "setup_needed": { + "heading": "Dein Shop ist noch nicht KI-bereit", + "copy": "Agentic Commerce ist installiert. Bereite zuerst deine bestehenden Verkaufskanäle vor und verbinde sie dann mit einem KI-Anbieter.", + "pill": "Einrichtung nötig" + }, + "action_needed": { + "heading": "Es ist noch etwas zu tun", + "copy": "Einige deiner Verkaufskanäle sind vorbereitet. Schließe die übrigen Schritte ab, damit KI-Agenten sie auch erreichen können.", + "pill": "Aktion nötig" + }, + "ready": { + "heading": "Dein Shop ist KI-bereit", + "copy": "Deine Verkaufskanäle sind vorbereitet und ein Agentic-Verkaufskanal ist mit einem KI-Anbieter verbunden.", + "pill": "KI-bereit" + } + }, + "tasks": { + "installed": { + "title": "Agentic Commerce installiert", + "description": "Die Erweiterung ist in diesem Shop aktiv.", + "doneDescription": "Die Erweiterung ist in diesem Shop aktiv." + }, + "prepared": { + "title": "Bestehende Verkaufskanäle vorbereiten", + "description": "Aktiviere die nötigen UCP- und KI-Datei-Einstellungen, ohne jeden Verkaufskanal einzeln zu konfigurieren.", + "doneDescription": "{count} Verkaufskanal/-kanäle vorbereitet.", + "action": "Verkaufskanäle vorbereiten", + "pendingDescription": "{count} vorbereitet, {pending} Verkaufskanal/-kanäle noch nicht.", + "actionMore": "{count} weitere vorbereiten" + }, + "connected": { + "title": "Mit einem KI-Anbieter verbinden", + "description": "Erstelle aus deiner Storefront einen Produkt-Feed für OpenAI oder Google Shopping.", + "doneDescription": "{count} Agentic-Verkaufskanal/-kanäle verbunden.", + "action": "KI-Anbieter verbinden", + "addAction": "Anbieter hinzufügen" + } + }, + "connectedTitle": "Verbundene KI-Anbieter", + "connectedInactive": "Inaktiv" + }, + "prepare": { + "title": "Verkaufskanäle vorbereiten", + "steps": { + "understand": "Verstehen", + "select": "Auswählen", + "review": "Prüfen" + }, + "understand": { + "heading": "Was Shopware ändert", + "copy": "Agentic Commerce nutzt zusätzliche Produktdaten-Endpunkte. Für die ausgewählten Verkaufskanäle aktiviert Shopware Folgendes, sofern es noch nicht aktiv ist.", + "ucpTitle": "Universal Commerce Protocol", + "ucpCopy": "Stellt strukturierte Produkt- und Commerce-Informationen über UCP für KI-Agenten bereit und erzeugt einen Signaturschlüssel für den Kanal.", + "filesTitle": "KI-Dateien", + "filesCopy": "Veröffentlicht maschinenlesbare Dateien, die KI-Diensten mitteilen, wie sie mit diesem Verkaufskanal interagieren dürfen.", + "notice": "Bis zur Bestätigung ändert sich nichts. Bereits aktive Einstellungen bleiben unverändert, und bereits vorbereitete Kanäle werden nicht angeboten." + }, + "select": { + "heading": "Verkaufskanäle auswählen", + "selected": "{count} ausgewählt", + "selectAll": "Alle auswählen", + "deselectAll": "Auswahl aufheben", + "products": "{count} aktive Produkte", + "noDomain": "Keine Domain, Agenten erreichen diesen Kanal nicht", + "empty": "Alle bereitstellbaren Verkaufskanäle sind bereits vorbereitet.", + "notice": "Es werden nur fehlende Einstellungen aktiviert. Bestehende bleiben unverändert." + }, + "review": { + "heading": "Änderungen prüfen", + "summary": "{count} Verkaufskanal/-kanäle werden vorbereitet.", + "notice": "Shopware aktiviert UCP und die KI-Dateien, wo nötig. Bereits aktive Einstellungen werden nicht geändert." + }, + "result": { + "heading": "Ergebnis", + "summary": "{enabled} vorbereitet, {skipped} bereits eingerichtet, {failed} fehlgeschlagen.", + "enabled": "Vorbereitet", + "skipped": "Bereits eingerichtet", + "findingsTitle": "Bitte prüfen" + }, + "actions": { + "back": "Zurück", + "continue": "Weiter", + "apply": "Änderungen anwenden", + "done": "Fertig" + } + }, + "feeds": { + "title": "KI-Anbieter verbinden", + "steps": { + "choose": "Anbieter wählen", + "review": "Prüfen", + "result": "Ergebnis" + }, + "choose": { + "heading": "KI-Anbieter wählen", + "lede": "Shopware erstellt pro Anbieter einen Agentic-Verkaufskanal, vorkonfiguriert aus deiner Storefront. Feinabstimmen kannst du ihn später unter Verkaufskanäle.", + "selected": "{count} ausgewählt", + "selectAll": "Alle auswählen", + "deselectAll": "Auswahl aufheben", + "noDomain": "Keine Domain", + "domainHint": "Füge dieser Storefront eine Domain hinzu, um einen Feed zu erstellen.", + "willCreate": "Wird erstellt", + "empty": "Es gibt noch keinen Storefront-Verkaufskanal. Lege zuerst einen an und verbinde ihn dann hier." + }, + "providers": { + "openAi": { + "label": "OpenAI", + "description": "JSONL-Produkt-Feed für das Shopping in ChatGPT" + }, + "google": { + "label": "Google Shopping", + "description": "RSS/XML-Feed für das Google Merchant Center" + }, + "unknown": "Unbekannter Anbieter" + }, + "connected": "Verbunden", + "copy": "Kopieren", + "copied": "Feed-URL in die Zwischenablage kopiert.", + "copyFailed": "Die Feed-URL konnte nicht kopiert werden.", + "openChannel": "Verkaufskanal öffnen", + "review": { + "heading": "Prüfen", + "summary": "{count} Agentic-Verkaufskanal/-kanäle werden erstellt.", + "notice": "Sonst ändert sich nichts. Bestehende Kanäle bleiben unverändert." + }, + "result": { + "heading": "Ergebnis", + "summary": "{created} erstellt, {skipped} bereits verbunden, {failed} fehlgeschlagen.", + "created": "Erstellt", + "skipped": "Bereits verbunden", + "failed": "Fehlgeschlagen" + }, + "actions": { + "back": "Zurück", + "continue": "Weiter", + "apply": "Verkaufskanäle erstellen", + "done": "Fertig" + } + } + } +} diff --git a/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/snippet/en.json b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/snippet/en.json new file mode 100644 index 00000000..ffd6845c --- /dev/null +++ b/src/Resources/app/administration/src/module/sw-settings-agentic-commerce/snippet/en.json @@ -0,0 +1,159 @@ +{ + "swagAgenticCommerce": { + "settings": { + "title": "Agentic Commerce", + "description": "Check whether your shop is prepared for AI discovery and complete the remaining setup.", + "readinessTitle": "AI readiness", + "stepsComplete": "{completed} of {total} steps complete", + "taskComplete": "Complete", + "findingsTitle": "Worth checking", + "preparedTitle": "Prepared sales channels", + "preparedMeta": "UCP and AI files enabled. {count} active products.", + "preparedPill": "Prepared", + "metricsTitle": "Shop readiness", + "metricExtension": "Extension", + "metricInstalled": "Installed", + "metricPrepared": "Prepared sales channels", + "metricAgentic": "Agentic sales channels", + "status": { + "setup_needed": { + "heading": "Your shop is not AI prepared yet", + "copy": "Agentic Commerce is installed. Prepare your existing sales channels first, then connect them to an AI provider.", + "pill": "Setup needed" + }, + "action_needed": { + "heading": "There is still something to do", + "copy": "Some of your sales channels are prepared. Finish the remaining steps so AI agents can actually reach them.", + "pill": "Action needed" + }, + "ready": { + "heading": "Your shop is AI prepared", + "copy": "Your sales channels are prepared and an agentic sales channel is connected to an AI provider.", + "pill": "AI prepared" + } + }, + "tasks": { + "installed": { + "title": "Agentic Commerce installed", + "description": "The extension is active in this shop.", + "doneDescription": "The extension is active in this shop." + }, + "prepared": { + "title": "Prepare existing sales channels", + "description": "Enable the required UCP and AI-file settings without configuring each sales channel by hand.", + "doneDescription": "{count} sales channel(s) prepared.", + "action": "Prepare sales channels", + "pendingDescription": "{count} prepared, {pending} sales channel(s) not prepared yet.", + "actionMore": "Prepare {count} more" + }, + "connected": { + "title": "Connect to an AI provider", + "description": "Create a product feed for OpenAI or Google Shopping from your storefront.", + "doneDescription": "{count} agentic sales channel(s) connected.", + "action": "Connect AI provider", + "addAction": "Add provider" + } + }, + "connectedTitle": "Connected AI providers", + "connectedInactive": "Inactive" + }, + "prepare": { + "title": "Prepare sales channels", + "steps": { + "understand": "Understand", + "select": "Select", + "review": "Review" + }, + "understand": { + "heading": "What Shopware will change", + "copy": "Agentic Commerce uses additional product-data endpoints. For the sales channels you select, Shopware enables the following where they are not already active.", + "ucpTitle": "Universal Commerce Protocol", + "ucpCopy": "Makes structured product and commerce information available to AI agents over UCP, and creates a signing key for the channel.", + "filesTitle": "AI files", + "filesCopy": "Publishes machine-readable files that tell AI services how they may interact with this sales channel.", + "notice": "Nothing changes until you confirm. Settings that are already enabled stay unchanged, and channels that are already prepared are not offered." + }, + "select": { + "heading": "Choose sales channels", + "selected": "{count} selected", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "products": "{count} active products", + "noDomain": "No domain, agents cannot reach this channel", + "empty": "Every sales channel that can be exposed is already prepared.", + "notice": "Only missing settings are enabled. Existing ones are left unchanged." + }, + "review": { + "heading": "Review changes", + "summary": "{count} sales channel(s) will be prepared.", + "notice": "Shopware enables UCP and the AI files where required. Existing enabled settings will not be changed." + }, + "result": { + "heading": "Result", + "summary": "{enabled} prepared, {skipped} already set up, {failed} failed.", + "enabled": "Prepared", + "skipped": "Already set up", + "findingsTitle": "Worth checking" + }, + "actions": { + "back": "Back", + "continue": "Continue", + "apply": "Apply changes", + "done": "Done" + } + }, + "feeds": { + "title": "Connect AI provider", + "steps": { + "choose": "Choose providers", + "review": "Review", + "result": "Result" + }, + "choose": { + "heading": "Choose AI providers", + "lede": "Shopware creates one agentic sales channel per provider, pre-configured from your storefront. You can fine-tune it later under Sales Channels.", + "selected": "{count} selected", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "noDomain": "No domain", + "domainHint": "Add a domain to this storefront to create a feed.", + "willCreate": "Will be created", + "empty": "There is no storefront sales channel yet. Create one first, then connect it here." + }, + "providers": { + "openAi": { + "label": "OpenAI", + "description": "JSONL product feed for ChatGPT shopping" + }, + "google": { + "label": "Google Shopping", + "description": "RSS/XML feed for Google Merchant Center" + }, + "unknown": "Unknown provider" + }, + "connected": "Connected", + "copy": "Copy", + "copied": "Feed URL copied to the clipboard.", + "copyFailed": "The feed URL could not be copied.", + "openChannel": "Open sales channel", + "review": { + "heading": "Review", + "summary": "{count} agentic sales channel(s) will be created.", + "notice": "Nothing else changes. Existing channels are left untouched." + }, + "result": { + "heading": "Result", + "summary": "{created} created, {skipped} already connected, {failed} failed.", + "created": "Created", + "skipped": "Already connected", + "failed": "Failed" + }, + "actions": { + "back": "Back", + "continue": "Continue", + "apply": "Create sales channels", + "done": "Done" + } + } + } +} diff --git a/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/admin-notification.spec.js b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/admin-notification.spec.js new file mode 100644 index 00000000..1e832950 --- /dev/null +++ b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/admin-notification.spec.js @@ -0,0 +1,67 @@ +import { createAdminNotification } from 'Resources/extension/sw-sales-channel/agentic-commerce/admin-notification'; +import { translateAdmin } from 'Resources/extension/sw-sales-channel/agentic-commerce/admin-i18n'; + +function throwingStore() { + return { + get(id) { + throw new Error(`Store with id "${id}" not found`); + }, + }; +} + +describe('admin notification and translation adapters', () => { + afterEach(() => { + delete globalThis.Shopware.Store; + delete globalThis.Shopware.State; + delete globalThis.Shopware.Application; + }); + + it('raises through the Pinia store when it is registered', () => { + const createNotification = jest.fn(() => 'uuid-1'); + globalThis.Shopware.Store = { get: () => ({ createNotification }) }; + + expect(createAdminNotification({ title: 'hi' })).toBe('uuid-1'); + expect(createNotification).toHaveBeenCalledWith({ title: 'hi' }); + }); + + it('falls back to a Vuex dispatch when the Pinia registry throws for the id', () => { + const dispatch = jest.fn(() => 'uuid-2'); + globalThis.Shopware.Store = throwingStore(); + globalThis.Shopware.State = { dispatch }; + + expect(createAdminNotification({ title: 'hi' })).toBe('uuid-2'); + expect(dispatch).toHaveBeenCalledWith('notification/createNotification', { title: 'hi' }); + }); + + it('stays silent when neither store can raise anything', () => { + globalThis.Shopware.Store = throwingStore(); + + expect(createAdminNotification({ title: 'hi' })).toBeNull(); + }); + + it('translates through the Vue 3 app global properties', () => { + globalThis.Shopware.Application = { + getApplicationRoot: () => ({ config: { globalProperties: { $t: (key) => `translated:${key}` } } }), + }; + + expect(translateAdmin('a.b')).toBe('translated:a.b'); + }); + + it('translates through a Vue 2 root instance', () => { + globalThis.Shopware.Application = { + getApplicationRoot: () => ({ $tc: (key) => `legacy:${key}` }), + }; + + expect(translateAdmin('a.b')).toBe('legacy:a.b'); + }); + + it('returns the key rather than throwing inside a navigation guard', () => { + globalThis.Shopware.Application = { getApplicationRoot: () => false }; + expect(translateAdmin('a.b')).toBe('a.b'); + + globalThis.Shopware.Application = { + getApplicationRoot: () => ({ config: { globalProperties: { $t: () => { throw new Error('boom'); } } } }), + }; + expect(translateAdmin('a.b')).toBe('a.b'); + }); +}); \ No newline at end of file diff --git a/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/admin-session.spec.js b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/admin-session.spec.js new file mode 100644 index 00000000..443c50cd --- /dev/null +++ b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/admin-session.spec.js @@ -0,0 +1,61 @@ +import { + adminSession, + currentAdminUserId, + isAdminLoggedIn, +} from 'Resources/extension/sw-sales-channel/agentic-commerce/admin-session'; + +const user = { id: 'user-1', admin: true }; + +function throwingStore() { + return { + get(id) { + throw new Error(`Store with id "${id}" not found`); + }, + }; +} + +describe('admin-session', () => { + afterEach(() => { + delete globalThis.Shopware.Store; + delete globalThis.Shopware.State; + }); + + it('falls back to Vuex when the Pinia registry throws for an unknown id', () => { + // Shopware.Store.get() throws rather than returning undefined, so a plain + // `?? ` chain propagates out of the caller instead of falling through. + globalThis.Shopware.Store = throwingStore(); + globalThis.Shopware.State = { get: () => ({ currentUser: user }) }; + + expect(adminSession()).toEqual({ currentUser: user }); + expect(currentAdminUserId()).toBe('user-1'); + expect(isAdminLoggedIn()).toBe(true); + }); + + it('reads the Pinia session when it is registered', () => { + globalThis.Shopware.Store = { get: () => ({ currentUser: user }) }; + + expect(currentAdminUserId()).toBe('user-1'); + }); + + it('reports nobody logged in when neither store has a session', () => { + globalThis.Shopware.Store = throwingStore(); + globalThis.Shopware.State = { get: () => null }; + + expect(adminSession()).toBeNull(); + expect(currentAdminUserId()).toBeNull(); + expect(isAdminLoggedIn()).toBe(false); + }); + + it('reports nobody logged in when both stores throw', () => { + globalThis.Shopware.Store = throwingStore(); + globalThis.Shopware.State = throwingStore(); + + expect(isAdminLoggedIn()).toBe(false); + }); + + it('reports nobody logged in while the session carries no user yet', () => { + globalThis.Shopware.State = { get: () => ({ currentUser: null }) }; + + expect(isAdminLoggedIn()).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/onboarding-state.spec.js b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/onboarding-state.spec.js new file mode 100644 index 00000000..c64a07a1 --- /dev/null +++ b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/onboarding-state.spec.js @@ -0,0 +1,44 @@ +import { + needsOnboarding, + shouldOfferOnboarding, +} from 'Resources/extension/sw-sales-channel/agentic-commerce/onboarding-state'; + +describe('onboarding-state', () => { + const channels = [ + { id: 'storefront', name: 'Storefront', ucp: { active: false }, domains: [{ url: 'https://shop.example' }] }, + { id: 'headless', name: 'Headless', ucp: { active: false }, domains: [] }, + { id: 'b2b', name: 'B2B', ucp: { active: true }, domains: [{ url: 'https://b2b.example' }] }, + ]; + + it('needs onboarding while no channel exposes UCP', () => { + expect(needsOnboarding([channels[0], channels[1]])).toBe(true); + }); + + it('does not need onboarding once any channel is exposed', () => { + expect(needsOnboarding(channels)).toBe(false); + }); + + it('does not need onboarding when there is no channel to offer', () => { + expect(needsOnboarding([])).toBe(false); + }); + + it('offers onboarding on boot while nothing is exposed', () => { + expect(shouldOfferOnboarding({ channels: [channels[0]], currentRouteName: 'sw.dashboard.index' })).toBe(true); + }); + + it('does not offer onboarding once a channel is exposed', () => { + expect(shouldOfferOnboarding({ channels, currentRouteName: 'sw.dashboard.index' })).toBe(false); + }); + + it('does not offer onboarding to a user who dismissed it', () => { + expect(shouldOfferOnboarding({ dismissed: true, channels: [channels[0]], currentRouteName: 'sw.dashboard.index' })).toBe(false); + }); + + it('does not offer the page the merchant is already looking at', () => { + expect(shouldOfferOnboarding({ channels: [channels[0]], currentRouteName: 'sw.settings.agentic.commerce.index' })).toBe(false); + }); + + it('offers onboarding only once per session', () => { + expect(shouldOfferOnboarding({ channels: [channels[0]], currentRouteName: 'sw.dashboard.index', alreadyOffered: true })).toBe(false); + }); +}); diff --git a/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/prepare-flow.spec.js b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/prepare-flow.spec.js new file mode 100644 index 00000000..33af67bb --- /dev/null +++ b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/prepare-flow.spec.js @@ -0,0 +1,115 @@ +import { + STEP_UNDERSTAND, + STEP_SELECT, + STEP_REVIEW, + PREPARE_STEPS, + stepIndex, + nextStep, + previousStep, + isFirstStep, + stepState, + stepItems, + buildPreparePayload, + canLeaveStep, + toggleSelection, + selectedRows, + outcomesById, + actionableOutcomeFindings, + hasFailures, +} from 'Resources/extension/sw-sales-channel/agentic-commerce/prepare-flow'; + +const rows = [ + { id: 'a', name: 'Storefront' }, + { id: 'b', name: 'Headless' }, +]; + +describe('prepare-flow', () => { + it('walks the three steps forward and back without running off either end', () => { + expect(PREPARE_STEPS).toEqual([STEP_UNDERSTAND, STEP_SELECT, STEP_REVIEW]); + expect(nextStep(STEP_UNDERSTAND)).toBe(STEP_SELECT); + expect(nextStep(STEP_REVIEW)).toBe(STEP_REVIEW); + expect(previousStep(STEP_SELECT)).toBe(STEP_UNDERSTAND); + expect(previousStep(STEP_UNDERSTAND)).toBe(STEP_UNDERSTAND); + }); + + it('treats an unknown step as the first one', () => { + expect(stepIndex('nonsense')).toBe(0); + expect(isFirstStep(STEP_UNDERSTAND)).toBe(true); + expect(isFirstStep(STEP_SELECT)).toBe(false); + }); + + it('marks passed steps done, the current one active and the rest todo', () => { + expect(stepState(STEP_UNDERSTAND, STEP_SELECT)).toBe('done'); + expect(stepState(STEP_SELECT, STEP_SELECT)).toBe('active'); + expect(stepState(STEP_REVIEW, STEP_SELECT)).toBe('todo'); + }); + + it('numbers the stepper items and resolves their labels', () => { + const items = stepItems(STEP_SELECT); + + expect(items.map((item) => item.number)).toEqual([1, 2, 3]); + expect(items.map((item) => item.state)).toEqual(['done', 'active', 'todo']); + expect(items[0].label).toBe('swagAgenticCommerce.prepare.steps.understand'); + }); + + it('sends only the channel ids so the endpoint applies its own defaults', () => { + const payload = buildPreparePayload(['a', 'b']); + + expect(payload).toEqual({ salesChannelIds: ['a', 'b'] }); + expect(payload).not.toHaveProperty('enabledCapabilities'); + expect(payload).not.toHaveProperty('enabledTransports'); + expect(payload).not.toHaveProperty('profileDomain'); + }); + + it('copies the selection so a later edit cannot mutate a sent payload', () => { + const selected = ['a']; + const payload = buildPreparePayload(selected); + + selected.push('b'); + + expect(payload.salesChannelIds).toEqual(['a']); + }); + + it('lets the merchant read the explanation but not leave the selection empty', () => { + expect(canLeaveStep(STEP_UNDERSTAND, [])).toBe(true); + expect(canLeaveStep(STEP_SELECT, [])).toBe(false); + expect(canLeaveStep(STEP_SELECT, ['a'])).toBe(true); + expect(canLeaveStep(STEP_REVIEW, [])).toBe(false); + }); + + it('toggles a selection without duplicating an id', () => { + expect(toggleSelection(['a'], 'b', true)).toEqual(['a', 'b']); + expect(toggleSelection(['a', 'b'], 'a', false)).toEqual(['b']); + expect(toggleSelection(['a'], 'a', true)).toEqual(['a']); + }); + + it('resolves the selected rows in list order', () => { + expect(selectedRows(rows, ['b']).map((row) => row.name)).toEqual(['Headless']); + expect(selectedRows(rows, [])).toEqual([]); + }); + + it('indexes outcomes by id and survives a missing result', () => { + expect(outcomesById({ results: [{ salesChannelId: 'a', status: 'enabled' }] }).a.status).toBe('enabled'); + expect(outcomesById(null)).toEqual({}); + }); + + it('keeps only findings that need an action', () => { + const findings = actionableOutcomeFindings({ + results: [{ + findings: [ + { severity: 'info', code: 'inactive' }, + { severity: 'warning', code: 'no_storefront_domain' }, + { severity: 'error', code: 'no_active_signing_key' }, + ], + }], + }); + + expect(findings.map((f) => f.code)).toEqual(['no_storefront_domain', 'no_active_signing_key']); + }); + + it('reports whether any channel failed', () => { + expect(hasFailures({ enabled: 2, skipped: 0, failed: 1 })).toBe(true); + expect(hasFailures({ enabled: 2, skipped: 0, failed: 0 })).toBe(false); + expect(hasFailures(null)).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/readiness-state.spec.js b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/readiness-state.spec.js new file mode 100644 index 00000000..ee92711e --- /dev/null +++ b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/readiness-state.spec.js @@ -0,0 +1,200 @@ +import { + emptyReadiness, + normalizeReadiness, + statusVariant, + statusSnippet, + progressPercent, + readinessTasks, + preparedChannels, + unpreparedChannels, + actionableChannelFindings, + hasChannelsToPrepare, + prepareRows, + defaultPrepareSelection, + taskDescriptionKey, + taskActionKey, + STATUS_READY, + STATUS_ACTION_NEEDED, +} from 'Resources/extension/sw-sales-channel/agentic-commerce/readiness-state'; + +function readiness(overrides = {}) { + return normalizeReadiness({ + status: STATUS_ACTION_NEEDED, + completedSteps: 2, + totalSteps: 3, + steps: { + installed: { done: true }, + prepared: { done: true, count: 1 }, + connected: { done: false, count: 0 }, + }, + channels: [ + { id: 'a', name: 'Storefront', prepared: true, activeProductCount: 2030, domains: [{ url: 'https://shop' }], findings: [] }, + { id: 'b', name: 'Headless', prepared: false, activeProductCount: 2000, domains: [], findings: [] }, + ], + ...overrides, + }); +} + +describe('readiness-state', () => { + it('falls back to an empty shape for a missing payload', () => { + expect(normalizeReadiness(undefined)).toEqual(emptyReadiness()); + expect(normalizeReadiness('nope').channels).toEqual([]); + }); + + it('keeps the default steps when the payload only carries some of them', () => { + const merged = normalizeReadiness({ steps: { prepared: { done: true, count: 2 } } }); + + expect(merged.steps.prepared).toEqual({ done: true, count: 2 }); + expect(merged.steps.connected).toEqual({ done: false, count: 0 }); + }); + + it('shows a success pill only when the backend reports ready', () => { + expect(statusVariant(readiness({ status: STATUS_READY }))).toBe('success'); + expect(statusVariant(readiness())).toBe('warning'); + }); + + it('does not infer readiness from the step count', () => { + // Every step done, but the backend still says action_needed because a + // prepared channel has an error-level finding. + const allStepsDone = readiness({ + status: STATUS_ACTION_NEEDED, + completedSteps: 3, + steps: { + installed: { done: true }, + prepared: { done: true, count: 1 }, + connected: { done: true, count: 1 }, + }, + }); + + expect(progressPercent(allStepsDone)).toBe(100); + expect(statusVariant(allStepsDone)).toBe('warning'); + }); + + it('resolves the status snippet keys from the status', () => { + expect(statusSnippet(readiness({ status: STATUS_READY })).heading) + .toBe('swagAgenticCommerce.settings.status.ready.heading'); + }); + + it('reports progress as a percentage and clamps an overshooting count', () => { + expect(progressPercent(readiness({ completedSteps: 1 }))).toBe(33); + expect(progressPercent(readiness({ completedSteps: 9 }))).toBe(100); + }); + + it('locks connecting a provider until at least one channel is prepared', () => { + const unprepared = readiness({ + steps: { + installed: { done: true }, + prepared: { done: false, count: 0 }, + connected: { done: false, count: 0 }, + }, + }); + + expect(readinessTasks(unprepared).find((task) => task.key === 'connected').locked).toBe(true); + expect(readinessTasks(readiness()).find((task) => task.key === 'connected').locked).toBe(false); + }); + + it('keeps the prepared step actionable when a channel was added later', () => { + // One prepared, one new: the step counts as done for the progress bar but + // a merchant must still be able to reach the new channel. + const task = readinessTasks(readiness()).find((t) => t.key === 'prepared'); + + expect(task.done).toBe(true); + expect(task.pending).toBe(1); + expect(taskDescriptionKey(task)).toBe('swagAgenticCommerce.settings.tasks.prepared.pendingDescription'); + expect(taskActionKey(task)).toBe('swagAgenticCommerce.settings.tasks.prepared.actionMore'); + }); + + it('reports the prepared step as plainly done once nothing is left', () => { + const allPrepared = readiness({ + channels: [ + { id: 'a', name: 'Storefront', prepared: true, activeProductCount: 1, domains: [], findings: [] }, + ], + }); + const task = readinessTasks(allPrepared).find((t) => t.key === 'prepared'); + + expect(task.pending).toBe(0); + expect(taskDescriptionKey(task)).toBe('swagAgenticCommerce.settings.tasks.prepared.doneDescription'); + }); + + it('describes an untouched step with its plain description', () => { + const nothingPrepared = readiness({ + steps: { + installed: { done: true }, + prepared: { done: false, count: 0 }, + connected: { done: false, count: 0 }, + }, + }); + const task = readinessTasks(nothingPrepared).find((t) => t.key === 'prepared'); + + expect(taskDescriptionKey(task)).toBe('swagAgenticCommerce.settings.tasks.prepared.description'); + expect(taskActionKey(task)).toBe('swagAgenticCommerce.settings.tasks.prepared.action'); + }); + + it('splits prepared from unprepared channels', () => { + expect(preparedChannels(readiness()).map((c) => c.id)).toEqual(['a']); + expect(unpreparedChannels(readiness()).map((c) => c.id)).toEqual(['b']); + expect(hasChannelsToPrepare(readiness())).toBe(true); + }); + + it('keeps only findings a merchant can act on and names the channel', () => { + const withFindings = readiness({ + channels: [ + { + id: 'a', + name: 'Storefront', + prepared: true, + findings: [ + { severity: 'info', code: 'inactive', message: 'i', salesChannelName: '' }, + { severity: 'warning', code: 'no_storefront_domain', message: 'w', salesChannelName: '' }, + { severity: 'error', code: 'no_active_signing_key', message: 'e', salesChannelName: 'Storefront' }, + ], + }, + ], + }); + + const findings = actionableChannelFindings(withFindings); + + expect(findings.map((f) => f.code)).toEqual(['no_storefront_domain', 'no_active_signing_key']); + expect(findings[0].salesChannelName).toBe('Storefront'); + }); + + it('offers only the channels that are not prepared yet', () => { + // An already-prepared channel is nothing for the flow to do, and offering + // it would invite rewriting a configuration the merchant tuned. + expect(prepareRows(readiness())).toEqual([ + { + id: 'b', + name: 'Headless', + activeProductCount: 2000, + domainCount: 0, + hasDomain: false, + primaryDomain: null, + }, + ]); + }); + + it('carries the domain and product count each row shows', () => { + const bothUnprepared = readiness({ + channels: [ + { id: 'a', name: 'Storefront', prepared: false, activeProductCount: 2030, domains: [{ url: 'https://shop' }] }, + ], + }); + + expect(prepareRows(bothUnprepared)[0]).toMatchObject({ + primaryDomain: 'https://shop', + domainCount: 1, + hasDomain: true, + activeProductCount: 2030, + }); + }); + + it('drops entries without an id rather than offering a row that cannot be submitted', () => { + const broken = readiness({ channels: [{ name: 'ghost', prepared: false }] }); + + expect(prepareRows(broken)).toEqual([]); + }); + + it('preselects every offered channel', () => { + expect(defaultPrepareSelection(prepareRows(readiness()))).toEqual(['b']); + }); +}); \ No newline at end of file diff --git a/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/settings-group.spec.js b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/settings-group.spec.js new file mode 100644 index 00000000..07ef12d6 --- /dev/null +++ b/src/Resources/app/administration/test/extension/sw-sales-channel/agentic-commerce/settings-group.spec.js @@ -0,0 +1,65 @@ +function loadSettingsGroup({ store = undefined, state = undefined, version = '6.6.10.0' } = {}) { + globalThis.Shopware = { + ...globalThis.Shopware, + Store: store, + State: state, + Context: { app: { config: { version } } }, + }; + + let module; + jest.isolateModules(() => { + module = require('Resources/extension/sw-sales-channel/agentic-commerce/settings-group'); + }); + + return module; +} + +function throwingStore() { + return { + get(id) { + throw new Error(`Store with id "${id}" not found`); + }, + }; +} + +function storeWithGroups(groups) { + return { get: () => ({ settingsGroups: groups }) }; +} + +describe('settings-group', () => { + afterEach(() => { + delete globalThis.Shopware.Store; + delete globalThis.Shopware.State; + }); + + it('files the item under commerce when that group exists', () => { + const { settingsGroup } = loadSettingsGroup({ + store: storeWithGroups({ general: [], commerce: [], system: [] }), + }); + + expect(settingsGroup()).toBe('commerce'); + }); + + it('falls back to shop on a lane whose groups predate commerce', () => { + const { settingsGroup } = loadSettingsGroup({ + store: storeWithGroups({ general: [], shop: [], system: [] }), + }); + + expect(settingsGroup()).toBe('shop'); + }); + + it('reads the Vuex registry when the Pinia one throws for the id', () => { + const { settingsGroup } = loadSettingsGroup({ + store: throwingStore(), + state: storeWithGroups({ commerce: [] }), + }); + + expect(settingsGroup()).toBe('commerce'); + }); + + it('falls back to the lane version when no store is readable yet', () => { + // Module registration can run before the settings store exists. + expect(loadSettingsGroup({ store: throwingStore(), version: '6.7.0.0' }).settingsGroup()).toBe('commerce'); + expect(loadSettingsGroup({ store: throwingStore(), version: '6.5.8.0' }).settingsGroup()).toBe('shop'); + }); +}); \ No newline at end of file diff --git a/src/Resources/app/administration/test/extension/sw-sales-channel/onboarding-init.spec.js b/src/Resources/app/administration/test/extension/sw-sales-channel/onboarding-init.spec.js new file mode 100644 index 00000000..13ae42d7 --- /dev/null +++ b/src/Resources/app/administration/test/extension/sw-sales-channel/onboarding-init.spec.js @@ -0,0 +1,148 @@ +function loadInit({ canEdit = true, channels = [], dismissedEntry = null, currentUser = { id: 'user-1', admin: true } } = {}) { + const push = jest.fn(); + const afterEach = jest.fn(); + const createNotification = jest.fn(); + const save = jest.fn(() => Promise.resolve()); + const router = { currentRoute: { value: { name: 'sw.dashboard.index' } }, push, afterEach }; + const getSalesChannels = jest.fn(() => Promise.resolve({ data: { data: channels } })); + const search = jest.fn(() => Promise.resolve({ first: () => dismissedEntry })); + const sessionState = { currentUser }; + + globalThis.Shopware = { + ...globalThis.Shopware, + Context: { api: {} }, + Data: { + Criteria: class { + addFilter() {} + + static equals(field, value) { + return { field, value }; + } + }, + }, + Store: { + get(id) { + if (id === 'notification') { + return { createNotification }; + } + throw new Error(`Store with id "${id}" not found`); + }, + }, + Application: { + viewInitialized: new Promise(() => {}), + view: { router }, + getApplicationRoot: () => ({ config: { globalProperties: { $t: (key) => key } } }), + }, + State: { get: () => sessionState }, + Service: jest.fn((name) => { + if (name === 'acl') { + return { can: (privilege) => (privilege === 'ucp.editor' ? canEdit : true) }; + } + if (name === 'ucpAdminApiService') { + return { getSalesChannels }; + } + if (name === 'repositoryFactory') { + return { create: () => ({ search, save }) }; + } + + return null; + }), + }; + + let init; + jest.isolateModules(() => { + // The module arms the boot hook as an import side effect. + init = require('Resources/extension/sw-sales-channel/onboarding.init'); + }); + + return { ...init, router, push, afterEach, getSalesChannels, search, sessionState, createNotification }; +} + +const unexposed = [{ id: 'storefront', name: 'Storefront', ucp: { active: false }, domains: [] }]; +const exposed = [{ id: 'storefront', name: 'Storefront', ucp: { active: true }, domains: [] }]; + +describe('onboarding.init', () => { + it('offers the settings page without navigating there uninvited', async () => { + const { offerOnboarding, router, push, createNotification } = loadInit({ channels: unexposed }); + + await expect(offerOnboarding(router)).resolves.toBe(true); + expect(createNotification).toHaveBeenCalledTimes(1); + expect(push).not.toHaveBeenCalled(); + + const notification = createNotification.mock.calls[0][0]; + expect(notification.autoClose).toBe(false); + expect(notification.actions[0].route).toEqual({ name: 'sw.settings.agentic.commerce.index' }); + expect(typeof notification.actions[1].method).toBe('function'); + }); + + it('says nothing once a channel is exposed', async () => { + const { offerOnboarding, router, createNotification } = loadInit({ channels: exposed }); + + await expect(offerOnboarding(router)).resolves.toBe(false); + expect(createNotification).not.toHaveBeenCalled(); + }); + + it('retries on a later navigation while the session has no user yet', async () => { + const { offerOnboarding, router, createNotification, sessionState, getSalesChannels } = loadInit({ channels: unexposed, currentUser: null }); + + await expect(offerOnboarding(router)).resolves.toBe(false); + expect(getSalesChannels).not.toHaveBeenCalled(); + + // ACL answers false for everything until the session carries a user, so the + // first navigation must not burn the single evaluation. + sessionState.currentUser = { id: 'user-1', admin: true }; + + await expect(offerOnboarding(router)).resolves.toBe(true); + expect(createNotification).toHaveBeenCalledTimes(1); + }); + + it('never calls the API for a user who cannot edit UCP config', async () => { + const { offerOnboarding, router, getSalesChannels, search } = loadInit({ canEdit: false, channels: unexposed }); + + await expect(offerOnboarding(router)).resolves.toBe(false); + expect(getSalesChannels).not.toHaveBeenCalled(); + expect(search).not.toHaveBeenCalled(); + }); + + it('does not fetch channels for a user who already dismissed the offer', async () => { + const { offerOnboarding, router, getSalesChannels } = loadInit({ + channels: unexposed, + dismissedEntry: { value: [true] }, + }); + + await expect(offerOnboarding(router)).resolves.toBe(false); + expect(getSalesChannels).not.toHaveBeenCalled(); + }); + + it('evaluates once even when every navigation runs it', async () => { + const { offerOnboarding, router, createNotification, getSalesChannels } = loadInit({ channels: unexposed }); + + await offerOnboarding(router); + await offerOnboarding(router); + await offerOnboarding(router); + + expect(createNotification).toHaveBeenCalledTimes(1); + expect(getSalesChannels).toHaveBeenCalledTimes(1); + }); + + it('does not offer the page the user is already looking at', async () => { + const { offerOnboarding, router, createNotification } = loadInit({ channels: unexposed }); + router.currentRoute.value.name = 'sw.settings.agentic.commerce.index'; + + await expect(offerOnboarding(router)).resolves.toBe(false); + expect(createNotification).not.toHaveBeenCalled(); + }); + + it('subscribes to navigation rather than checking once at view init', () => { + const { installOnboardingHook, router, afterEach } = loadInit(); + + expect(installOnboardingHook(router)).toBe(true); + expect(afterEach).toHaveBeenCalledTimes(1); + }); + + it('stays silent when the router is not available yet', () => { + const { installOnboardingHook } = loadInit(); + + expect(installOnboardingHook(undefined)).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 3c21fb5c..1161770f 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -73,6 +73,7 @@ use Swag\AgenticCommerce\Ucp\Adapter\ShopwareDiscountAdapter; use Swag\AgenticCommerce\Ucp\Adapter\ShopwareOrderAdapter; use Swag\AgenticCommerce\Ucp\Admin\Api\UcpAdminController; +use Swag\AgenticCommerce\Ucp\Admin\Api\UcpOnboardingController; use Swag\AgenticCommerce\Ucp\Capability\CartCapability; use Swag\AgenticCommerce\Ucp\Capability\CatalogCapability; use Swag\AgenticCommerce\Ucp\Capability\CheckoutCapability; @@ -127,6 +128,10 @@ use Swag\AgenticCommerce\Ucp\Mcp\Tool\UcpDiscountApplyTool; use Swag\AgenticCommerce\Ucp\Mcp\Tool\UcpOrderGetTool; use Swag\AgenticCommerce\Ucp\Negotiation\VersionNegotiationCounter; +use Swag\AgenticCommerce\Ucp\Onboarding\AgenticFeedChannelCreator; +use Swag\AgenticCommerce\Ucp\Onboarding\DoctrineDbalOnboardingMetrics; +use Swag\AgenticCommerce\Ucp\Onboarding\FeedChannelLookup; +use Swag\AgenticCommerce\Ucp\Onboarding\OnboardingMetricsInterface; use Swag\AgenticCommerce\Ucp\Payment\ShopwareInvoicePaymentHandler; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolver; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainResolverCacheInvalidator; @@ -371,6 +376,9 @@ ->arg('$allowHttpLocalWebhookOverride', $allowHttpLocalWebhookOverride) ->tag('controller.service_arguments'); + $services->set(UcpOnboardingController::class) + ->tag('controller.service_arguments'); + $services->set(FallbackAgenticFileController::class) ->tag('controller.service_arguments'); @@ -416,6 +424,12 @@ ->arg('$profileFetchingDevelopmentMode', env('bool:default:defaults_bool_false:SWAG_AGENTIC_COMMERCE_UCP_PROFILE_FETCHING_DEVELOPMENT_MODE')) ->arg('$logger', service('logger')->nullOnInvalid()); + $services->alias(OnboardingMetricsInterface::class, DoctrineDbalOnboardingMetrics::class); + $services->set(FeedChannelLookup::class) + ->arg('$productExportRepository', service('product_export.repository')); + $services->set(AgenticFeedChannelCreator::class) + ->arg('$salesChannelRepository', service('sales_channel.repository')) + ->arg('$productStreamRepository', service('product_stream.repository')); $services->alias(AbstractSalesChannelTypeResolver::class, SalesChannelTypeResolver::class); $services->alias(UcpConfigRepositoryInterface::class, DoctrineDbalUcpConfigRepository::class); $services->alias(LegacyConfigStoreInterface::class, SystemConfigLegacyConfigStore::class); diff --git a/src/Resources/product-export-templates/google/body.xml.twig b/src/Resources/product-export-templates/google/body.xml.twig new file mode 100644 index 00000000..853da206 --- /dev/null +++ b/src/Resources/product-export-templates/google/body.xml.twig @@ -0,0 +1,259 @@ +{%- set title = product.translated.name|default(product.name)|default('')|trim -%} +{%- set description = product.translated.description|default(title)|default('')|striptags|trim -%} +{%- set price = product.calculatedPrice -%} +{%- if product.calculatedPrices.count > 0 -%} + {%- set price = product.calculatedPrices.last -%} +{%- endif -%} +{%- set unitPrice = price.unitPrice -%} +{%- set regularPriceValue = unitPrice -%} +{%- set salePriceValue = null -%} +{%- if price.listPrice is defined and price.listPrice -%} + {%- set regularPriceValue = price.listPrice.price -%} + {%- if price.listPrice.price > unitPrice -%} + {%- set salePriceValue = unitPrice -%} + {%- endif -%} +{%- endif -%} +{%- set imageUrl = '' -%} +{%- if product.cover is defined and product.cover and product.cover.media is defined and product.cover.media -%} + {%- set imageUrl = product.cover.media.url -%} +{%- endif -%} +{%- set additionalImageUrls = [] -%} +{%- if product.media is defined and product.media -%} + {%- for productMedia in product.media -%} + {%- if productMedia.media is defined and productMedia.media and productMedia.media.url and productMedia.id != product.coverId -%} + {%- set additionalImageUrls = additionalImageUrls|merge([productMedia.media.url]) -%} + {%- endif -%} + {%- endfor -%} +{%- endif -%} +{%- set hasVariantListing = productExport.includeVariants and (product.parentId or product.childCount > 0) -%} +{#- @deprecated tag:v2.0.0 - remove 3rd argument 'productId' once min. required Shopware version >= 6.7.14 -#} +{%- set canonicalUrl = entitySeoUrl('product', product.id, 'productId') -%} +{%- set productUrl = canonicalUrl ~ '?referringSalesChannel=' ~ provider.referringSalesChannel -%} +{%- if provider.affiliateCode -%} + {%- set productUrl = productUrl ~ '&affiliateCode=' ~ provider.affiliateCode|url_encode -%} +{%- endif -%} +{%- if provider.campaignCode -%} + {%- set productUrl = productUrl ~ '&campaignCode=' ~ provider.campaignCode|url_encode -%} +{%- endif -%} + +{%- set itemId = product.productNumber ? product.productNumber : product.id -%} +{%- set availability = product.available ? 'in_stock' : (product.restockTime ? 'backorder' : 'out_of_stock') -%} +{%- set brand = (product.manufacturer is defined and product.manufacturer) ? product.manufacturer.translated.name : provider.sellerName -%} +{%- set gtin = product.ean|default('') -%} +{%- set mpn = product.manufacturerNumber|default('') -%} +{%- set categoryPath = '' -%} +{%- if product.categories is defined and product.categories and product.categories.count > 0 -%} + {%- set categoryPath = product.categories.first.getBreadCrumb|slice(1)|join(' > ') -%} +{%- endif -%} + +{%- set mappedVariantOptions = {} -%} +{%- set customVariantEntries = [] -%} +{%- set reservedCustomGroupIds = [] -%} +{%- if provider.variantMapping is defined and provider.variantMapping -%} + {%- for mappingProperty, propertyGroupIds in provider.variantMapping -%} + {%- if mappingProperty != 'custom_variants' and propertyGroupIds -%} + {%- for propertyGroupId in propertyGroupIds -%} + {%- if propertyGroupId and propertyGroupId not in reservedCustomGroupIds -%} + {%- set reservedCustomGroupIds = reservedCustomGroupIds|merge([propertyGroupId]) -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + {%- endfor -%} +{%- endif -%} +{%- if provider.variantMapping is defined and provider.variantMapping -%} + {%- for mappingProperty, propertyGroupIds in provider.variantMapping -%} + {%- if propertyGroupIds -%} + {%- if mappingProperty == 'custom_variants' -%} + {%- for customPropertyGroupId in propertyGroupIds -%} + {%- if customVariantEntries|length < 3 and customPropertyGroupId not in reservedCustomGroupIds -%} + {%- set customOptionName = null -%} + {%- set customCategoryName = null -%} + + {%- if product.options is defined and product.options -%} + {%- for option in product.options -%} + {%- if customOptionName is null and option.groupId and option.groupId == customPropertyGroupId -%} + {%- set optionName = option.translated.name|default(option.name)|default('') -%} + {%- if optionName -%} + {%- set customOptionName = optionName -%} + {%- endif -%} + + {%- if option.group is defined and option.group -%} + {%- set groupName = option.group.translated.name|default(option.group.name)|default('') -%} + {%- if groupName -%} + {%- set customCategoryName = groupName -%} + {%- endif -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + + {%- if (customOptionName is null or customCategoryName is null) and product.sortedProperties is defined and product.sortedProperties -%} + {%- for group in product.sortedProperties -%} + {%- if group.id and group.id == customPropertyGroupId -%} + {%- if customCategoryName is null -%} + {%- set groupName = group.translated.name|default(group.name)|default('') -%} + {%- if groupName -%} + {%- set customCategoryName = groupName -%} + {%- endif -%} + {%- endif -%} + + {%- if customOptionName is null and group.options is defined and group.options -%} + {%- for option in group.options -%} + {%- if customOptionName is null -%} + {%- set optionName = option.translated.name|default(option.name)|default('') -%} + {%- if optionName -%} + {%- set customOptionName = optionName -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + + {%- if customCategoryName and customOptionName -%} + {%- set customVariantEntries = customVariantEntries|merge([{ + 'category': customCategoryName, + 'option': customOptionName + }]) -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- else -%} + {%- set variantPropertyOptionName = null -%} + + {%- if product.options is defined and product.options -%} + {%- for option in product.options -%} + {%- if variantPropertyOptionName is null and option.groupId and option.groupId in propertyGroupIds -%} + {%- set optionName = option.translated.name|default(option.name)|default('') -%} + {%- if optionName -%} + {%- set variantPropertyOptionName = optionName -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + + {%- if variantPropertyOptionName is null and product.sortedProperties is defined and product.sortedProperties -%} + {%- for group in product.sortedProperties -%} + {%- if variantPropertyOptionName is null and group.id and group.id in propertyGroupIds and group.options is defined and group.options -%} + {%- for option in group.options -%} + {%- if variantPropertyOptionName is null -%} + {%- set optionName = option.translated.name|default(option.name)|default('') -%} + {%- if optionName -%} + {%- set variantPropertyOptionName = optionName -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + + {%- if variantPropertyOptionName is not null -%} + {%- set mappedVariantOptions = mappedVariantOptions|merge({ + (mappingProperty): variantPropertyOptionName + }) -%} + {%- endif -%} + {%- endif -%} + {%- endif -%} + {%- endfor -%} +{%- endif -%} + +{# Skip rows that are missing core required Google fields. #} +{%- if title and imageUrl and price -%} + + {{ itemId|escape }} + {{ title|escape }} + {{ description|escape }} + {{ productUrl|escape }} + {{ canonicalUrl|escape }} + {{ imageUrl|escape }} + {%- for additionalImageUrl in additionalImageUrls|slice(0, 10) %} + {{ additionalImageUrl|escape }} + {%- endfor %} + {{ availability }} + {{ regularPriceValue|number_format(context.currency.itemRounding.decimals, '.', '') }} {{ context.currency.isoCode }} + {%- if salePriceValue is not null %} + {{ salePriceValue|number_format(context.currency.itemRounding.decimals, '.', '') }} {{ context.currency.isoCode }} + {%- endif %} + {%- if mappedVariantOptions.condition is defined and mappedVariantOptions.condition %} + {{ mappedVariantOptions.condition|escape }} + {%- else %} + new + {%- endif %} + {{ brand|escape }} + {%- if gtin %} + {{ gtin|escape }} + {%- endif %} + {%- if mpn %} + {{ mpn|escape }} + {%- endif %} + {%- if not gtin and not mpn %} + no + {%- endif %} + {%- if categoryPath %} + {{ categoryPath|escape }} + {%- endif %} + {%- if hasVariantListing %} + {{ (product.parentId ? product.parentId : product.id)|escape }} + {%- endif %} + {%- if mappedVariantOptions.color is defined and mappedVariantOptions.color %} + {{ mappedVariantOptions.color|escape }} + {%- endif %} + {%- if mappedVariantOptions.size is defined and mappedVariantOptions.size %} + {{ mappedVariantOptions.size|escape }} + {%- endif %} + {%- if mappedVariantOptions.size_system is defined and mappedVariantOptions.size_system %} + {{ mappedVariantOptions.size_system|escape }} + {%- endif %} + {%- if mappedVariantOptions.gender is defined and mappedVariantOptions.gender %} + {{ mappedVariantOptions.gender|escape }} + {%- endif %} + {%- if mappedVariantOptions.age_group is defined and mappedVariantOptions.age_group %} + {{ mappedVariantOptions.age_group|escape }} + {%- endif %} + {%- if mappedVariantOptions.material is defined and mappedVariantOptions.material %} + {{ mappedVariantOptions.material|escape }} + {%- endif %} + {%- for customEntry in customVariantEntries %} + {%- set customIndex = loop.index0 %} + {%- if customIndex < 3 %} + {{ (customEntry.category ~ ': ' ~ customEntry.option)|escape }} + {%- endif %} + {%- endfor %} + {%- if product.shippingFree %} + + {{ (provider.shippingCountry ?: provider.storeCountry)|escape }} + {%- if provider.shippingService %} + {{ provider.shippingService|escape }} + {%- endif %} + 0.00 {{ context.currency.isoCode }} + + {%- endif %} + {%- for characteristic in agentic_essential_characteristics(product, context) %} + + {{ characteristic.section|escape }} + {{ characteristic.name|escape }} + {{ characteristic.value|escape }} + + {%- endfor %} + {%- set measurements = agentic_product_measurements(product) %} + {%- if measurements.weight %} + {{ measurements.weight.display|escape }} + {%- endif %} + {%- if measurements.length %} + {{ measurements.length.display|escape }} + {%- endif %} + {%- if measurements.width %} + {{ measurements.width.display|escape }} + {%- endif %} + {%- if measurements.height %} + {{ measurements.height.display|escape }} + {%- endif %} + {%- if measurements.unitPricingMeasure %} + {{ measurements.unitPricingMeasure|escape }} + {%- endif %} + {%- if measurements.unitPricingBaseMeasure %} + {{ measurements.unitPricingBaseMeasure|escape }} + {%- endif %} + +{%- endif -%} diff --git a/src/Resources/product-export-templates/google/footer.xml.twig b/src/Resources/product-export-templates/google/footer.xml.twig new file mode 100644 index 00000000..f651c3bb --- /dev/null +++ b/src/Resources/product-export-templates/google/footer.xml.twig @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/Resources/product-export-templates/google/header.xml.twig b/src/Resources/product-export-templates/google/header.xml.twig new file mode 100644 index 00000000..82676bf1 --- /dev/null +++ b/src/Resources/product-export-templates/google/header.xml.twig @@ -0,0 +1,8 @@ + + + + + {{ context.salesChannel.name|escape }} + {{ context.salesChannel.name|escape }} + {{ productExport.salesChannelDomain.url|escape }} + {{ productExport.salesChannelDomain.language.locale.code|escape }} \ No newline at end of file diff --git a/src/Resources/product-export-templates/open-ai/body.json.twig b/src/Resources/product-export-templates/open-ai/body.json.twig new file mode 100644 index 00000000..a5639803 --- /dev/null +++ b/src/Resources/product-export-templates/open-ai/body.json.twig @@ -0,0 +1,274 @@ +{% set title = product.translated.name|default(product.name)|default('')|trim %} +{% set description = product.translated.description|default(title)|default('')|striptags|trim %} +{% set price = product.calculatedPrice %} +{% if product.calculatedPrices.count > 0 %} + {% set price = product.calculatedPrices.last %} +{% endif %} +{% set unitPrice = price.unitPrice %} +{% set regularPriceValue = unitPrice %} +{% set salePriceValue = null %} +{% if price.listPrice is defined and price.listPrice %} + {% set regularPriceValue = price.listPrice.price %} + {% if price.listPrice.price > unitPrice %} + {% set salePriceValue = unitPrice %} + {% endif %} +{% endif %} +{% set imageUrl = '' %} +{% if product.cover is defined and product.cover and product.cover.media is defined and product.cover.media %} + {% set imageUrl = product.cover.media.url %} +{% endif %} +{% set additionalImageUrls = [] %} +{% if product.media is defined and product.media %} + {% for productMedia in product.media %} + {% if productMedia.media is defined and productMedia.media and productMedia.media.url and productMedia.id != product.coverId %} + {% set additionalImageUrls = additionalImageUrls|merge([productMedia.media.url]) %} + {% endif %} + {% endfor %} +{% endif %} +{% set hasVariants = product.parentId or product.childCount > 0 %} +{% set isConcreteVariant = product.parentId %} +{#- @deprecated tag:v2.0.0 - remove 3rd argument 'productId' once min. required Shopware version >= 6.7.14 -#} +{% set productUrl = entitySeoUrl('product', product.id, 'productId') ~ '?referringSalesChannel=' ~ provider.referringSalesChannel %} +{% if provider.affiliateCode %} + {% set productUrl = productUrl ~ '&affiliateCode=' ~ provider.affiliateCode|url_encode %} +{% endif %} +{% if provider.campaignCode %} + {% set productUrl = productUrl ~ '&campaignCode=' ~ provider.campaignCode|url_encode %} +{% endif %} +{% set feedRow = { + 'is_eligible_search': provider.isEligibleSearch, + 'is_eligible_checkout': provider.isEligibleCheckout, + 'item_id': product.productNumber ? product.productNumber : product.id, + 'title': title, + 'description': description, + 'url': productUrl, + 'image_url': imageUrl, + 'price': (regularPriceValue|number_format(context.currency.itemRounding.decimals, '.', '')) ~ ' ' ~ context.currency.isoCode, + 'availability': product.available ? 'in_stock' : (product.restockTime ? 'backorder' : 'out_of_stock'), + 'brand': (product.manufacturer is defined and product.manufacturer) ? product.manufacturer.translated.name : provider.sellerName, + 'seller_name': provider.sellerName, + 'seller_url': provider.sellerUrl, + 'return_policy': provider.returnPolicyUrl, + 'store_country': provider.storeCountry, + 'gtin': product.ean|default(''), + 'mpn': product.manufacturerNumber|default(''), + 'is_digital': product.downloads is defined and product.downloads|length > 0 +} %} +{% if provider.targetCountries is not empty %} + {% set feedRow = feedRow|merge({ + 'target_countries': provider.targetCountries + }) %} +{% endif %} +{% if additionalImageUrls is not empty %} + {% set feedRow = feedRow|merge({ + 'additional_image_urls': additionalImageUrls|join(',') + }) %} +{% endif %} +{% if salePriceValue is not null %} + {% set feedRow = feedRow|merge({ + 'sale_price': (salePriceValue|number_format(context.currency.itemRounding.decimals, '.', '')) ~ ' ' ~ context.currency.isoCode + }) %} +{% endif %} +{% set feedRow = feedRow|merge({ + 'listing_has_variations': hasVariants +}) %} +{% if hasVariants %} + {% set offerId = 'SKU-' ~ (product.productNumber ? product.productNumber : product.id) ~ '-' ~ (regularPriceValue|number_format(context.currency.itemRounding.decimals, '.', '')) %} + {% set feedRow = feedRow|merge({ + 'offer_id': offerId, + 'group_id': product.parentId ? product.parentId : product.id, + 'item_group_title': title + }) %} + + {% if isConcreteVariant %} + {# Collect resolved variant output fields and the final OpenAI variant_dict payload #} + {% set mappedVariantOptions = {} %} + {% set variantDict = {} %} + {% set customVariantEntries = [] %} + + {# Property groups used by specific mappings (color/size/...) are excluded from custom variants #} + {% set reservedCustomGroupIds = [] %} + {% if provider.variantMapping is defined and provider.variantMapping %} + {% for mappingProperty, propertyGroupIds in provider.variantMapping %} + {% if mappingProperty != 'custom_variants' and propertyGroupIds %} + {% for propertyGroupId in propertyGroupIds %} + {% if propertyGroupId and propertyGroupId not in reservedCustomGroupIds %} + {% set reservedCustomGroupIds = reservedCustomGroupIds|merge([propertyGroupId]) %} + {% endif %} + {% endfor %} + {% endif %} + {% endfor %} + {% endif %} + {% if provider.variantMapping is defined and provider.variantMapping %} + {% for mappingProperty, propertyGroupIds in provider.variantMapping %} + {% if propertyGroupIds %} + {% if mappingProperty == 'custom_variants' %} + {# Resolve up to 3 custom variant entries: category (group name) + option (selected value) #} + {% for customPropertyGroupId in propertyGroupIds %} + {% if customVariantEntries|length < 3 and customPropertyGroupId not in reservedCustomGroupIds %} + {% set customOptionName = null %} + {% set customCategoryName = null %} + + {# Try to resolve custom category and option from direct product options first #} + {% if product.options is defined and product.options %} + {% for option in product.options %} + {% if customOptionName is null and option.groupId and option.groupId == customPropertyGroupId %} + {% set optionName = option.translated.name|default(option.name)|default('') %} + {% if optionName %} + {% set customOptionName = optionName %} + {% endif %} + + {% if option.group is defined and option.group %} + {% set groupName = option.group.translated.name|default(option.group.name)|default('') %} + {% if groupName %} + {% set customCategoryName = groupName %} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + + {# Fallback to sortedProperties when category or option is still missing #} + {% if (customOptionName is null or customCategoryName is null) and product.sortedProperties is defined and product.sortedProperties %} + {% for group in product.sortedProperties %} + {% if group.id and group.id == customPropertyGroupId %} + {% if customCategoryName is null %} + {% set groupName = group.translated.name|default(group.name)|default('') %} + {% if groupName %} + {% set customCategoryName = groupName %} + {% endif %} + {% endif %} + + {# Pick the first option value from the matched custom property group #} + {% if customOptionName is null and group.options is defined and group.options %} + {% for option in group.options %} + {% if customOptionName is null %} + {% set optionName = option.translated.name|default(option.name)|default('') %} + {% if optionName %} + {% set customOptionName = optionName %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + + {# Add custom variant entry when both category and option were resolved #} + {% if customCategoryName and customOptionName %} + {% set customVariantEntries = customVariantEntries|merge([{ + 'category': customCategoryName, + 'option': customOptionName + }]) %} + {% endif %} + {% endif %} + {% endfor %} + {% else %} + {# Resolve standard mappings (color/size/size_system/gender/material): first matching option wins #} + {% set variantPropertyOptionName = null %} + + {# Check direct variant options on the product first #} + {% if product.options is defined and product.options %} + {% for option in product.options %} + {% if variantPropertyOptionName is null and option.groupId and option.groupId in propertyGroupIds %} + {% set optionName = option.translated.name|default(option.name)|default('') %} + {% if optionName %} + {% set variantPropertyOptionName = optionName %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + + {# Fallback to sortedProperties when no direct option matches #} + {% if variantPropertyOptionName is null and product.sortedProperties is defined and product.sortedProperties %} + {% for group in product.sortedProperties %} + {% if variantPropertyOptionName is null and group.id and group.id in propertyGroupIds and group.options is defined and group.options %} + {% for option in group.options %} + {% if variantPropertyOptionName is null %} + {% set optionName = option.translated.name|default(option.name)|default('') %} + {% if optionName %} + {% set variantPropertyOptionName = optionName %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% endfor %} + {% endif %} + + {# Add resolved standard variant value for the mapped OpenAI field #} + {% if variantPropertyOptionName is not null %} + {% set mappedVariantOptions = mappedVariantOptions|merge({ + (mappingProperty): variantPropertyOptionName + }) %} + {% endif %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + + {% if mappedVariantOptions is not empty %} + {# Standard mapped fields are emitted as top-level fields and mirrored into variant_dict #} + {% for variantKey, variantValue in mappedVariantOptions %} + {% if variantValue %} + {% set variantDict = variantDict|merge({ + (variantKey): variantValue + }) %} + {% endif %} + {% endfor %} + {% set feedRow = feedRow|merge(mappedVariantOptions) %} + {% endif %} + + {% if customVariantEntries is not empty %} + {# Emit custom variants as custom_variant1..3_(category|option) and mirror into variant_dict #} + {% for customEntry in customVariantEntries %} + {% set customIndex = loop.index %} + {% if customIndex <= 3 %} + {% set feedRow = feedRow|merge({ + ('custom_variant' ~ customIndex ~ '_category'): customEntry.category, + ('custom_variant' ~ customIndex ~ '_option'): customEntry.option + }) %} + {% set variantDict = variantDict|merge({ + (customEntry.category): customEntry.option + }) %} + {% endif %} + {% endfor %} + {% endif %} + + {% if variantDict is not empty %} + {# Attaching OpenAI variants object for this exported item #} + {% set feedRow = feedRow|merge({ + 'variant_dict': variantDict + }) %} + {% endif %} + {% endif %} +{% endif %} + +{# Product measurements: OpenAI ACP uses separate value + unit fields (no product_detail equivalent). #} +{% set measurements = agentic_product_measurements(product) %} +{% if measurements.weight %} + {% set feedRow = feedRow|merge({ + 'weight': measurements.weight.value, + 'item_weight_unit': measurements.weight.unit + }) %} +{% endif %} +{% set dimensionsUnit = null %} +{% if measurements.length %} + {% set feedRow = feedRow|merge({'length': measurements.length.value}) %} + {% set dimensionsUnit = measurements.length.unit %} +{% endif %} +{% if measurements.width %} + {% set feedRow = feedRow|merge({'width': measurements.width.value}) %} + {% set dimensionsUnit = measurements.width.unit %} +{% endif %} +{% if measurements.height %} + {% set feedRow = feedRow|merge({'height': measurements.height.value}) %} + {% set dimensionsUnit = measurements.height.unit %} +{% endif %} +{% if dimensionsUnit %} + {% set feedRow = feedRow|merge({'dimensions_unit': dimensionsUnit}) %} +{% endif %} + +{# Skip rows that are missing core required feed data and would be invalid for OpenAI. #} +{% if title and imageUrl and price %} + {{ feedRow|json_encode(constant('JSON_UNESCAPED_SLASHES'))|raw }} +{% endif %} \ No newline at end of file diff --git a/src/SwagAgenticCommerce.php b/src/SwagAgenticCommerce.php index 2925af25..4f9cdc62 100644 --- a/src/SwagAgenticCommerce.php +++ b/src/SwagAgenticCommerce.php @@ -9,6 +9,7 @@ use Shopware\Core\Framework\Plugin; use Shopware\Core\Framework\Plugin\Context\ActivateContext; use Shopware\Core\Framework\Plugin\Context\InstallContext; +use Shopware\Core\Framework\Plugin\Context\UninstallContext; use Shopware\Core\Framework\Plugin\Context\UpdateContext; use Shopware\Core\Kernel; use Swag\AgenticCommerce\AgenticFiles\AgenticFilesCoreBridgeInterface; @@ -18,8 +19,10 @@ use Swag\AgenticCommerce\DependencyInjection\AgenticCommerceCoexistenceCompilerPass; use Swag\AgenticCommerce\DependencyInjection\TestAgentProfileFetcherCompilerPass; use Swag\AgenticCommerce\Exception\SdkNotAvailableException; +use Swag\AgenticCommerce\Lifecycle\PluginDataRemover; use Swag\AgenticCommerce\Ucp\DependencyInjection\ReplaceSdkSigningKeyCommandsPass; use Swag\AgenticCommerce\Ucp\DependencyInjection\ReplaceSdkUrlSafetyValidatorPass; +use Swag\AgenticCommerce\Ucp\Onboarding\OnboardingDismissal; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -35,6 +38,9 @@ final class SwagAgenticCommerce extends Plugin */ public const SALES_CHANNEL_TYPE_AGENTIC_COMMERCE = '5e29f9890c4d4d519a1c7f9d5c24b7c1'; + /** Plugin-owned stream the onboarding-created feed channels export: every active product. */ + public const DEFAULT_PRODUCT_STREAM_ID = '0195a9c0a1b07b8e9d2f4c6e8a1b3d5f'; + public const OPEN_AI_PRODUCT_EXPORT_CONFIG_DOMAIN = 'SwagAgenticCommerce.openAiProductExport'; public const GOOGLE_PRODUCT_EXPORT_CONFIG_DOMAIN = 'SwagAgenticCommerce.googleProductExport'; @@ -123,6 +129,7 @@ public function install(InstallContext $installContext): void $this->bootstrapSdkSchema(); $this->syncCoreAgenticFiles(); + $this->resetOnboardingDismissal(); } public function update(UpdateContext $updateContext): void @@ -141,6 +148,23 @@ public function activate(ActivateContext $activateContext): void $this->bootstrapSdkSchema(); $this->syncCoreAgenticFiles(); + $this->resetOnboardingDismissal(); + } + + /** + * Honours the "remove all data" choice a merchant makes in the Extensions UI. + * Without this the plugin's own tables outlive it, so a reinstall finds a + * shop that still looks configured and never offers onboarding again. + */ + public function uninstall(UninstallContext $uninstallContext): void + { + parent::uninstall($uninstallContext); + + if ($uninstallContext->keepUserData()) { + return; + } + + PluginDataRemover::removeAll(Kernel::getConnection()); } /** @@ -165,6 +189,15 @@ public function enrichPrivileges(): array ]; } + /** + * A dismissal lives in core's `user_config`, which an uninstall leaves behind, so a fresh + * install would otherwise never offer the onboarding dialog to an admin who once said no. + */ + private function resetOnboardingDismissal(): void + { + OnboardingDismissal::reset(Kernel::getConnection()); + } + /** * Refuses the lifecycle call when the SDK is missing and nothing is going to install it. * diff --git a/src/Ucp/Admin/Api/UcpOnboardingController.php b/src/Ucp/Admin/Api/UcpOnboardingController.php new file mode 100644 index 00000000..0fecc16e --- /dev/null +++ b/src/Ucp/Admin/Api/UcpOnboardingController.php @@ -0,0 +1,206 @@ + [ApiRouteScope::ID]])] +#[Package('framework')] +final class UcpOnboardingController +{ + public function __construct( + private readonly BulkUcpActivator $activator, + private readonly ShopReadinessProvider $readinessProvider, + private readonly AgenticFeedChannelCreator $feedChannelCreator, + ) { + } + + #[Route( + path: '/api/_admin/ucp/onboarding/readiness', + name: 'api.action.swag_agentic_commerce.ucp.onboarding.readiness', + methods: ['GET'], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['ucp.viewer']], + )] + public function readiness(Context $context): JsonResponse + { + return new JsonResponse(['data' => $this->readinessProvider->readiness($context)]); + } + + #[Route( + path: '/api/_admin/ucp/onboarding/bulk-enable', + name: 'api.action.swag_agentic_commerce.ucp.onboarding.bulk_enable', + methods: ['POST'], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['ucp.editor']], + )] + public function bulkEnable(Request $request, Context $context): JsonResponse + { + try { + $payload = $request->toArray(); + } catch (JsonException) { + throw UcpConfigException::invalidJsonPayload(); + } + + [$capabilities, $transports] = $this->exposure($payload); + + $outcomes = $this->activator->activate( + $this->salesChannelIds($payload), + $capabilities, + $transports, + $context, + ); + + return new JsonResponse([ + 'data' => [ + 'results' => $outcomes, + 'enabled' => $this->count($outcomes, static fn (ChannelActivationOutcome $o): bool => $o->isEnabled()), + 'skipped' => $this->count($outcomes, static fn (ChannelActivationOutcome $o): bool => $o->isSkipped()), + 'failed' => $this->count($outcomes, static fn (ChannelActivationOutcome $o): bool => $o->isFailed()), + ], + ]); + } + + #[Route( + path: '/api/_admin/ucp/onboarding/feed-channels', + name: 'api.action.swag_agentic_commerce.ucp.onboarding.feed_channels', + methods: ['POST'], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['ucp.editor']], + )] + public function feedChannels(Request $request, Context $context): JsonResponse + { + try { + $payload = $request->toArray(); + } catch (JsonException) { + throw UcpConfigException::invalidJsonPayload(); + } + + $outcomes = $this->feedChannelCreator->create($this->feedRequests($payload), $context); + + return new JsonResponse([ + 'data' => [ + 'results' => $outcomes, + 'created' => \count(array_filter($outcomes, static fn (FeedChannelOutcome $o): bool => $o->isCreated())), + 'skipped' => \count(array_filter($outcomes, static fn (FeedChannelOutcome $o): bool => $o->isSkipped())), + 'failed' => \count(array_filter($outcomes, static fn (FeedChannelOutcome $o): bool => $o->isFailed())), + ], + ]); + } + + /** + * @param array $payload + * + * @return list + */ + private function feedRequests(array $payload): array + { + $feeds = $payload['feeds'] ?? null; + if (!\is_array($feeds) || !array_is_list($feeds) || [] === $feeds) { + throw UcpConfigException::invalidValue('$.feeds', 'must be a non-empty list'); + } + + $requests = []; + foreach ($feeds as $index => $feed) { + $path = \sprintf('$.feeds[%d]', $index); + if (!\is_array($feed)) { + throw UcpConfigException::invalidValue($path, 'must be an object'); + } + + $storefrontId = $feed['storefrontSalesChannelId'] ?? null; + if (!\is_string($storefrontId) || '' === trim($storefrontId)) { + throw UcpConfigException::invalidValue($path.'.storefrontSalesChannelId', 'must be a non-empty string'); + } + + $provider = \is_string($feed['provider'] ?? null) ? FeedProvider::tryFrom($feed['provider']) : null; + if (null === $provider) { + throw UcpConfigException::invalidValue($path.'.provider', \sprintf('must be one of "%s"', implode('", "', array_column(FeedProvider::cases(), 'value')))); + } + + $requests[trim($storefrontId).'|'.$provider->value] = ['storefrontSalesChannelId' => trim($storefrontId), 'provider' => $provider]; + } + + return array_values($requests); + } + + /** + * @param array $payload + * + * @return list + */ + private function salesChannelIds(array $payload): array + { + $ids = $payload['salesChannelIds'] ?? null; + if (!\is_array($ids) || !array_is_list($ids)) { + throw UcpConfigException::invalidValue('$.salesChannelIds', 'must be a list'); + } + + $normalized = []; + foreach ($ids as $index => $id) { + if (!\is_string($id) || '' === trim($id)) { + throw UcpConfigException::invalidValue(\sprintf('$.salesChannelIds[%d]', $index), 'must be a non-empty string'); + } + + $normalized[] = trim($id); + } + + return array_values(array_unique($normalized)); + } + + /** + * Validates the exposure subset by building the very config object the save + * path builds, so the rules and their messages live in exactly one place + * ({@see UcpConfig::fromArray}). Done up front rather than per channel: a + * malformed capability is a bad request, not a per-channel outcome. + * + * @param array $payload + * + * @return array{0: list, 1: list} + */ + private function exposure(array $payload): array + { + $config = UcpConfig::fromArray(array_filter( + [ + 'enabledCapabilities' => $payload['enabledCapabilities'] ?? null, + 'enabledTransports' => $payload['enabledTransports'] ?? null, + ], + static fn (mixed $value): bool => null !== $value, + )); + + return [$config->enabledCapabilities, $config->enabledTransports]; + } + + /** + * @param list $outcomes + * @param \Closure(ChannelActivationOutcome): bool $matches + */ + private function count(array $outcomes, \Closure $matches): int + { + return \count(array_filter($outcomes, $matches)); + } +} diff --git a/src/Ucp/Checkout/DoctrineDbalCheckoutCompletionStore.php b/src/Ucp/Checkout/DoctrineDbalCheckoutCompletionStore.php index e7f2abb3..27904b6d 100644 --- a/src/Ucp/Checkout/DoctrineDbalCheckoutCompletionStore.php +++ b/src/Ucp/Checkout/DoctrineDbalCheckoutCompletionStore.php @@ -13,7 +13,7 @@ #[Package('checkout')] final class DoctrineDbalCheckoutCompletionStore implements CheckoutCompletionStoreInterface { - private const TABLE = 'swag_agentic_commerce_ucp_checkout_completion'; + public const TABLE = 'swag_agentic_commerce_ucp_checkout_completion'; public function __construct( private readonly Connection $connection, diff --git a/src/Ucp/Command/UcpConfigValidateCommand.php b/src/Ucp/Command/UcpConfigValidateCommand.php index 9fb857be..17a679a1 100644 --- a/src/Ucp/Command/UcpConfigValidateCommand.php +++ b/src/Ucp/Command/UcpConfigValidateCommand.php @@ -6,13 +6,11 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; -use Swag\AgenticCommerce\Ucp\Admin\SigningKey\UcpSigningKeyService; use Swag\AgenticCommerce\Ucp\Config\UcpConfigException; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Swag\AgenticCommerce\Ucp\Config\Validation\Finding; use Swag\AgenticCommerce\Ucp\Config\Validation\Severity; -use Swag\AgenticCommerce\Ucp\Config\Validation\UcpConfigValidator; -use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainView; +use Swag\AgenticCommerce\Ucp\Onboarding\ChannelFindingsResolver; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelView; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelViewProvider; use Symfony\Component\Console\Attribute\AsCommand; @@ -34,8 +32,7 @@ public function __construct( private readonly SalesChannelViewProvider $salesChannelViewProvider, private readonly SalesChannelResolver $salesChannelResolver, private readonly UcpConfigService $configService, - private readonly UcpSigningKeyService $signingKeyService, - private readonly UcpConfigValidator $validator, + private readonly ChannelFindingsResolver $findingsResolver, ) { parent::__construct(); } @@ -114,13 +111,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int continue; } - $channelFindings = $this->validator->validate( - $id, - $name, - $config, - $this->signingKeyService->all($id), - array_map(static fn (SalesChannelDomainView $domain): array => $domain->jsonSerialize(), $channel->domains), - ); + $channelFindings = $this->findingsResolver->resolve($id, $name, $config, $channel); $checked[] = ['id' => $id, 'name' => $name, 'findings' => $channelFindings]; $findings = array_merge($findings, $channelFindings); @@ -193,10 +184,8 @@ private function tag(Severity $severity): string */ private function exitCode(array $findings, bool $strict): int { - foreach ($findings as $finding) { - if (Severity::Error === $finding->severity) { - return self::FAILURE; - } + if ($this->findingsResolver->hasError($findings)) { + return self::FAILURE; } if ($strict) { diff --git a/src/Ucp/Command/UcpSetupCommand.php b/src/Ucp/Command/UcpSetupCommand.php index 62fbb613..b86a1324 100644 --- a/src/Ucp/Command/UcpSetupCommand.php +++ b/src/Ucp/Command/UcpSetupCommand.php @@ -14,7 +14,7 @@ use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Swag\AgenticCommerce\Ucp\Config\Validation\Finding; use Swag\AgenticCommerce\Ucp\Config\Validation\Severity; -use Swag\AgenticCommerce\Ucp\Config\Validation\UcpConfigValidator; +use Swag\AgenticCommerce\Ucp\Onboarding\ChannelFindingsResolver; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelDomainView; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelView; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelViewProvider; @@ -60,7 +60,7 @@ public function __construct( private readonly SalesChannelViewProvider $salesChannelViewProvider, private readonly UcpConfigService $configService, private readonly UcpSigningKeyService $signingKeyService, - private readonly UcpConfigValidator $validator, + private readonly ChannelFindingsResolver $findingsResolver, ) { parent::__construct(); } @@ -156,19 +156,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::FAILURE; } - $findings = $this->validator->validate( - $salesChannelId, - $channel->name ?? '', - $config, - $keys, - array_map(static fn (SalesChannelDomainView $domain): array => $domain->jsonSerialize(), $channel->domains), - ); + $findings = $this->findingsResolver->resolve($salesChannelId, $channel->name, $config, $channel); $this->renderFindings($io, $findings); $this->renderSummary($io, $config, $channel); $this->renderNextSteps($io, $dev, $agentHosts, $domainHosts, $salesChannelId); - return $this->hasErrors($findings) ? self::FAILURE : self::SUCCESS; + return $this->findingsResolver->hasError($findings) ? self::FAILURE : self::SUCCESS; } /** @@ -319,18 +313,4 @@ private function renderNextSteps(SymfonyStyle $io, bool $dev, array $agentHosts, $steps[] = \sprintf('Before go-live: bin/console ucp:config:validate --sales-channel=%s --strict', $salesChannelId); $io->listing($steps); } - - /** - * @param list $findings - */ - private function hasErrors(array $findings): bool - { - foreach ($findings as $finding) { - if (Severity::Error === $finding->severity) { - return true; - } - } - - return false; - } } diff --git a/src/Ucp/Config/DoctrineDbalUcpConfigRepository.php b/src/Ucp/Config/DoctrineDbalUcpConfigRepository.php index f26cbbfd..1d240ab3 100644 --- a/src/Ucp/Config/DoctrineDbalUcpConfigRepository.php +++ b/src/Ucp/Config/DoctrineDbalUcpConfigRepository.php @@ -15,7 +15,7 @@ #[Package('framework')] final class DoctrineDbalUcpConfigRepository implements UcpConfigRepositoryInterface { - private const TABLE = 'swag_agentic_commerce_ucp_config'; + public const TABLE = 'swag_agentic_commerce_ucp_config'; public function __construct( private readonly Connection $connection, diff --git a/src/Ucp/Config/UcpConfigService.php b/src/Ucp/Config/UcpConfigService.php index c9974c7e..db12ac0a 100644 --- a/src/Ucp/Config/UcpConfigService.php +++ b/src/Ucp/Config/UcpConfigService.php @@ -16,7 +16,7 @@ #[Package('framework')] final class UcpConfigService { - private const DOMAIN = 'SwagAgenticCommerce.config.'; + public const DOMAIN = 'SwagAgenticCommerce.config.'; /** * @var list diff --git a/src/Ucp/Identity/DoctrineDbalUcpOAuthStore.php b/src/Ucp/Identity/DoctrineDbalUcpOAuthStore.php index e6873aa2..cfa57940 100644 --- a/src/Ucp/Identity/DoctrineDbalUcpOAuthStore.php +++ b/src/Ucp/Identity/DoctrineDbalUcpOAuthStore.php @@ -15,10 +15,10 @@ final class DoctrineDbalUcpOAuthStore { private const ACCESS_TOKEN_TTL = 3600; private const AUTHORIZATION_CODE_TTL = 600; - private const CODE_TABLE = 'swag_agentic_commerce_ucp_oauth_code'; - private const ACCESS_TOKEN_TABLE = 'swag_agentic_commerce_ucp_oauth_access_token'; + public const CODE_TABLE = 'swag_agentic_commerce_ucp_oauth_code'; + public const ACCESS_TOKEN_TABLE = 'swag_agentic_commerce_ucp_oauth_access_token'; private const REFRESH_TOKEN_TTL = 2592000; - private const REFRESH_TOKEN_TABLE = 'swag_agentic_commerce_ucp_oauth_refresh_token'; + public const REFRESH_TOKEN_TABLE = 'swag_agentic_commerce_ucp_oauth_refresh_token'; /** * Upper bound of rows removed per DELETE so a large backlog cannot produce a single unbounded diff --git a/src/Ucp/Onboarding/AgenticFeedChannelCreator.php b/src/Ucp/Onboarding/AgenticFeedChannelCreator.php new file mode 100644 index 00000000..eb2b50ce --- /dev/null +++ b/src/Ucp/Onboarding/AgenticFeedChannelCreator.php @@ -0,0 +1,290 @@ + $salesChannelRepository + * @param EntityRepository $productStreamRepository + */ + public function __construct( + private readonly EntityRepository $salesChannelRepository, + private readonly EntityRepository $productStreamRepository, + private readonly FeedChannelLookup $feedChannelLookup, + private readonly FeedTemplateLoader $templateLoader, + private readonly SystemConfigService $systemConfigService, + ) { + } + + /** + * @param list $requests + * + * @return list + */ + public function create(array $requests, Context $context): array + { + if ([] === $requests) { + return []; + } + + $storefrontIds = array_values(array_unique(array_column($requests, 'storefrontSalesChannelId'))); + $storefronts = $this->storefronts($storefrontIds, $context); + $existing = $this->feedChannelLookup->forStorefronts($storefrontIds, $context); + $streamEnsured = false; + + $outcomes = []; + foreach ($requests as ['storefrontSalesChannelId' => $storefrontId, 'provider' => $provider]) { + $storefront = $storefronts->get($storefrontId); + $storefrontName = $storefront instanceof SalesChannelEntity ? $this->name($storefront) : null; + + $existingFeed = $this->existingFeed($existing[$storefrontId] ?? [], $provider); + if (null !== $existingFeed) { + $outcomes[] = FeedChannelOutcome::skipped($storefrontId, $storefrontName, $existingFeed); + + continue; + } + + $outcome = $this->createOne($storefrontId, $storefront, $provider, $streamEnsured, $context); + if ($outcome->isCreated() && null !== $outcome->salesChannelId) { + $existing[$storefrontId][] = new FeedChannelView($provider, $outcome->salesChannelId, $outcome->salesChannelName, $outcome->feedUrl, true); + } + $outcomes[] = $outcome; + } + + return $outcomes; + } + + private function createOne( + string $storefrontId, + ?SalesChannelEntity $storefront, + FeedProvider $provider, + bool &$streamEnsured, + Context $context, + ): FeedChannelOutcome { + if (null === $storefront) { + return FeedChannelOutcome::failed($storefrontId, null, $provider, FeedChannelOutcome::CODE_NOT_FOUND, \sprintf('Sales channel "%s" does not exist.', $storefrontId)); + } + + $storefrontName = $this->name($storefront); + $label = $storefrontName ?? $storefrontId; + + if (Defaults::SALES_CHANNEL_TYPE_STOREFRONT !== $storefront->getTypeId()) { + return FeedChannelOutcome::failed($storefrontId, $storefrontName, $provider, FeedChannelOutcome::CODE_NOT_A_STOREFRONT, \sprintf('Sales channel "%s" is not a storefront, a feed exports a storefront.', $label)); + } + + $domain = $this->domain($storefront); + if (null === $domain) { + return FeedChannelOutcome::failed($storefrontId, $storefrontName, $provider, FeedChannelOutcome::CODE_NO_STOREFRONT_DOMAIN, \sprintf('Sales channel "%s" has no domain, a feed needs a domain URL.', $label)); + } + + $salesChannelId = Uuid::randomHex(); + $salesChannelName = \sprintf('%s – %s', $label, $provider->channelNameSuffix()); + $fileName = \sprintf('agentic-commerce-%s-%s.%s', $provider->value, substr(Uuid::randomHex(), -8), $provider->fileExtension()); + $exportAccessKey = AccessKeyHelper::generateAccessKey('product-export'); + + try { + if (!$streamEnsured) { + $this->ensureDefaultProductStream($context); + $streamEnsured = true; + } + + $this->salesChannelRepository->create([ + $this->salesChannelPayload($salesChannelId, $salesChannelName, $storefront, [ + 'id' => Uuid::randomHex(), + 'productStreamId' => SwagAgenticCommerce::DEFAULT_PRODUCT_STREAM_ID, + 'storefrontSalesChannelId' => $storefrontId, + 'salesChannelDomainId' => $domain->getId(), + 'currencyId' => $storefront->getCurrencyId(), + 'fileName' => $fileName, + 'accessKey' => $exportAccessKey, + 'encoding' => 'UTF-8', + 'fileFormat' => $provider->fileFormat(), + 'generateByCronjob' => true, + 'interval' => self::FEED_INTERVAL_SECONDS, + 'includeVariants' => true, + 'provider' => $provider->value, + ...$this->templateLoader->load($provider), + ]), + ], $context); + + if (FeedProvider::OpenAi === $provider) { + $this->systemConfigService->set($provider->configDomain().'.returnPolicyUrl', $domain->getUrl(), $salesChannelId); + } + } catch (\Throwable $exception) { + return FeedChannelOutcome::failed($storefrontId, $storefrontName, $provider, FeedChannelOutcome::CODE_WRITE_FAILED, $exception->getMessage()); + } + + return FeedChannelOutcome::created( + $storefrontId, + $storefrontName, + $provider, + $salesChannelId, + $salesChannelName, + FeedChannelLookup::feedUrl($domain->getUrl(), $exportAccessKey, $fileName), + ); + } + + /** + * @param array $productExport + * + * @return array + */ + private function salesChannelPayload(string $id, string $name, SalesChannelEntity $storefront, array $productExport): array + { + $translations = [Defaults::LANGUAGE_SYSTEM => ['name' => $name]]; + $translations[$storefront->getLanguageId()] = ['name' => $name]; + + $payload = [ + 'id' => $id, + 'typeId' => SwagAgenticCommerce::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE, + 'active' => true, + 'accessKey' => AccessKeyHelper::generateAccessKey('sales-channel'), + 'languageId' => $storefront->getLanguageId(), + 'currencyId' => $storefront->getCurrencyId(), + 'paymentMethodId' => $storefront->getPaymentMethodId(), + 'shippingMethodId' => $storefront->getShippingMethodId(), + 'countryId' => $storefront->getCountryId(), + 'customerGroupId' => $storefront->getCustomerGroupId(), + 'navigationCategoryId' => $storefront->getNavigationCategoryId(), + 'navigationCategoryVersionId' => $storefront->getNavigationCategoryVersionId(), + 'translations' => $translations, + 'languages' => $this->idList($storefront->getLanguages(), $storefront->getLanguageId()), + 'currencies' => $this->idList($storefront->getCurrencies(), $storefront->getCurrencyId()), + 'countries' => $this->idList($storefront->getCountries(), $storefront->getCountryId()), + 'paymentMethods' => $this->idList($storefront->getPaymentMethods(), $storefront->getPaymentMethodId()), + 'shippingMethods' => $this->idList($storefront->getShippingMethods(), $storefront->getShippingMethodId()), + 'productExports' => [$productExport], + ]; + + $optional = [ + 'footerCategoryId' => $storefront->getFooterCategoryId(), + 'footerCategoryVersionId' => $storefront->getFooterCategoryVersionId(), + 'serviceCategoryId' => $storefront->getServiceCategoryId(), + 'serviceCategoryVersionId' => $storefront->getServiceCategoryVersionId(), + 'homeCmsPageId' => $storefront->getHomeCmsPageId(), + 'homeCmsPageVersionId' => $storefront->getHomeCmsPageVersionId(), + ]; + foreach ($optional as $key => $value) { + if (null !== $value) { + $payload[$key] = $value; + } + } + + return $payload; + } + + /** + * The singular default must be part of the to-many list, or the write is rejected. + * + * @param EntityCollection|null $collection + * + * @return list + */ + private function idList(?EntityCollection $collection, string $defaultId): array + { + $ids = null === $collection ? [] : $collection->getIds(); + $ids[$defaultId] = $defaultId; + + return array_map(static fn (string $id): array => ['id' => $id], array_values($ids)); + } + + private function ensureDefaultProductStream(Context $context): void + { + $id = SwagAgenticCommerce::DEFAULT_PRODUCT_STREAM_ID; + if (null !== $this->productStreamRepository->searchIds(new Criteria([$id]), $context)->firstId()) { + return; + } + + $this->productStreamRepository->create([[ + 'id' => $id, + 'name' => self::DEFAULT_PRODUCT_STREAM_NAME, + 'filters' => [['type' => 'equals', 'field' => 'active', 'value' => '1']], + ]], $context); + } + + /** + * @param list $ids + */ + private function storefronts(array $ids, Context $context): SalesChannelCollection + { + $ids = array_values(array_filter($ids, Uuid::isValid(...))); + if ([] === $ids) { + return new SalesChannelCollection(); + } + + $criteria = new Criteria($ids); + foreach (['domains', 'languages', 'currencies', 'countries', 'paymentMethods', 'shippingMethods'] as $association) { + $criteria->addAssociation($association); + } + + return $this->salesChannelRepository->search($criteria, $context)->getEntities(); + } + + private function domain(SalesChannelEntity $storefront): ?SalesChannelDomainEntity + { + $domains = $storefront->getDomains(); + if (null === $domains || 0 === $domains->count()) { + return null; + } + + foreach ($domains as $domain) { + if ($domain->getLanguageId() === $storefront->getLanguageId()) { + return $domain; + } + } + + return $domains->first(); + } + + /** + * @param list $feeds + */ + private function existingFeed(array $feeds, FeedProvider $provider): ?FeedChannelView + { + foreach ($feeds as $feed) { + if ($feed->provider === $provider) { + return $feed; + } + } + + return null; + } + + private function name(SalesChannelEntity $salesChannel): ?string + { + $name = $salesChannel->getTranslation('name') ?? $salesChannel->getName(); + + return \is_string($name) ? $name : null; + } +} diff --git a/src/Ucp/Onboarding/BulkUcpActivator.php b/src/Ucp/Onboarding/BulkUcpActivator.php new file mode 100644 index 00000000..b3bf1308 --- /dev/null +++ b/src/Ucp/Onboarding/BulkUcpActivator.php @@ -0,0 +1,156 @@ + $salesChannelIds + * @param list $enabledCapabilities + * @param list $enabledTransports + * + * @return list + */ + public function activate( + array $salesChannelIds, + array $enabledCapabilities, + array $enabledTransports, + Context $context, + ): array { + if ([] === $salesChannelIds) { + return []; + } + + $views = $this->viewsById($context); + $stored = $this->configService->getConfigs($salesChannelIds); + + $payload = [ + 'active' => true, + 'enabledCapabilities' => $enabledCapabilities, + 'enabledTransports' => $enabledTransports, + ]; + + $outcomes = []; + foreach ($salesChannelIds as $salesChannelId) { + $outcomes[] = $this->activateOne( + $salesChannelId, + $views[$salesChannelId] ?? null, + $stored[$salesChannelId] ?? null, + $payload, + ); + } + + return $outcomes; + } + + /** + * @param array $payload + */ + private function activateOne( + string $salesChannelId, + ?SalesChannelView $view, + ?UcpConfig $stored, + array $payload, + ): ChannelActivationOutcome { + $name = $view?->name; + + if (null !== $stored && $this->isAlreadySatisfied($stored, $payload)) { + return ChannelActivationOutcome::skipped($salesChannelId, $name, ChannelActivationOutcome::REASON_ALREADY_ACTIVE); + } + + try { + $config = $this->configService->saveConfig($payload, $salesChannelId); + } catch (UcpConfigException $exception) { + return ChannelActivationOutcome::failed($salesChannelId, $name, $exception->getErrorCode(), $exception->getMessage()); + } + + return ChannelActivationOutcome::enabled($salesChannelId, $name, $this->findingsResolver->resolve($salesChannelId, $name, $config, $view)); + } + + /** + * Already active with the requested capabilities and transports, so writing + * would be a no-op. Reported rather than silently repeated, so the dialog + * can say the channel was already on instead of claiming it turned it on. + * + * @param array $payload + */ + private function isAlreadySatisfied(UcpConfig $stored, array $payload): bool + { + if (!$stored->active) { + return false; + } + + return $this->sameSet($stored->enabledCapabilities, $payload['enabledCapabilities']) + && $this->sameSet($stored->enabledTransports, $payload['enabledTransports']); + } + + /** + * @param list $stored + */ + private function sameSet(array $stored, mixed $requested): bool + { + if (!\is_array($requested)) { + return false; + } + + $left = array_values(array_unique($stored)); + /** @var list $right */ + $right = array_values(array_unique($requested)); + sort($left); + sort($right); + + return $left === $right; + } + + /** + * @return array + */ + private function viewsById(Context $context): array + { + $views = []; + foreach ($this->salesChannelViewProvider->all($context) as $view) { + $views[$view->id] = $view; + } + + return $views; + } +} diff --git a/src/Ucp/Onboarding/ChannelActivationOutcome.php b/src/Ucp/Onboarding/ChannelActivationOutcome.php new file mode 100644 index 00000000..cc5e5adb --- /dev/null +++ b/src/Ucp/Onboarding/ChannelActivationOutcome.php @@ -0,0 +1,96 @@ + $findings readiness findings from UcpConfigValidator, empty unless enabled + */ + private function __construct( + public readonly string $salesChannelId, + public readonly ?string $salesChannelName, + public readonly string $status, + public readonly array $findings = [], + public readonly ?string $reason = null, + public readonly ?string $code = null, + public readonly ?string $message = null, + ) { + } + + /** + * @param list $findings + */ + public static function enabled(string $salesChannelId, ?string $salesChannelName, array $findings): self + { + return new self($salesChannelId, $salesChannelName, self::STATUS_ENABLED, $findings); + } + + public static function skipped(string $salesChannelId, ?string $salesChannelName, string $reason): self + { + return new self($salesChannelId, $salesChannelName, self::STATUS_SKIPPED, [], $reason); + } + + public static function failed(string $salesChannelId, ?string $salesChannelName, string $code, string $message): self + { + return new self($salesChannelId, $salesChannelName, self::STATUS_FAILED, [], null, $code, $message); + } + + public function isEnabled(): bool + { + return self::STATUS_ENABLED === $this->status; + } + + public function isSkipped(): bool + { + return self::STATUS_SKIPPED === $this->status; + } + + public function isFailed(): bool + { + return self::STATUS_FAILED === $this->status; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $payload = [ + 'salesChannelId' => $this->salesChannelId, + 'salesChannelName' => $this->salesChannelName, + 'status' => $this->status, + 'findings' => array_map(static fn (Finding $finding): array => $finding->toArray(), $this->findings), + ]; + + foreach (['reason' => $this->reason, 'code' => $this->code, 'message' => $this->message] as $key => $value) { + if (null !== $value) { + $payload[$key] = $value; + } + } + + return $payload; + } +} diff --git a/src/Ucp/Onboarding/ChannelFindingsResolver.php b/src/Ucp/Onboarding/ChannelFindingsResolver.php new file mode 100644 index 00000000..fe4cba22 --- /dev/null +++ b/src/Ucp/Onboarding/ChannelFindingsResolver.php @@ -0,0 +1,70 @@ + + */ + public function resolve(string $salesChannelId, ?string $name, UcpConfig $config, ?SalesChannelView $view): array + { + try { + $signingKeys = $this->signingKeyService->all($salesChannelId); + } catch (UcpSigningKeyException) { + // A key that cannot be read costs this channel its readiness report, + // never the caller's operation. + $signingKeys = []; + } + + $domains = null === $view ? [] : array_map( + static fn (SalesChannelDomainView $domain): array => $domain->jsonSerialize(), + $view->domains, + ); + + return $this->validator->validate($salesChannelId, $name ?? '', $config, $signingKeys, $domains); + } + + /** + * @param list $findings + */ + public function hasError(array $findings): bool + { + foreach ($findings as $finding) { + if (Severity::Error === $finding->severity) { + return true; + } + } + + return false; + } +} diff --git a/src/Ucp/Onboarding/ChannelReadiness.php b/src/Ucp/Onboarding/ChannelReadiness.php new file mode 100644 index 00000000..131770a1 --- /dev/null +++ b/src/Ucp/Onboarding/ChannelReadiness.php @@ -0,0 +1,67 @@ + $domains + * @param list $findings + * @param list $feeds Agentic Commerce channels exporting this storefront + */ + public function __construct( + public readonly string $id, + public readonly ?string $name, + public readonly string $typeId, + public readonly bool $storefront, + public readonly array $domains, + public readonly int $activeProductCount, + public readonly bool $prepared, + public readonly array $findings = [], + public readonly array $feeds = [], + ) { + } + + public function hasDomain(): bool + { + return [] !== $this->domains; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'typeId' => $this->typeId, + 'storefront' => $this->storefront, + 'domains' => array_map( + static fn (SalesChannelDomainView $domain): array => $domain->jsonSerialize(), + $this->domains, + ), + 'activeProductCount' => $this->activeProductCount, + 'prepared' => $this->prepared, + 'findings' => array_map(static fn (Finding $finding): array => $finding->toArray(), $this->findings), + 'feeds' => array_map(static fn (FeedChannelView $feed): array => $feed->jsonSerialize(), $this->feeds), + ]; + } +} diff --git a/src/Ucp/Onboarding/DoctrineDbalOnboardingMetrics.php b/src/Ucp/Onboarding/DoctrineDbalOnboardingMetrics.php new file mode 100644 index 00000000..1cfedf83 --- /dev/null +++ b/src/Ucp/Onboarding/DoctrineDbalOnboardingMetrics.php @@ -0,0 +1,73 @@ + Uuid::fromHexToBytes($id), $salesChannelIds); + + /** @var list $rows */ + $rows = $this->connection->fetchAllAssociative( + 'SELECT LOWER(HEX(pv.sales_channel_id)) AS sales_channel_id, + COUNT(DISTINCT pv.product_id) AS product_count + FROM product_visibility pv + INNER JOIN product p + ON p.id = pv.product_id + AND p.version_id = pv.product_version_id + WHERE pv.sales_channel_id IN (:salesChannelIds) + AND p.version_id = :liveVersion + AND p.active = 1 + GROUP BY pv.sales_channel_id', + [ + 'salesChannelIds' => $binaryIds, + 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + ], + ['salesChannelIds' => ArrayParameterType::BINARY], + ); + + $counts = []; + foreach ($rows as $row) { + $counts[$row['sales_channel_id']] = (int) $row['product_count']; + } + + return $counts; + } + + public function agenticSalesChannelCount(): int + { + return (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM sales_channel WHERE type_id = :typeId AND active = 1', + ['typeId' => Uuid::fromHexToBytes(SwagAgenticCommerce::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE)], + ); + } +} diff --git a/src/Ucp/Onboarding/FeedChannelLookup.php b/src/Ucp/Onboarding/FeedChannelLookup.php new file mode 100644 index 00000000..d65ca700 --- /dev/null +++ b/src/Ucp/Onboarding/FeedChannelLookup.php @@ -0,0 +1,106 @@ + $productExportRepository + */ + public function __construct( + private readonly EntityRepository $productExportRepository, + ) { + } + + /** + * @param list $storefrontIds + * + * @return array> keyed by storefront sales channel id + */ + public function forStorefronts(array $storefrontIds, Context $context): array + { + $storefrontIds = array_values(array_filter($storefrontIds, Uuid::isValid(...))); + if ([] === $storefrontIds) { + return []; + } + + $criteria = new Criteria(); + $criteria->addAssociation('salesChannel'); + $criteria->addAssociation('salesChannelDomain'); + $criteria->addFilter( + new EqualsFilter('salesChannel.typeId', SwagAgenticCommerce::SALES_CHANNEL_TYPE_AGENTIC_COMMERCE), + new EqualsAnyFilter('storefrontSalesChannelId', $storefrontIds), + new EqualsAnyFilter('provider', array_map(static fn (FeedProvider $p): string => $p->value, FeedProvider::cases())), + ); + $criteria->addSorting(new FieldSorting('createdAt', FieldSorting::ASCENDING)); + + $byStorefront = []; + foreach ($this->productExportRepository->search($criteria, $context)->getEntities() as $export) { + if (!$export instanceof ProductExportEntity) { + continue; + } + + $provider = FeedProvider::tryFrom((string) ($export->has('provider') ? $export->get('provider') : '')); + $storefrontId = $export->getStorefrontSalesChannelId(); + if (null === $provider || isset($byStorefront[$storefrontId][$provider->value])) { + continue; + } + + $byStorefront[$storefrontId][$provider->value] = $this->view($export, $provider); + } + + $result = []; + foreach ($byStorefront as $storefrontId => $feeds) { + $ordered = []; + foreach (FeedProvider::cases() as $provider) { + if (isset($feeds[$provider->value])) { + $ordered[] = $feeds[$provider->value]; + } + } + $result[$storefrontId] = $ordered; + } + + return $result; + } + + public static function feedUrl(string $domainUrl, string $accessKey, string $fileName): string + { + return \sprintf('%s/store-api/product-export/%s/%s', rtrim($domainUrl, '/'), $accessKey, $fileName); + } + + private function view(ProductExportEntity $export, FeedProvider $provider): FeedChannelView + { + $salesChannel = $export->getSalesChannel(); + $domain = $export->getSalesChannelDomain(); + $name = $salesChannel?->getTranslation('name') ?? $salesChannel?->getName(); + + return new FeedChannelView( + $provider, + $export->getSalesChannelId(), + \is_string($name) ? $name : null, + null === $domain ? null : self::feedUrl($domain->getUrl(), $export->getAccessKey(), $export->getFileName()), + (bool) $salesChannel?->getActive(), + ); + } +} diff --git a/src/Ucp/Onboarding/FeedChannelOutcome.php b/src/Ucp/Onboarding/FeedChannelOutcome.php new file mode 100644 index 00000000..2ede99d9 --- /dev/null +++ b/src/Ucp/Onboarding/FeedChannelOutcome.php @@ -0,0 +1,124 @@ +provider, + self::STATUS_SKIPPED, + $existing->salesChannelId, + $existing->salesChannelName, + $existing->feedUrl, + self::REASON_ALREADY_CONNECTED, + ); + } + + public static function failed( + string $storefrontSalesChannelId, + ?string $storefrontSalesChannelName, + FeedProvider $provider, + string $code, + string $message, + ): self { + return new self($storefrontSalesChannelId, $storefrontSalesChannelName, $provider, self::STATUS_FAILED, code: $code, message: $message); + } + + public function isCreated(): bool + { + return self::STATUS_CREATED === $this->status; + } + + public function isSkipped(): bool + { + return self::STATUS_SKIPPED === $this->status; + } + + public function isFailed(): bool + { + return self::STATUS_FAILED === $this->status; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $payload = [ + 'storefrontSalesChannelId' => $this->storefrontSalesChannelId, + 'storefrontSalesChannelName' => $this->storefrontSalesChannelName, + 'provider' => $this->provider->value, + 'status' => $this->status, + ]; + + $optional = [ + 'reason' => $this->reason, + 'salesChannelId' => $this->salesChannelId, + 'salesChannelName' => $this->salesChannelName, + 'feedUrl' => $this->feedUrl, + 'code' => $this->code, + 'message' => $this->message, + ]; + foreach ($optional as $key => $value) { + if (null !== $value) { + $payload[$key] = $value; + } + } + + return $payload; + } +} diff --git a/src/Ucp/Onboarding/FeedChannelView.php b/src/Ucp/Onboarding/FeedChannelView.php new file mode 100644 index 00000000..c3478c8a --- /dev/null +++ b/src/Ucp/Onboarding/FeedChannelView.php @@ -0,0 +1,40 @@ + $this->provider->value, + 'salesChannelId' => $this->salesChannelId, + 'salesChannelName' => $this->salesChannelName, + 'feedUrl' => $this->feedUrl, + 'active' => $this->active, + ]; + } +} diff --git a/src/Ucp/Onboarding/FeedProvider.php b/src/Ucp/Onboarding/FeedProvider.php new file mode 100644 index 00000000..a4702294 --- /dev/null +++ b/src/Ucp/Onboarding/FeedProvider.php @@ -0,0 +1,55 @@ + SwagAgenticCommerce::FILE_FORMAT_JSONL, + self::Google => 'xml', + }; + } + + public function fileExtension(): string + { + return $this->fileFormat(); + } + + public function channelNameSuffix(): string + { + return match ($this) { + self::OpenAi => 'OpenAI', + self::Google => 'Google Shopping', + }; + } + + public function templateDirectory(): string + { + return $this->value; + } + + public function configDomain(): string + { + return match ($this) { + self::OpenAi => SwagAgenticCommerce::OPEN_AI_PRODUCT_EXPORT_CONFIG_DOMAIN, + self::Google => SwagAgenticCommerce::GOOGLE_PRODUCT_EXPORT_CONFIG_DOMAIN, + }; + } +} diff --git a/src/Ucp/Onboarding/FeedTemplateLoader.php b/src/Ucp/Onboarding/FeedTemplateLoader.php new file mode 100644 index 00000000..89b4896e --- /dev/null +++ b/src/Ucp/Onboarding/FeedTemplateLoader.php @@ -0,0 +1,52 @@ + $this->read($provider, 'header', $extension), + 'bodyTemplate' => $this->read($provider, 'body', $extension), + 'footerTemplate' => $this->read($provider, 'footer', $extension), + ]; + } + + private function read(FeedProvider $provider, string $part, string $extension): string + { + $path = \sprintf('%s/%s/%s.%s.twig', $this->directory, $provider->templateDirectory(), $part, $extension); + if (!is_file($path)) { + return ''; + } + + $content = file_get_contents($path); + + return false === $content ? '' : $content; + } +} diff --git a/src/Ucp/Onboarding/OnboardingDismissal.php b/src/Ucp/Onboarding/OnboardingDismissal.php new file mode 100644 index 00000000..b84615c9 --- /dev/null +++ b/src/Ucp/Onboarding/OnboardingDismissal.php @@ -0,0 +1,43 @@ +executeStatement( + 'DELETE FROM `user_config` WHERE `key` = :key', + ['key' => self::USER_CONFIG_KEY], + ); + } catch (\Throwable) { + // Never fail an install over a dialog preference. The worst case is + // that an admin who dismissed the offer does not see it again. + } + } +} diff --git a/src/Ucp/Onboarding/OnboardingMetricsInterface.php b/src/Ucp/Onboarding/OnboardingMetricsInterface.php new file mode 100644 index 00000000..b5a02d27 --- /dev/null +++ b/src/Ucp/Onboarding/OnboardingMetricsInterface.php @@ -0,0 +1,35 @@ + $salesChannelIds + * + * @return array + */ + public function activeProductCounts(array $salesChannelIds): array; + + /** + * Active sales channels of the Agentic Commerce type, which is what + * "connected to an AI provider" means on the readiness page. + */ + public function agenticSalesChannelCount(): int; +} diff --git a/src/Ucp/Onboarding/ShopReadiness.php b/src/Ucp/Onboarding/ShopReadiness.php new file mode 100644 index 00000000..5a33e11c --- /dev/null +++ b/src/Ucp/Onboarding/ShopReadiness.php @@ -0,0 +1,75 @@ + $channels + */ + public function __construct( + public readonly string $status, + public readonly int $preparedCount, + public readonly int $agenticSalesChannelCount, + public readonly array $channels, + ) { + } + + public function isPrepared(): bool + { + return $this->preparedCount > 0; + } + + public function isConnected(): bool + { + return $this->agenticSalesChannelCount > 0; + } + + public function completedSteps(): int + { + return 1 + ($this->isPrepared() ? 1 : 0) + ($this->isConnected() ? 1 : 0); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'status' => $this->status, + 'completedSteps' => $this->completedSteps(), + 'totalSteps' => 3, + 'steps' => [ + // The extension answering this route is itself the proof of step one. + 'installed' => ['done' => true], + 'prepared' => ['done' => $this->isPrepared(), 'count' => $this->preparedCount], + 'connected' => ['done' => $this->isConnected(), 'count' => $this->agenticSalesChannelCount], + ], + 'channels' => array_map( + static fn (ChannelReadiness $channel): array => $channel->jsonSerialize(), + $this->channels, + ), + ]; + } +} diff --git a/src/Ucp/Onboarding/ShopReadinessProvider.php b/src/Ucp/Onboarding/ShopReadinessProvider.php new file mode 100644 index 00000000..fd34c5d6 --- /dev/null +++ b/src/Ucp/Onboarding/ShopReadinessProvider.php @@ -0,0 +1,117 @@ +salesChannelViewProvider->all($context), + static fn (SalesChannelView $view): bool => $view->active, + )); + $salesChannelIds = array_map(static fn (SalesChannelView $view): string => $view->id, $views); + + $configs = $this->configService->getConfigs($salesChannelIds); + $productCounts = $this->metrics->activeProductCounts($salesChannelIds); + $storefrontIds = array_values(array_map( + static fn (SalesChannelView $view): string => $view->id, + array_filter($views, static fn (SalesChannelView $view): bool => self::isStorefront($view)), + )); + $feeds = $this->feedChannelLookup->forStorefronts($storefrontIds, $context); + + $channels = []; + $preparedCount = 0; + $hasError = false; + + foreach ($views as $view) { + $config = $configs[$view->id] ?? null; + $prepared = null !== $config && $config->active; + + $findings = []; + if ($prepared) { + ++$preparedCount; + $findings = $this->findingsResolver->resolve($view->id, $view->name, $config, $view); + $hasError = $hasError || $this->findingsResolver->hasError($findings); + } + + $channels[] = new ChannelReadiness( + $view->id, + $view->name, + $view->typeId, + self::isStorefront($view), + $view->domains, + $productCounts[$view->id] ?? 0, + $prepared, + $findings, + $feeds[$view->id] ?? [], + ); + } + + $agenticSalesChannelCount = $this->metrics->agenticSalesChannelCount(); + + return new ShopReadiness( + $this->status($preparedCount, $agenticSalesChannelCount, $hasError), + $preparedCount, + $agenticSalesChannelCount, + $channels, + ); + } + + private static function isStorefront(SalesChannelView $view): bool + { + return Defaults::SALES_CHANNEL_TYPE_STOREFRONT === $view->typeId; + } + + /** + * Nothing exposed is a setup problem. Anything exposed but broken, or a shop + * that has not connected a provider yet, needs an action. Only a shop that + * finished every step with no error-level finding is reported as ready. + */ + private function status(int $preparedCount, int $agenticSalesChannelCount, bool $hasError): string + { + if (0 === $preparedCount) { + return ShopReadiness::STATUS_SETUP_NEEDED; + } + + if ($hasError || 0 === $agenticSalesChannelCount) { + return ShopReadiness::STATUS_ACTION_NEEDED; + } + + return ShopReadiness::STATUS_READY; + } +} diff --git a/src/Ucp/SalesChannel/SalesChannelView.php b/src/Ucp/SalesChannel/SalesChannelView.php index 0fa8c2c5..00f9b66d 100644 --- a/src/Ucp/SalesChannel/SalesChannelView.php +++ b/src/Ucp/SalesChannel/SalesChannelView.php @@ -19,11 +19,12 @@ public function __construct( public readonly string $typeId, public readonly bool $transactional, public readonly array $domains, + public readonly bool $active = true, ) { } /** - * @return array{id: string, name: string|null, typeId: string, transactional: bool, domains: list} + * @return array{id: string, name: string|null, typeId: string, transactional: bool, active: bool, domains: list} */ public function jsonSerialize(): array { @@ -32,6 +33,7 @@ public function jsonSerialize(): array 'name' => $this->name, 'typeId' => $this->typeId, 'transactional' => $this->transactional, + 'active' => $this->active, 'domains' => array_map( static fn (SalesChannelDomainView $domain): array => $domain->jsonSerialize(), $this->domains, diff --git a/src/Ucp/SalesChannel/SalesChannelViewProvider.php b/src/Ucp/SalesChannel/SalesChannelViewProvider.php index 8ada67ec..1e8805c3 100644 --- a/src/Ucp/SalesChannel/SalesChannelViewProvider.php +++ b/src/Ucp/SalesChannel/SalesChannelViewProvider.php @@ -103,6 +103,7 @@ private function view(SalesChannelEntity $salesChannel, SalesChannelTypeClassifi $salesChannel->getTypeId(), $typeClassification->isTransactional(), $domains, + $salesChannel->getActive(), ); } } diff --git a/tests/Functional/Ucp/UcpOnboardingBulkEnableTest.php b/tests/Functional/Ucp/UcpOnboardingBulkEnableTest.php new file mode 100644 index 00000000..906a674a --- /dev/null +++ b/tests/Functional/Ucp/UcpOnboardingBulkEnableTest.php @@ -0,0 +1,153 @@ +storefrontSalesChannelId(); + + $this->getBrowser()->request('POST', self::ROUTE, [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'salesChannelIds' => [$salesChannelId], + 'enabledCapabilities' => ['catalog', 'cart'], + 'enabledTransports' => ['rest'], + ])); + + $response = $this->getBrowser()->getResponse(); + self::assertSame(Response::HTTP_OK, $response->getStatusCode(), (string) $response->getContent()); + + $data = $this->decode($response->getContent()); + self::assertSame(1, $data['enabled']); + self::assertSame(0, $data['failed']); + self::assertSame('enabled', $data['results'][0]['status']); + + $config = static::getContainer()->get(UcpConfigService::class)->getConfig($salesChannelId); + self::assertTrue($config->active); + self::assertSame(['catalog', 'cart'], $config->enabledCapabilities); + self::assertSame(['rest'], $config->enabledTransports); + + self::assertNotSame([], static::getContainer()->get(UcpSigningKeyService::class)->all($salesChannelId)); + } + + #[Test] + public function testAnIneligibleChannelFailsItsOwnRowAndTheBatchStillSucceeds(): void + { + $salesChannelId = $this->storefrontSalesChannelId(); + $unknownId = Uuid::randomHex(); + + $this->getBrowser()->request('POST', self::ROUTE, [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'salesChannelIds' => [$unknownId, $salesChannelId], + 'enabledCapabilities' => ['catalog'], + 'enabledTransports' => ['rest'], + ])); + + $response = $this->getBrowser()->getResponse(); + self::assertSame(Response::HTTP_OK, $response->getStatusCode(), (string) $response->getContent()); + + $data = $this->decode($response->getContent()); + self::assertSame(1, $data['failed']); + self::assertSame(1, $data['enabled']); + + self::assertSame('failed', $data['results'][0]['status']); + self::assertSame('SWAG_AGENTIC_COMMERCE__UCP_SALES_CHANNEL_TYPE_NOT_SUPPORTED', $data['results'][0]['code']); + self::assertSame('enabled', $data['results'][1]['status']); + } + + #[Test] + public function testReadinessReportsSetupNeededAndFlipsOnceAChannelIsPrepared(): void + { + $this->getBrowser()->request('GET', self::READINESS_ROUTE); + $before = $this->decode($this->getBrowser()->getResponse()->getContent()); + + self::assertSame('setup_needed', $before['status']); + self::assertSame(1, $before['completedSteps']); + self::assertTrue($before['steps']['installed']['done']); + self::assertFalse($before['steps']['prepared']['done']); + self::assertNotSame([], $before['channels']); + + $this->getBrowser()->request('POST', self::ROUTE, [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode(['salesChannelIds' => [$this->storefrontSalesChannelId()]])); + self::assertSame(Response::HTTP_OK, $this->getBrowser()->getResponse()->getStatusCode()); + + $this->getBrowser()->request('GET', self::READINESS_ROUTE); + $after = $this->decode($this->getBrowser()->getResponse()->getContent()); + + self::assertTrue($after['steps']['prepared']['done']); + self::assertSame(1, $after['steps']['prepared']['count']); + // No Agentic Commerce sales channel exists, so step three is still open. + self::assertSame('action_needed', $after['status']); + } + + #[Test] + public function testAnUnknownCapabilityIsRejectedBeforeAnythingIsWritten(): void + { + $this->getBrowser()->request('POST', self::ROUTE, [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], (string) json_encode([ + 'salesChannelIds' => [$this->storefrontSalesChannelId()], + 'enabledCapabilities' => ['teleportation'], + ])); + + self::assertSame(Response::HTTP_BAD_REQUEST, $this->getBrowser()->getResponse()->getStatusCode()); + } + + /** + * @return array + */ + private function decode(string|false $content): array + { + $payload = json_decode((string) $content, true, 512, \JSON_THROW_ON_ERROR); + self::assertIsArray($payload); + self::assertIsArray($payload['data'] ?? null); + + return $payload['data']; + } + + private function storefrontSalesChannelId(): string + { + $url = rtrim((string) EnvironmentHelper::getVariable('APP_URL'), '/'); + $salesChannelId = static::getContainer()->get(Connection::class)->fetchOne( + 'SELECT LOWER(HEX(sales_channel_id)) FROM sales_channel_domain WHERE url = :url LIMIT 1', + ['url' => $url], + ); + self::assertIsString($salesChannelId, \sprintf('Expected a storefront sales-channel domain at APP_URL (%s).', $url)); + + return $salesChannelId; + } +} diff --git a/tests/Integration/Ucp/Onboarding/DoctrineDbalOnboardingMetricsTest.php b/tests/Integration/Ucp/Onboarding/DoctrineDbalOnboardingMetricsTest.php new file mode 100644 index 00000000..979b847c --- /dev/null +++ b/tests/Integration/Ucp/Onboarding/DoctrineDbalOnboardingMetricsTest.php @@ -0,0 +1,117 @@ +connection = KernelLifecycleManager::getConnection(); + $this->connection->beginTransaction(); + $this->metrics = new DoctrineDbalOnboardingMetrics($this->connection); + } + + protected function tearDown(): void + { + $this->connection->rollBack(); + } + + public function testItCountsOnlyActiveProductsVisibleInTheChannel(): void + { + $salesChannelId = $this->firstStorefrontSalesChannelId(); + + $before = $this->metrics->activeProductCounts([$salesChannelId])[$salesChannelId] ?? 0; + + $active = $this->createProduct(active: true); + $inactive = $this->createProduct(active: false); + $this->makeVisible($active, $salesChannelId); + $this->makeVisible($inactive, $salesChannelId); + + $after = $this->metrics->activeProductCounts([$salesChannelId])[$salesChannelId] ?? 0; + + self::assertSame($before + 1, $after, 'Only the active product should raise the count.'); + } + + public function testAChannelWithoutVisibleProductsIsAbsentRatherThanZero(): void + { + $emptyChannelId = Uuid::randomHex(); + + self::assertSame([], $this->metrics->activeProductCounts([$emptyChannelId])); + } + + public function testItAsksForNothingWhenGivenNoChannels(): void + { + self::assertSame([], $this->metrics->activeProductCounts([])); + } + + public function testItCountsActiveAgenticCommerceSalesChannels(): void + { + // The stock test database ships none, so the count proves the type filter + // rather than an arbitrary number. + self::assertSame(0, $this->metrics->agenticSalesChannelCount()); + } + + private function firstStorefrontSalesChannelId(): string + { + $id = $this->connection->fetchOne( + 'SELECT LOWER(HEX(id)) FROM sales_channel WHERE type_id = :typeId AND active = 1 LIMIT 1', + ['typeId' => Uuid::fromHexToBytes(Defaults::SALES_CHANNEL_TYPE_STOREFRONT)], + ); + self::assertIsString($id, 'Expected an active storefront sales channel in the test database.'); + + return $id; + } + + private function createProduct(bool $active): string + { + $id = Uuid::randomHex(); + $taxId = $this->connection->fetchOne('SELECT LOWER(HEX(id)) FROM tax LIMIT 1'); + self::assertIsString($taxId); + + $this->connection->insert('product', [ + 'id' => Uuid::fromHexToBytes($id), + 'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'product_number' => 'metrics-'.$id, + 'stock' => 1, + 'tax_id' => Uuid::fromHexToBytes($taxId), + 'active' => $active ? 1 : 0, + 'available' => 1, + 'is_closeout' => 0, + 'created_at' => (new \DateTimeImmutable())->format(Defaults::STORAGE_DATE_TIME_FORMAT), + ]); + + return $id; + } + + private function makeVisible(string $productId, string $salesChannelId): void + { + $this->connection->insert('product_visibility', [ + 'id' => Uuid::randomBytes(), + 'product_id' => Uuid::fromHexToBytes($productId), + 'product_version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'sales_channel_id' => Uuid::fromHexToBytes($salesChannelId), + 'visibility' => 30, + 'created_at' => (new \DateTimeImmutable())->format(Defaults::STORAGE_DATE_TIME_FORMAT), + ]); + } +} diff --git a/tests/Unit/Ucp/Command/UcpChannelsCommandTest.php b/tests/Unit/Ucp/Command/UcpChannelsCommandTest.php index e958470c..84323c83 100644 --- a/tests/Unit/Ucp/Command/UcpChannelsCommandTest.php +++ b/tests/Unit/Ucp/Command/UcpChannelsCommandTest.php @@ -82,6 +82,7 @@ private function resolver(array $channels): SalesChannelResolver $entity->setUniqueIdentifier($id); $entity->setName($name); $entity->setTypeId('0191cccccccc7000cccccccccccccccc'); + $entity->setActive(true); $entities[] = $entity; } diff --git a/tests/Unit/Ucp/Command/UcpConfigSetCommandTest.php b/tests/Unit/Ucp/Command/UcpConfigSetCommandTest.php index f42caf24..f99449f7 100644 --- a/tests/Unit/Ucp/Command/UcpConfigSetCommandTest.php +++ b/tests/Unit/Ucp/Command/UcpConfigSetCommandTest.php @@ -127,6 +127,7 @@ private function resolver(): SalesChannelResolver $entity->setUniqueIdentifier(self::STORE_ID); $entity->setName('Storefront'); $entity->setTypeId('0191cccccccc7000cccccccccccccccc'); + $entity->setActive(true); $searchResult = $this->createMock(EntitySearchResult::class); $searchResult->method('getEntities')->willReturn(new SalesChannelCollection([$entity])); diff --git a/tests/Unit/Ucp/Command/UcpConfigValidateCommandTest.php b/tests/Unit/Ucp/Command/UcpConfigValidateCommandTest.php index 85f75eeb..2242a859 100644 --- a/tests/Unit/Ucp/Command/UcpConfigValidateCommandTest.php +++ b/tests/Unit/Ucp/Command/UcpConfigValidateCommandTest.php @@ -28,6 +28,7 @@ use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Swag\AgenticCommerce\Ucp\Config\Validation\UcpConfigValidator; +use Swag\AgenticCommerce\Ucp\Onboarding\ChannelFindingsResolver; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelViewProvider; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -123,8 +124,7 @@ private function commandWithRepository(UcpConfigRepositoryInterface $configRepos $viewProvider, new SalesChannelResolver($viewProvider), $configService, - $signingKeyService, - new UcpConfigValidator(), + new ChannelFindingsResolver(new UcpConfigValidator(), $signingKeyService), ); } @@ -138,6 +138,7 @@ private function salesChannelRepository(): EntityRepository $entity->setUniqueIdentifier(self::CHANNEL_ID); $entity->setName('Storefront'); $entity->setTypeId('0191cccccccc7000cccccccccccccccc'); + $entity->setActive(true); $searchResult = $this->createMock(EntitySearchResult::class); $searchResult->method('getEntities')->willReturn(new SalesChannelCollection([$entity])); diff --git a/tests/Unit/Ucp/Command/UcpSetupCommandTest.php b/tests/Unit/Ucp/Command/UcpSetupCommandTest.php index c9f0f3f9..5ec395d6 100644 --- a/tests/Unit/Ucp/Command/UcpSetupCommandTest.php +++ b/tests/Unit/Ucp/Command/UcpSetupCommandTest.php @@ -29,6 +29,7 @@ use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Swag\AgenticCommerce\Ucp\Config\Validation\UcpConfigValidator; +use Swag\AgenticCommerce\Ucp\Onboarding\ChannelFindingsResolver; use Swag\AgenticCommerce\Ucp\SalesChannel\SalesChannelViewProvider; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; @@ -189,8 +190,8 @@ private function command(UcpConfigRepositoryInterface $configRepository, UcpSign new SalesChannelResolver($viewProvider), $viewProvider, $configService, - new UcpSigningKeyService($keys, new UcpSigningKeyServiceTestSigningKeyManager()), - new UcpConfigValidator(), + $signingKeyService = new UcpSigningKeyService($keys, new UcpSigningKeyServiceTestSigningKeyManager()), + new ChannelFindingsResolver(new UcpConfigValidator(), $signingKeyService), ); } @@ -210,6 +211,7 @@ private function salesChannelRepository(): EntityRepository $entity->setUniqueIdentifier(self::CHANNEL_ID); $entity->setName('Storefront'); $entity->setTypeId('0191cccccccc7000cccccccccccccccc'); + $entity->setActive(true); $entity->setDomains(new SalesChannelDomainCollection([$domain])); $searchResult = $this->createMock(EntitySearchResult::class); diff --git a/tests/Unit/Ucp/Command/UcpSigningKeyCommandTenantTest.php b/tests/Unit/Ucp/Command/UcpSigningKeyCommandTenantTest.php index 58421258..569d86dc 100644 --- a/tests/Unit/Ucp/Command/UcpSigningKeyCommandTenantTest.php +++ b/tests/Unit/Ucp/Command/UcpSigningKeyCommandTenantTest.php @@ -150,6 +150,7 @@ private function salesChannel(string $id, string $name): SalesChannelEntity $entity->setUniqueIdentifier($id); $entity->setName($name); $entity->setTypeId('0191cccccccc7000cccccccccccccccc'); + $entity->setActive(true); return $entity; } diff --git a/tests/Unit/Ucp/Onboarding/BulkUcpActivatorTest.php b/tests/Unit/Ucp/Onboarding/BulkUcpActivatorTest.php new file mode 100644 index 00000000..a848ed03 --- /dev/null +++ b/tests/Unit/Ucp/Onboarding/BulkUcpActivatorTest.php @@ -0,0 +1,225 @@ +configRepository = new InMemoryUcpConfigRepository(); + } + + public function testItActivatesAnUnexposedChannelAndReportsItsReadiness(): void + { + $outcomes = $this->activator()->activate([self::STOREFRONT_ID], self::CAPABILITIES, self::TRANSPORTS, Context::createDefaultContext()); + + self::assertCount(1, $outcomes); + self::assertTrue($outcomes[0]->isEnabled()); + self::assertSame('Storefront', $outcomes[0]->salesChannelName); + + $stored = $this->configRepository->find(self::STOREFRONT_ID); + self::assertInstanceOf(UcpConfig::class, $stored); + self::assertTrue($stored->active); + self::assertSame(self::CAPABILITIES, $stored->enabledCapabilities); + self::assertSame(self::TRANSPORTS, $stored->enabledTransports); + } + + public function testItLeavesTheProfileDomainAloneSoEachChannelServesItsOwn(): void + { + $this->configRepository->save(self::STOREFRONT_ID, UcpConfig::fromArray([ + 'active' => false, + 'profileDomain' => 'https://pinned.example.com', + ])); + + $this->activator()->activate([self::STOREFRONT_ID], self::CAPABILITIES, self::TRANSPORTS, Context::createDefaultContext()); + + $stored = $this->configRepository->find(self::STOREFRONT_ID); + self::assertInstanceOf(UcpConfig::class, $stored); + self::assertSame('https://pinned.example.com', $stored->profileDomain); + } + + public function testItSkipsAChannelThatAlreadyExposesTheRequestedSet(): void + { + $this->configRepository->save(self::STOREFRONT_ID, UcpConfig::fromArray([ + 'active' => true, + 'enabledCapabilities' => array_reverse(self::CAPABILITIES), + 'enabledTransports' => self::TRANSPORTS, + ])); + + $outcomes = $this->activator()->activate([self::STOREFRONT_ID], self::CAPABILITIES, self::TRANSPORTS, Context::createDefaultContext()); + + self::assertTrue($outcomes[0]->isSkipped()); + self::assertSame(ChannelActivationOutcome::REASON_ALREADY_ACTIVE, $outcomes[0]->reason); + } + + public function testItRewritesAnActiveChannelWhenTheRequestedSetDiffers(): void + { + $this->configRepository->save(self::STOREFRONT_ID, UcpConfig::fromArray([ + 'active' => true, + 'enabledCapabilities' => ['catalog'], + 'enabledTransports' => self::TRANSPORTS, + ])); + + $outcomes = $this->activator()->activate([self::STOREFRONT_ID], self::CAPABILITIES, self::TRANSPORTS, Context::createDefaultContext()); + + self::assertTrue($outcomes[0]->isEnabled()); + $stored = $this->configRepository->find(self::STOREFRONT_ID); + self::assertInstanceOf(UcpConfig::class, $stored); + self::assertSame(self::CAPABILITIES, $stored->enabledCapabilities); + } + + public function testARefusedChannelBecomesItsOwnFailureAndTheBatchContinues(): void + { + $outcomes = $this->activator()->activate( + [self::FEED_ID, self::STOREFRONT_ID, self::HEADLESS_ID], + self::CAPABILITIES, + self::TRANSPORTS, + Context::createDefaultContext(), + ); + + self::assertTrue($outcomes[0]->isFailed()); + self::assertSame(UcpConfigException::SALES_CHANNEL_TYPE_NOT_SUPPORTED, $outcomes[0]->code); + self::assertNull($this->configRepository->find(self::FEED_ID)); + + self::assertTrue($outcomes[1]->isEnabled()); + self::assertTrue($outcomes[2]->isEnabled()); + } + + public function testItReportsThatAChannelWithoutADomainCannotBeReached(): void + { + $outcomes = $this->activator()->activate([self::HEADLESS_ID], self::CAPABILITIES, self::TRANSPORTS, Context::createDefaultContext()); + + self::assertTrue($outcomes[0]->isEnabled()); + $codes = array_map(static fn (Finding $finding): string => $finding->code, $outcomes[0]->findings); + self::assertContains('no_storefront_domain', $codes); + } + + private function activator(): BulkUcpActivator + { + $typeResolver = new StaticSalesChannelTypeResolver(SalesChannelTypeClassification::Other, [ + self::STOREFRONT_ID => SalesChannelTypeClassification::Storefront, + self::HEADLESS_ID => SalesChannelTypeClassification::Headless, + self::FEED_ID => SalesChannelTypeClassification::AgenticCommerce, + ]); + + $legacyStore = $this->createMock(LegacyConfigStoreInterface::class); + $legacyStore->method('get')->willReturn(null); + + $configService = new UcpConfigService( + $this->configRepository, + $legacyStore, + null, + null, + false, + $typeResolver, + null, + ); + + return new BulkUcpActivator( + new SalesChannelViewProvider($this->salesChannelRepository(), $typeResolver), + $configService, + new ChannelFindingsResolver( + new UcpConfigValidator(), + new UcpSigningKeyService( + $this->createMock(ManagedSigningKeyRepositoryInterface::class), + $this->createMock(SigningKeyManagerInterface::class), + ), + ), + ); + } + + /** + * @return EntityRepository + */ + private function salesChannelRepository(): EntityRepository + { + $channels = new SalesChannelCollection([ + $this->salesChannel(self::STOREFRONT_ID, 'Storefront', Defaults::SALES_CHANNEL_TYPE_STOREFRONT, 'https://shop.example.com'), + $this->salesChannel(self::HEADLESS_ID, 'Headless', Defaults::SALES_CHANNEL_TYPE_API, null), + $this->salesChannel(self::FEED_ID, 'Feed', Defaults::SALES_CHANNEL_TYPE_PRODUCT_COMPARISON, null), + ]); + + $repository = $this->createMock(EntityRepository::class); + $repository->method('search')->willReturnCallback( + static fn (Criteria $criteria, Context $context): EntitySearchResult => new EntitySearchResult( + 'sales_channel', + $channels->count(), + $channels, + null, + $criteria, + $context, + ), + ); + + return $repository; + } + + private function salesChannel(string $id, string $name, string $typeId, ?string $domainUrl): SalesChannelEntity + { + $salesChannel = new SalesChannelEntity(); + $salesChannel->setUniqueIdentifier($id); + $salesChannel->setId($id); + $salesChannel->setName($name); + $salesChannel->setTypeId($typeId); + $salesChannel->setActive(true); + + $domains = new SalesChannelDomainCollection(); + if (null !== $domainUrl) { + $domain = new SalesChannelDomainEntity(); + $domain->setUniqueIdentifier($id.'-domain'); + $domain->setId($id.'-domain'); + $domain->setUrl($domainUrl); + $domain->setLanguageId(Defaults::LANGUAGE_SYSTEM); + $domains->add($domain); + } + $salesChannel->setDomains($domains); + + return $salesChannel; + } +} diff --git a/tests/Unit/Ucp/Onboarding/Fixtures/InMemoryUcpConfigRepository.php b/tests/Unit/Ucp/Onboarding/Fixtures/InMemoryUcpConfigRepository.php new file mode 100644 index 00000000..bc82758a --- /dev/null +++ b/tests/Unit/Ucp/Onboarding/Fixtures/InMemoryUcpConfigRepository.php @@ -0,0 +1,46 @@ + $configs + */ + public function __construct(private array $configs = []) + { + } + + public function find(string $salesChannelId): ?UcpConfig + { + return $this->configs[$salesChannelId] ?? null; + } + + public function findMany(array $salesChannelIds): array + { + $found = []; + foreach ($salesChannelIds as $salesChannelId) { + if (isset($this->configs[$salesChannelId])) { + $found[$salesChannelId] = $this->configs[$salesChannelId]; + } + } + + return $found; + } + + public function save(string $salesChannelId, UcpConfig $config): void + { + $this->configs[$salesChannelId] = $config; + } +} diff --git a/tests/Unit/Ucp/Onboarding/Fixtures/StaticOnboardingMetrics.php b/tests/Unit/Ucp/Onboarding/Fixtures/StaticOnboardingMetrics.php new file mode 100644 index 00000000..09b4f384 --- /dev/null +++ b/tests/Unit/Ucp/Onboarding/Fixtures/StaticOnboardingMetrics.php @@ -0,0 +1,30 @@ + $productCounts + */ + public function __construct( + private readonly array $productCounts = [], + private readonly int $agenticSalesChannelCount = 0, + ) { + } + + public function activeProductCounts(array $salesChannelIds): array + { + return array_intersect_key($this->productCounts, array_flip($salesChannelIds)); + } + + public function agenticSalesChannelCount(): int + { + return $this->agenticSalesChannelCount; + } +} diff --git a/tests/Unit/Ucp/Onboarding/ShopReadinessProviderTest.php b/tests/Unit/Ucp/Onboarding/ShopReadinessProviderTest.php new file mode 100644 index 00000000..74199dfc --- /dev/null +++ b/tests/Unit/Ucp/Onboarding/ShopReadinessProviderTest.php @@ -0,0 +1,275 @@ +configRepository = new InMemoryUcpConfigRepository(); + } + + public function testAShopWithNothingExposedNeedsSetup(): void + { + $readiness = $this->provider()->readiness(Context::createDefaultContext()); + + self::assertSame(ShopReadiness::STATUS_SETUP_NEEDED, $readiness->status); + self::assertSame(0, $readiness->preparedCount); + self::assertSame(1, $readiness->completedSteps()); + self::assertFalse($readiness->channels[0]->prepared); + self::assertSame([], $readiness->channels[0]->findings); + } + + public function testAPreparedShopWithoutAnAgenticChannelStillNeedsAnAction(): void + { + $this->expose(self::STOREFRONT_ID); + + $readiness = $this->provider(agenticSalesChannelCount: 0)->readiness(Context::createDefaultContext()); + + self::assertSame(ShopReadiness::STATUS_ACTION_NEEDED, $readiness->status); + self::assertSame(1, $readiness->preparedCount); + self::assertSame(2, $readiness->completedSteps()); + } + + public function testEveryStepDoneAndNoErrorReportsReady(): void + { + $this->expose(self::STOREFRONT_ID); + + $readiness = $this->provider(agenticSalesChannelCount: 1, withSigningKey: true)->readiness(Context::createDefaultContext()); + + self::assertSame(ShopReadiness::STATUS_READY, $readiness->status); + self::assertSame(3, $readiness->completedSteps()); + } + + public function testAnExposedChannelThatNoAgentCanReachIsNotReportedReady(): void + { + // Every step is done, but the headless channel has no domain and no signing + // key, so the validator raises errors and the step count alone would lie. + $this->expose(self::HEADLESS_ID); + + $readiness = $this->provider(agenticSalesChannelCount: 1)->readiness(Context::createDefaultContext()); + + self::assertSame(3, $readiness->completedSteps()); + self::assertSame(ShopReadiness::STATUS_ACTION_NEEDED, $readiness->status); + + $headless = $this->channel($readiness, self::HEADLESS_ID); + self::assertNotSame([], $headless->findings); + self::assertFalse($headless->hasDomain()); + } + + public function testItReportsTheActiveProductCountPerChannelAndZeroWhenUnknown(): void + { + $readiness = $this->provider(productCounts: [self::STOREFRONT_ID => 12480])->readiness(Context::createDefaultContext()); + + self::assertSame(12480, $this->channel($readiness, self::STOREFRONT_ID)->activeProductCount); + self::assertSame(0, $this->channel($readiness, self::HEADLESS_ID)->activeProductCount); + } + + public function testTheSerialisedPayloadCarriesTheThreeSteps(): void + { + $this->expose(self::STOREFRONT_ID); + + $payload = $this->provider()->readiness(Context::createDefaultContext())->jsonSerialize(); + + self::assertTrue($payload['steps']['installed']['done']); + self::assertTrue($payload['steps']['prepared']['done']); + self::assertSame(1, $payload['steps']['prepared']['count']); + self::assertFalse($payload['steps']['connected']['done']); + self::assertCount(2, $payload['channels']); + } + + private function expose(string $salesChannelId): void + { + $this->configRepository->save($salesChannelId, UcpConfig::fromArray([ + 'active' => true, + 'enabledCapabilities' => ['catalog', 'cart'], + 'enabledTransports' => ['rest'], + 'platformAllowlist' => ['agent.example.com'], + ])); + } + + private function channel(ShopReadiness $readiness, string $salesChannelId): ChannelReadiness + { + foreach ($readiness->channels as $channel) { + if ($channel->id === $salesChannelId) { + return $channel; + } + } + + self::fail(\sprintf('Expected sales channel %s in the readiness payload.', $salesChannelId)); + } + + /** + * @param array $productCounts + */ + private function provider( + array $productCounts = [], + int $agenticSalesChannelCount = 0, + bool $withSigningKey = false, + ): ShopReadinessProvider { + $typeResolver = new StaticSalesChannelTypeResolver(SalesChannelTypeClassification::Other, [ + self::STOREFRONT_ID => SalesChannelTypeClassification::Storefront, + self::HEADLESS_ID => SalesChannelTypeClassification::Headless, + ]); + + $legacyStore = $this->createMock(LegacyConfigStoreInterface::class); + $legacyStore->method('get')->willReturn(null); + + $configService = new UcpConfigService( + $this->configRepository, + $legacyStore, + null, + null, + false, + $typeResolver, + null, + ); + + return new ShopReadinessProvider( + new SalesChannelViewProvider($this->salesChannelRepository(), $typeResolver), + $configService, + new ChannelFindingsResolver(new UcpConfigValidator(), $this->signingKeyService($withSigningKey)), + $this->metrics($productCounts, $agenticSalesChannelCount), + new FeedChannelLookup($this->emptyProductExportRepository()), + ); + } + + /** + * @return EntityRepository + */ + private function emptyProductExportRepository(): EntityRepository + { + $repository = $this->createMock(EntityRepository::class); + $repository->method('search')->willReturnCallback( + static fn (Criteria $criteria, Context $context): EntitySearchResult => new EntitySearchResult( + 'product_export', + 0, + new ProductExportCollection(), + null, + $criteria, + $context, + ), + ); + + return $repository; + } + + /** + * @param array $productCounts + */ + private function metrics(array $productCounts, int $agenticSalesChannelCount): OnboardingMetricsInterface + { + return new StaticOnboardingMetrics($productCounts, $agenticSalesChannelCount); + } + + private function signingKeyService(bool $withSigningKey): UcpSigningKeyService + { + $keys = $withSigningKey + ? [new ManagedSigningKey('key-1', 'public-pem', 'private-pem')] + : []; + + $repository = $this->createMock(ManagedSigningKeyRepositoryInterface::class); + $repository->method('allManaged')->willReturn($keys); + + $manager = $this->createMock(SigningKeyManagerInterface::class); + $manager->method('toPublicKey')->willReturn(new PublicSigningKey('key-1')); + + return new UcpSigningKeyService($repository, $manager); + } + + /** + * @return EntityRepository + */ + private function salesChannelRepository(): EntityRepository + { + $channels = new SalesChannelCollection([ + $this->salesChannel(self::STOREFRONT_ID, 'Storefront', Defaults::SALES_CHANNEL_TYPE_STOREFRONT, 'https://shop.example.com'), + $this->salesChannel(self::HEADLESS_ID, 'Headless', Defaults::SALES_CHANNEL_TYPE_API, null), + ]); + + $repository = $this->createMock(EntityRepository::class); + $repository->method('search')->willReturnCallback( + static fn (Criteria $criteria, Context $context): EntitySearchResult => new EntitySearchResult( + 'sales_channel', + $channels->count(), + $channels, + null, + $criteria, + $context, + ), + ); + + return $repository; + } + + private function salesChannel(string $id, string $name, string $typeId, ?string $domainUrl): SalesChannelEntity + { + $salesChannel = new SalesChannelEntity(); + $salesChannel->setUniqueIdentifier($id); + $salesChannel->setId($id); + $salesChannel->setName($name); + $salesChannel->setTypeId($typeId); + $salesChannel->setActive(true); + + $domains = new SalesChannelDomainCollection(); + if (null !== $domainUrl) { + $domain = new SalesChannelDomainEntity(); + $domain->setUniqueIdentifier($id.'-domain'); + $domain->setId($id.'-domain'); + $domain->setUrl($domainUrl); + $domain->setLanguageId(Defaults::LANGUAGE_SYSTEM); + $domains->add($domain); + } + $salesChannel->setDomains($domains); + + return $salesChannel; + } +} diff --git a/tests/Unit/Ucp/SalesChannel/SalesChannelViewProviderTest.php b/tests/Unit/Ucp/SalesChannel/SalesChannelViewProviderTest.php index 9669e190..0c5faf70 100644 --- a/tests/Unit/Ucp/SalesChannel/SalesChannelViewProviderTest.php +++ b/tests/Unit/Ucp/SalesChannel/SalesChannelViewProviderTest.php @@ -77,6 +77,7 @@ static function (Criteria $criteria): void { 'name' => 'Storefront-channel', 'typeId' => Defaults::SALES_CHANNEL_TYPE_STOREFRONT, 'transactional' => true, + 'active' => true, 'domains' => [[ 'id' => 'domain-id', 'url' => 'https://shop.example', @@ -177,6 +178,7 @@ private function salesChannelRepository( $salesChannel->setUniqueIdentifier($id); $salesChannel->setName(ucfirst($id)); $salesChannel->setTypeId($typeId); + $salesChannel->setActive(true); if ($withDomain) { $domain = new SalesChannelDomainEntity();