Create product page wrappers for Content hub products
When your content editors create or localize a product in your product catalog, they typically expect it to appear in your main website channel. However, if you’ve chosen to store products in Content hub, as in the Training guides example, this doesn’t happen out-of-the-box.
Let’s explore how to build a handler that automatically synchronizes your reusable product items with pages on your website channel.
We’ll implement event-driven wrapper creation, language variant handling, publishing/unpublishing behavior, parent-page orchestration within the store section, and the content retrieval methods needed to run all of this without website channel context.
The following shows how a change in the Content hub instantly affects the website channel.
Content hub:

Website channel:

Before you start
This guide requires the following:
- Familiarity with C#, .NET Core, Dependency injection, and the MVC pattern.
- A running instance of Xperience by Kentico, preferably 31.3.3 or higher.
Some features covered in the training guides may not work in older versions.
Code samples
You can find a project with completed, working versions of code samples from this guide and others in the finished branch of the Training guides repository.
The main branch of the repository provides a starting point to code along with the guides.
The code samples in this guide are for .NET 8 only.
They come from a project that uses implicit using directives. You may need to add additional using directives to your code if your project does not use this feature.
Allow your page types
Since the handler module will create pages in a website channel, we need to ensure the page content types we want to create are allowed within the limitations of your channel.
Before you create the handler, remember to check your type’s allowed channels, scopes, and content types to ensure that Xperience will let the handler create pages in the correct location.
For this example, we need to allow the Store section type beneath the Store page, and the Product page type beneath Store section pages in the Training guides pages channel. This is already in place if you are following along using the main branch of the Training guides repo.
Create and register an event handler module
In the ~/Features/Commerce/EventHandlers folder of the TrainingGuides.Web project, create a ProductPageWrapperHandlerModule class that inherits from Module, and register it with the RegisterModule assembly attribute so Kentico initializes it at startup.
// Partial — see the complete ProductPageWrapperHandlerModule.cs for all using directives and full implementation
using CMS;
using CMS.Base;
using CMS.ContentEngine;
using CMS.Core;
using CMS.DataEngine;
using TrainingGuides.Web.Commerce.EventHandlers;
[assembly: RegisterModule(typeof(ProductPageWrapperHandlerModule))]
namespace TrainingGuides.Web.Commerce.EventHandlers;
public class ProductPageWrapperHandlerModule : Module
{
public ProductPageWrapperHandlerModule() : base(nameof(ProductPageWrapperHandlerModule)) { }
}
Then, add a corresponding service with constants up front for values that you will reuse throughout the implementation: module name, admin username, store path, channel name, and channel GUID.
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
using CMS.ContentEngine;
using CMS.Core;
using CMS.DataEngine;
using CMS.Membership;
using Kentico.PageBuilder.Web.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using TrainingGuides.ProductStock;
using TrainingGuides.Web.Features.Commerce.Products.Widgets.ProductListing;
using TrainingGuides.Web.Features.Commerce.Products.Widgets.ProductWidget;
using TrainingGuides.Web.Features.Shared.Logging;
using TrainingGuides.Web.Features.Shared.OptionProviders.ColorScheme;
using TrainingGuides.Web.Features.Shared.OptionProviders.ColumnLayout;
using TrainingGuides.Web.Features.Shared.OptionProviders.CornerStyle;
using TrainingGuides.Web.Features.Shared.Sections.General;
using TrainingGuides.Web.Features.Shared.Services;
namespace TrainingGuides.Web.Commerce.EventHandlers;
// Shared service containing the page wrapper orchestration logic, injected into each handler
internal class ProductPageWrapperService
{
// Username of an admin user
private const string ADMIN = "administrator";
// Path to the store section of the site
private const string STORE_PATH = "/Store";
// Name of the website channel
private const string CHANNEL_NAME = "TrainingGuidesPages";
// GUID of the website channel (Note this is the value of WebsiteChannelGUID, not ChannelGUID)
private const string WEB_CHANNEL_GUID = "FDBA40FE-1ECE-4821-9D57-EAA1D89E13B1";
}
Resolve dependencies and subscribe to content events
Use dependency injection to resolve the following in the page wrapper service:
IWebPageManagerFactoryIContentItemRetrieverServiceIInfoProvider<UserInfo>IInfoProvider<WebsiteChannelInfo>ILogger<ProductPageWrapperService>
Then, use the info providers to resolve the admin user and website channel, and create an IWebPageManager from their IDs.
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
internal class ProductPageWrapperService
{
// ... existing code ...
private readonly IWebPageManager webPageManager;
private readonly IContentItemRetrieverService contentItemRetrieverService;
private readonly ILogger<ProductPageWrapperService> logger;
public ProductPageWrapperService(
IWebPageManagerFactory webPageManagerFactory,
IContentItemRetrieverService contentItemRetrieverService,
IInfoProvider<UserInfo> userInfoProvider,
IInfoProvider<WebsiteChannelInfo> websiteChannelInfoProvider,
ILogger<ProductPageWrapperService> logger)
{
this.contentItemRetrieverService = contentItemRetrieverService;
this.logger = logger;
var user = userInfoProvider.Get()
.WhereEquals(nameof(UserInfo.UserName), ADMIN)
.FirstOrDefault();
var webChannel = websiteChannelInfoProvider.Get()
.WhereEquals(nameof(WebsiteChannelInfo.WebsiteChannelGUID), new Guid(WEB_CHANNEL_GUID))
.FirstOrDefault();
webPageManager = webPageManagerFactory.Create(webChannel?.WebsiteChannelID ?? 0, user?.UserID ?? 0);
}
}
In the module, register the service and a new class for each of the events we will need to handle with the dependency injection container.
Based on these events, we can implement the behavior to automatically manage page wrappers.
// Partial — see the complete ProductPageWrapperHandlerModule.cs for all using directives and full implementation
// ... existing code ...
public class ProductPageWrapperHandlerModule : Module
{
public ProductPageWrapperHandlerModule() : base(nameof(ProductPageWrapperHandlerModule)) { }
protected override void OnPreInit(ModulePreInitParameters parameters)
{
base.OnPreInit(parameters);
parameters.Services.AddSingleton<ProductPageWrapperService>();
parameters.Services.AddEventHandler<AfterCreateContentItemEvent, ProductPageCreateHandler>();
parameters.Services.AddEventHandler<AfterCreateLanguageVariantEvent, ProductPageCreateLanguageVariantHandler>();
parameters.Services.AddEventHandler<AfterDeleteContentItemEvent, ProductPageDeleteHandler>();
parameters.Services.AddEventHandler<AfterPublishContentItemEvent, ProductPagePublishHandler>();
parameters.Services.AddEventHandler<AfterUnpublishContentItemEvent, ProductPageUnpublishHandler>();
}
}
// Handler triggered after a new product content item is created
internal class ProductPageCreateHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterCreateContentItemEvent>
{
public async Task HandleAsync(AfterCreateContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
}
}
// Handler triggered after a new language variant of a product content item is created
internal class ProductPageCreateLanguageVariantHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterCreateLanguageVariantEvent>
{
public async Task HandleAsync(AfterCreateLanguageVariantEvent asyncEvent, CancellationToken cancellationToken)
{
}
}
// Handler triggered after a product content item is deleted
internal class ProductPageDeleteHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterDeleteContentItemEvent>
{
public async Task HandleAsync(AfterDeleteContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
}
}
// Handler triggered after a product content item is published
internal class ProductPagePublishHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterPublishContentItemEvent>
{
public async Task HandleAsync(AfterPublishContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
}
}
// Handler triggered after a product content item is unpublished
internal class ProductPageUnpublishHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterUnpublishContentItemEvent>
{
public async Task HandleAsync(AfterUnpublishContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
}
}
Limit the handler to product content types
By default, these events fire for every reusable content item, regardless of whether or not it is a product. Let’s add some safeguards to make sure our functionality only applies to products.
Implement a method called GetApplicableTypeNames to discover product content types dynamically. Use reflection to search the TrainingGuides.Entities assembly for concrete classes that implement IProductSchema but not IProductVariantSchema, and collect each class’s CONTENT_TYPE_NAME constant.
Then implement a boolean IsApplicableType method that uses GetApplicableTypeNames to check if a provided content type meets the criteria.
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
/// <summary>
/// Checks if the specified content type is one that should have a product page wrapper.
/// </summary>
/// <param name="contentTypeName">The name of the content type to check.</param>
/// <returns>True if the content type should have a product page wrapper; otherwise, false.</returns>
internal bool IsApplicableType(string contentTypeName) =>
GetApplicableTypeNames().Contains(contentTypeName);
/// <summary>
/// Retrieves the content type names of all product types that should have a page wrapper.
/// This is determined by finding all classes that implement IProductSchema but not IProductVariantSchema, and retrieving the value of their CONTENT_TYPE_NAME constant.
/// </summary>
/// <returns>A list of content type names</returns>
/// <remarks>
/// Make sure the content type of the page wrapper does not meet the criteria defined in this method, otherwise it will cause an infinite loop of page creation.
/// </remarks>
private IEnumerable<string> GetApplicableTypeNames()
{
// We know this class is stored in the Entities project, so we can use it to access the assembly
var entitiesAssembly = typeof(ProductAvailableStockInfo).Assembly;
var types = entitiesAssembly
.GetTypes()
.Where(type =>
type.IsClass
&& !type.IsAbstract
&& typeof(IProductSchema).IsAssignableFrom(type)
&& !typeof(IProductVariantSchema).IsAssignableFrom(type));
return types.Select(type =>
{
var field = type.GetField("CONTENT_TYPE_NAME");
if (field is not null)
{
// The field is a constant, so we don't need an instance to retrieve its value - we can use null instead
return field.GetValue(null) as string;
}
// If there is no CONTENT_TYPE_NAME constant, we skip this type
return null;
})
// Filter out the classes with no CONTENT_TYPE_NAME constant
.Where(name => name is not null)!;
}
// ... existing code ...
Add content retrieval methods that do not rely on channel context
While handling page wrappers for products, we’ll need to query existing pages in a few cases, for example, to find a parent Store section page under which to create a product page.
However, event handlers run outside normal web channel context, so they cannot rely on available channel and language values used by ContentRetriever.
To solve that, add two explicit methods to IContentItemRetrieverService and ContentItemRetrieverService in the ~/Features/Shared/Services/ folder:
RetrieveWebPageByPathWithoutContext<T>to retrieve a single page by pathRetrieveWebPageChildrenByPathWithoutContextto retrieve child pages by parent path
Design both methods to accept channelName, languageName, forPreview, and includeSecuredItems where relevant.
These methods become the retrieval foundation for locating store sections and existing wrapper pages during event processing.
using CMS.ContentEngine;
using Kentico.Content.Web.Mvc;
// ... existing code ...
/// <summary>
/// Retrieves a web page content item by path without channel context using content item query API
/// </summary>
/// <typeparam name="T">Type of the web page to retrieve</typeparam>
/// <param name="pathToMatch">Path where the web page lives</param>
/// <param name="languageName">Name of the language</param>
/// <param name="channelName">Name of the channel</param>
/// <param name="forPreview">Indicates whether the retrieval is for preview (latest draft of items)</param>
/// <param name="includeSecuredItems">Indicates whether to include secured items</param>
/// <returns>The web page content item of the specified type</returns>
Task<T?> RetrieveWebPageByPathWithoutContext<T>(
string pathToMatch,
string languageName,
string channelName,
bool forPreview,
bool includeSecuredItems)
where T : IWebPageFieldsSource, new();
/// <summary>
/// Retrieves child pages of a given web page without channel context using content item query API
/// </summary>
/// <param name="contentTypeNames">Content types of the child pages to retrieve</param>
/// <param name="parentPagePath">Path of the parent page</param>
/// <param name="customContentTypeQueryParameters">Type-level filtering through <see cref="ContentTypesQueryParameters"/></param>
/// <param name="customContentQueryParameters">Query level filtering through <see cref="ContentQueryParameters"/></param>
/// <param name="forPreview">Indicates whether the retrieval is for preview (latest draft of items)</param>
/// <param name="includeSecuredItems">Indicates whether to include secured items</param>
/// <param name="channelName">Name of the channel</param>
/// <param name="languageName">Name of the language. If null, all languages are retrieved.</param>
/// <param name="depth">The maximum level of recursively linked content items to include in the results. Default is 1</param>
/// <returns>A collection of web pages that exist under the specified path in the content tree</returns>
Task<IEnumerable<IWebPageFieldsSource>> RetrieveWebPageChildrenByPathWithoutContext(
IEnumerable<string> contentTypeNames,
string parentPagePath,
Func<ContentTypesQueryParameters, ContentTypesQueryParameters> customContentTypeQueryParameters,
Func<ContentQueryParameters, ContentQueryParameters> customContentQueryParameters,
bool forPreview,
bool includeSecuredItems,
string channelName,
string? languageName = null,
int depth = 1);
// ... existing code ...
Implement RetrieveWebPageByPathWithoutContext
This method retrieves one page by its exact path for a specific channel/language without using IWebsiteChannelContext. This will help us locate the Store section of the site when we create parent pages to organize our product wrappers.
Define a ContentItemQueryBuilder, scope it to the target website, using PathMatch.Single to find the page. Return the first result after executing the query.
using CMS.ContentEngine;
using CMS.Websites.Routing;
using Kentico.Content.Web.Mvc;
using Kentico.Content.Web.Mvc.Routing;
// ... existing code ...
/// <inheritdoc />
public async Task<T?> RetrieveWebPageByPathWithoutContext<T>(
string pathToMatch,
string languageName,
string channelName,
bool forPreview,
bool includeSecuredItems)
where T : IWebPageFieldsSource, new()
{
var builder = new ContentItemQueryBuilder();
builder.ForContentTypes(query =>
{
query.ForWebsite(channelName, PathMatch.Single(pathToMatch));
})
.InLanguage(languageName);
var queryExecutorOptions = new ContentQueryExecutionOptions
{
ForPreview = forPreview,
IncludeSecuredItems = includeSecuredItems
};
var pages = await contentQueryExecutor.GetMappedResult<T>(builder, queryExecutorOptions);
return pages.FirstOrDefault();
}
// ... existing code ...
Implement RetrieveWebPageChildrenByPathWithoutContext
This method retrieves child pages under a parent path while letting callers inject custom filtering logic. This will come in handy for checking if a product already has a page wrapper, and for identifying potential parent pages in the Store section.
Scope the query using PathMatch.Children and limit results by content type using OfContentType. Use WithContentTypeFields so we can optionally cast the results and access type-specific fields later. Allow callers to inject custom type-level and query-level filters through customContentTypeQueryParameters and customContentQueryParameters, and only apply .InLanguage when a language is explicitly provided.
using CMS.ContentEngine;
using CMS.Websites.Routing;
using Kentico.Content.Web.Mvc;
using Kentico.Content.Web.Mvc.Routing;
// ... existing code ...
/// <inheritdoc />
public async Task<IEnumerable<IWebPageFieldsSource>> RetrieveWebPageChildrenByPathWithoutContext(
IEnumerable<string> contentTypeNames,
string parentPagePath,
Func<ContentTypesQueryParameters, ContentTypesQueryParameters> customContentTypesQueryParameters,
Func<ContentQueryParameters, ContentQueryParameters> customContentQueryParameters,
bool forPreview,
bool includeSecuredItems,
string channelName,
string? languageName = null,
int depth = 1)
{
Action<ContentTypesQueryParameters> contentTypesQueryParameters =
config => customContentTypesQueryParameters(config
.ForWebsite(channelName, PathMatch.Children(parentPagePath))
.OfContentType(contentTypeNames.ToArray())
.WithLinkedItems(depth)
.WithContentTypeFields());
var builder = new ContentItemQueryBuilder()
.ForContentTypes(contentTypesQueryParameters)
.Parameters(param => customContentQueryParameters(param));
if (!string.IsNullOrEmpty(languageName))
{
builder.InLanguage(languageName);
}
var queryExecutorOptions = new ContentQueryExecutionOptions
{
ForPreview = forPreview,
IncludeSecuredItems = includeSecuredItems
};
var pages = await contentQueryExecutor.GetMappedWebPageResult<IWebPageFieldsSource>(builder, queryExecutorOptions);
return pages;
}
// ... existing code ...
Set up Page Builder for the created pages
Add configuration helpers
When we create a page, such as a Product page wrapper or Store section parent page, it won’t have a page template or any Page Builder content by default. Since the Training guides implementation uses widgets to display product listing and details, let’s change that.
Create methods that generate Page Builder JSON for product wrapper pages (GetProductWidgetsConfiguration) and parent listing pages (GetParentWidgetsConfiguration), along with page template configuration JSON(GetPageTemplateConfiguration).
With these helpers in place, we can include a meaningful Page Builder setup for each page we create. Editors will not need to manually configure layout each time unless they want to change the default appearance or add more widgets.
Page templates and sections
This code sample uses templates and sections from the Page Builder guides. If you have not completed these guides, use the following instead:
TrainingGuides.EmptyPagePageTemplatefor theidentifierin the template configuration JSONComponentIdentifiers.Sections.SINGLE_COLUMNfor theTypeIdentifierof eachSectionConfigurationnew SingleColumnSectionProperties()for thePropertiesof eachSectionConfiguration
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
/// <summary>
/// Creates Page Builder JSON for the product wrapper page, with a product widget configured to display the current product
/// </summary>
/// <returns>JSON string representing the product page's Page Builder content</returns>
private string GetProductWidgetsConfiguration()
{
var config = new EditableAreasConfiguration
{
EditableAreas =
{
new EditableAreaConfiguration
{
Identifier = "areaMain",
Sections =
{
new SectionConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Sections.GENERAL,
Properties = new GeneralSectionProperties
{
ColorScheme = ColorSchemeOption.TransparentDark.ToString(),
CornerStyle = CornerStyleOption.Round.ToString(),
ColumnLayout = ColumnLayoutOption.OneColumn.ToString(),
},
Zones =
{
new ZoneConfiguration
{
Identifier = Guid.NewGuid(),
Name = "zoneMain",
Widgets =
{
new WidgetConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Widgets.PRODUCT,
Variants =
{
new WidgetVariantConfiguration
{
Identifier = Guid.NewGuid(),
Properties = new ProductWidgetProperties
{
DisplayCurrentPage = true,
ShowVariantSelection = true,
ShowVariantDetails = true,
ShowCallToAction = false,
CallToActionText = "View Product",
}
}
}
}
}
}
}
}
}
}
}
};
return JsonConvert.SerializeObject(config, Formatting.None, GetSerializerSettings());
}
/// <summary>
/// Creates Page Builder JSON for the parent page of the product wrapper, with a product listing widget configured to display products that exist beneath it in the tree
/// </summary>
/// <returns>JSON string representing the parent page's Page Builder content</returns>
private string GetParentWidgetsConfiguration()
{
var config = new EditableAreasConfiguration
{
EditableAreas =
{
new EditableAreaConfiguration
{
Identifier = "areaMain",
Sections =
{
new SectionConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Sections.GENERAL,
Properties = new GeneralSectionProperties
{
ColorScheme = ColorSchemeOption.TransparentDark.ToString(),
CornerStyle = CornerStyleOption.Round.ToString(),
ColumnLayout = ColumnLayoutOption.OneColumn.ToString(),
},
Zones =
{
new ZoneConfiguration
{
Identifier = Guid.NewGuid(),
Name = "zoneMain",
Widgets =
{
new WidgetConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Widgets.PRODUCT_LISTING,
Variants =
{
new WidgetVariantConfiguration
{
Identifier = Guid.NewGuid(),
Properties = new ProductListingWidgetProperties
{
SecuredItemsDisplayMode = SecuredOption.PromptForLogin.ToString(),
}
}
}
}
}
}
}
}
}
}
}
};
return JsonConvert.SerializeObject(config, Formatting.None, GetSerializerSettings());
}
/// <summary>
/// Creates JSON serializer settings for dealing with Page Builder JSON
/// </summary>
/// <returns>JSON serializer settings</returns>
private JsonSerializerSettings GetSerializerSettings() =>
new()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Include,
};
/// <summary>
/// Creates page template configuration JSON for the product wrapper page and its parent page, using the General template with default properties
/// </summary>
/// <returns>JSON string representing the page template configuration</returns>
private string GetPageTemplateConfiguration() =>
"{\"identifier\":\"TrainingGuides.GeneralPageTemplate\",\"properties\":null,\"fieldIdentifiers\":null}";
// ... existing code ...
Enable Page Builder for the content types
Before moving on to page creation, make sure Page Builder is enabled for the page content types involved in this flow. If it is not enabled, wrapper pages may still be created, but they will not render the section/widget configuration provided by your helper methods.
// ... existing code ...
using Kentico.Web.Mvc;
using Kentico.PageBuilder.Web.Mvc;
using TrainingGuides;
using TrainingGuides.Web;
// ... existing code ...
builder.Services
.AddKentico(async features =>
{
features.UsePageBuilder(new PageBuilderOptions
{
DefaultSectionIdentifier = ComponentIdentifiers.Sections.SINGLE_COLUMN,
RegisterDefaultSection = false,
ContentTypeNames = new[] {
// ... existing code ...
ProductPage.CONTENT_TYPE_NAME,
StoreSection.CONTENT_TYPE_NAME
}
});
// ... existing code ...
Implement parent-page orchestration under /Store
Before we can implement the creation of Product page wrappers, we need to ensure they will have a valid parent location.
In the Training guides repository, Store section pages have a StoreSectionContentTypes field, referencing a specific reusable product content type. This field designates the Store section as the parent page for reusable items of the designated type to store their page wrappers.
Create parent pages and language variants
Store section pages should live under the ~/Store path in the content tree, so we will need a method to designate the Store page as their parent, alongside creation methods for pages and variants:
GetStorePageIdto retrieve the/Storepage IDCreateParentPageto create aStoreSectionparent page beneath the/StorepageCreateParentPageLanguageVariantto add missing language variants of theStoreSectionparent
Build ContentItemData that assigns the provided content type GUID to the StoreSectionContentTypes field. For new pages, set ParentWebPageItemID using the GetStorePageId method.
// ... existing code ...
/// <summary>
/// Creates a parent page for a given product content type and language
/// </summary>
/// <param name="displayName">The display name of the parent page</param>
/// <param name="languageName">The language in which to create the parent page</param>
/// <param name="contentTypeGuid">The GUID of the product content type</param>
/// <returns>The WebPageItemID of the created parent page</returns>
private async Task<int> CreateParentPage(string displayName, string languageName, Guid contentTypeGuid)
{
var parentData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(StoreSection.StoreSectionContentTypes), new List<Guid>(){ contentTypeGuid } },
});
var parentContentItemParameters = new ContentItemParameters(StoreSection.CONTENT_TYPE_NAME, parentData);
var createParentPageParameters = new CreateWebPageParameters(displayName,
languageName,
parentContentItemParameters)
{
ParentWebPageItemID = await GetStorePageId(languageName) ?? 0
};
createParentPageParameters.SetPageBuilderConfiguration(GetParentWidgetsConfiguration(), GetPageTemplateConfiguration());
return await webPageManager.Create(createParentPageParameters);
}
/// <summary>
/// Creates a language variant for an existing parent page.
/// </summary>
/// <param name="displayName">The display name of the parent page</param>
/// <param name="languageName">The language in which to create the language variant</param>
/// <param name="contentTypeGuid">The GUID of the product content type</param>
/// <param name="webPageItemID">The WebPageItemID of the parent page</param>
private async Task CreateParentPageLanguageVariant(string displayName, string languageName, Guid contentTypeGuid, int webPageItemID)
{
var parentData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(StoreSection.StoreSectionContentTypes), new List<Guid>(){ contentTypeGuid } },
});
var createLanguageVariantParameters =
new CMS.Websites.CreateLanguageVariantParameters(webPageItemID,
languageName,
displayName,
parentData);
createLanguageVariantParameters.SetPageBuilderConfiguration(GetParentWidgetsConfiguration(), GetPageTemplateConfiguration());
if (!await webPageManager.TryCreateLanguageVariant(createLanguageVariantParameters))
{
logger.LogError(EventIds.ProductParentPageLanguageVariantCreateFailed,
"Parent page language variant creation failed for product content type with GUID {ContentTypeGuid} in language {LanguageName}.",
contentTypeGuid,
languageName);
}
}
/// <summary>
/// Retrieves the page ID of the store's main page
/// </summary>
/// <param name="languageName">The language to query</param>
/// <returns>The WebPageItemID of the store's main page, or null if not found</returns>
private async Task<int?> GetStorePageId(string languageName)
{
var page = await contentItemRetrieverService.RetrieveWebPageByPathWithoutContext<EmptyPage>(
pathToMatch: STORE_PATH,
includeSecuredItems: true,
languageName: languageName,
channelName: CHANNEL_NAME,
forPreview: true);
return page?.SystemFields.WebPageItemID;
}
// ... existing code ...
Ensure a Store section exists for a type and language
Now let’s create a method called EnsureParentPageInLanguage to create parent pages (or language variants) if they do not already exist, and return their WebPageItemID. We can use this method to get a valid parent page ID when we create page wrappers for products.
Resolve content type metadata from contentTypeId, search existing parent sections under /Store through RetrieveWebPageChildrenByPathWithoutContext, and filter using the stored content type GUID in StoreSection.StoreSectionContentTypes. If no matching section exists, create one; if it exists but lacks the requested language variant, create that variant.
This gives product wrappers a stable, language-aware parent structure.
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
/// <summary>
/// Ensures a parent page exists for the specified product content type in the current language
/// </summary>
/// <param name="contentTypeId">The ID of the product content type</param>
/// <param name="languageName">The language in which to ensure the parent page exists</param>
/// <param name="languageId">The ID of the language</param>
/// <returns>The WebPageItemID of the parent page</returns>
private async Task<int> EnsureParentPageInLanguage(int contentTypeId, string languageName, int languageId)
{
var contentType = DataClassInfoProvider.GetDataClassInfo(contentTypeId);
var contentTypeGuid = contentType?.ClassGUID ?? Guid.Empty;
// Get any language version of the parent page
var existingParentPages = await contentItemRetrieverService
.RetrieveWebPageChildrenByPathWithoutContext(
contentTypeNames: [StoreSection.CONTENT_TYPE_NAME],
parentPagePath: STORE_PATH,
customContentTypeQueryParameters: query => query,
customContentQueryParameters: config => config
// Filter store section pages that are assigned to the product's content type
.Where(where => where.WhereContains(nameof(StoreSection.StoreSectionContentTypes), contentTypeGuid.ToString()))
.TopN(1),
forPreview: true,
includeSecuredItems: true,
depth: 0,
languageName: null,
channelName: CHANNEL_NAME);
if (!existingParentPages.Any())
{
// If there is no parent page for the product type, create one and return its ID
return await CreateParentPage(
displayName: contentType?.ClassDisplayName ?? "Store section",
languageName: languageName,
contentTypeGuid: contentTypeGuid
);
}
else
{
// If there are no language variants in the specified language, create one
if (!existingParentPages
.Where(page => page.SystemFields.ContentItemCommonDataContentLanguageID == languageId)
.Any())
{
await CreateParentPageLanguageVariant(
displayName: contentType?.ClassDisplayName ?? "Store section",
languageName: languageName,
contentTypeGuid: contentTypeGuid,
webPageItemID: existingParentPages.First().SystemFields.WebPageItemID);
}
// Return the existing parent page ID
return existingParentPages.First().SystemFields.WebPageItemID;
}
}
// ... existing code ...
Implement wrapper creation and language-variant creation
Now that we have a way to retrieve the parent pages, we can move on to write operations for product wrappers:
CreatePageWrapperForProductfor creating a new wrapper pageCreatePageWrapperLanguageVariantForProductfor creating a language variant of an existing wrapper page
In both cases, build ContentItemData that references the reusable product item’s GUID through ProductPage.ProductPageProducts and apply Page Builder configuration. For new pages, set ParentWebPageItemID using the EnsureParentPageInLanguage method. Add failure logging for unsuccessful page or variant creation.
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
/// <summary>
/// Creates a page wrapper for the specified product content item in the specified language, and returns the ID of the page. Ensures the parent page exists.
/// </summary>
/// <param name="displayName">The display name of the product content item, used for naming the page wrapper.</param>
/// <param name="languageName">The language for which to create the page wrapper.</param>
/// <param name="languageId">The ID of the language for which to create the page wrapper.</param>
/// <param name="contentTypeId">The content type ID of the product content item, used to find or create a parent page if necessary.</param>
/// <param name="contentItemGuid">The GUID of the product content item, used to link the page wrapper to the product.</param>
/// <returns>The ID of the created page.</returns>
internal async Task<int> CreatePageWrapperForProduct(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid)
{
var itemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(ProductPage.ProductPageProducts), new List<ContentItemReference>()
{ new() { Identifier = contentItemGuid } } },
});
var contentItemParameters = new ContentItemParameters(ProductPage.CONTENT_TYPE_NAME, itemData);
var createPageParameters = new CreateWebPageParameters(displayName, languageName, contentItemParameters)
{
ParentWebPageItemID = await EnsureParentPageInLanguage(contentTypeId, languageName, languageId)
};
createPageParameters.SetPageBuilderConfiguration(GetProductWidgetsConfiguration(), GetPageTemplateConfiguration());
int id = await webPageManager.Create(createPageParameters);
if (id <= 0)
{
logger.LogError(EventIds.ProductWrapperCreateFailed,
"Page wrapper creation failed for product content item with GUID {ContentItemGuid} in language {LanguageName}.",
contentItemGuid,
languageName);
}
return id;
}
// ... existing code ...
/// <summary>
/// Creates a language variant of a product's page wrapper in the specified language
/// </summary>
/// <param name="displayName">The display name of the content item to apply to the page wrapper</param>
/// <param name="languageName">The language in which to create the variant</param>
/// <param name="contentItemGuid">The GUID of the content item to be referenced by the page wrapper</param>
/// <param name="existingPageId">The WebPageItemID of the existing page</param>
private async Task CreatePageWrapperLanguageVariantForProduct(string displayName, string languageName, Guid contentItemGuid, int existingPageId)
{
var itemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(ProductPage.ProductPageProducts), new List<ContentItemReference>()
{ new() { Identifier = contentItemGuid } } },
});
var createLanguageVariantParameters =
new CMS.Websites.CreateLanguageVariantParameters(existingPageId,
languageName,
displayName,
itemData);
createLanguageVariantParameters.SetPageBuilderConfiguration(GetProductWidgetsConfiguration(), GetPageTemplateConfiguration());
if (!await webPageManager.TryCreateLanguageVariant(createLanguageVariantParameters))
{
logger.LogError(EventIds.ProductWrapperLanguageVariantCreateFailed,
"Page wrapper language variant creation failed for product content item with GUID {ContentItemGuid} in language {LanguageName}.",
contentItemGuid,
languageName);
}
}
// ... existing code ...
Look up page wrappers and ensure their existence
Add a new method called GetProductPagesForProduct to find existing wrapper pages referencing a product item’s GUID.
Try to find existing pages that exist somewhere in the /Store section. Filter by linked product GUID using WhereContains rather than .Linking, so that the method will still work during deletion events. If a language ID is provided, use WhereEquals to filter the language to avoid language fallbacks, guaranteeing that the query only returns a page if it exists in the provided language.
Then, add an EnsureProductPageWrapperForLanguage method to guarantee wrapper presence in a given target language.
It should return existing wrappers in the requested language if they exist. If not, it should either create a new page wrapper or language variant, then return the newly-created wrapper.
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
/// <summary>
/// Retrieves all product pages that reference the specified product.
/// </summary>
/// <param name="guid">GUID of the product item being referenced</param>
/// <param name="languageName">Language version to check</param>
/// <param name="languageId">Language ID to check (must match the language name)</param>
/// <returns>A collection of product pages that reference the specified product</returns>
private async Task<IEnumerable<IWebPageFieldsSource>> GetProductPagesForProduct(Guid guid, string? languageName, int? languageId)
{
Func<ContentQueryParameters, ContentQueryParameters> whereFilter = config => config
// Normally we would use the .Linking(...) method in the customContentTypeQueryParameters function.
// However, during the delete event, the linking data is already removed,
// so we must manually check for the GUID in ProductPageProducts.
.Where(where => where.WhereContains(nameof(ProductPage.ProductPageProducts), guid.ToString()));
var customContentQueryParameters = languageId is int intLanguageId
? (config => whereFilter(config
// Filter by language ID to ensure we only get pages in the specific language, without falling back to other languages
.Where(where => where.WhereEquals(
nameof(ProductPage.SystemFields.ContentItemCommonDataContentLanguageID), intLanguageId))))
: whereFilter;
return await contentItemRetrieverService
.RetrieveWebPageChildrenByPathWithoutContext(
contentTypeNames: [ProductPage.CONTENT_TYPE_NAME],
parentPagePath: STORE_PATH,
customContentTypeQueryParameters: query => query,
customContentQueryParameters: customContentQueryParameters,
forPreview: true,
includeSecuredItems: true,
depth: 0,
languageName: languageName,
channelName: CHANNEL_NAME);
}
// ... existing code ...
/// <summary>
/// Ensures that a product page exists for the specified product and language. If it doesn't exist, it will be created. If a page exists in another language, a language variant will be created for the current language.
/// </summary>
/// <param name="displayName">Display name for new variant if creation required</param>
/// <param name="languageName">Language name of variant to retrieve</param>
/// <param name="languageId">Language ID of variant to retrieve (must match language name)</param>
/// <param name="contentTypeId">ID of the reusable product item's content type</param>
/// <param name="contentItemGuid">GUID of the reusable content item</param>
/// <returns>An enumerable collection of wrapper pages linking the specified product</returns>
internal async Task<IEnumerable<IWebPageFieldsSource>> EnsureProductPageWrapperForLanguage(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid)
{
var langSpecificProductPages = await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
if (langSpecificProductPages.Any())
{
return langSpecificProductPages;
}
var allLanguageProductPages = await GetProductPagesForProduct(contentItemGuid, null, null);
if (allLanguageProductPages.Any())
{
var uniquePageIds = allLanguageProductPages.Select(page => page.SystemFields.WebPageItemID).Distinct();
foreach (int pageId in uniquePageIds)
{
// We should still make sure the parent exists in the current language, but we don't need its ID to create a new page
_ = await EnsureParentPageInLanguage(contentTypeId, languageName, languageId);
await CreatePageWrapperLanguageVariantForProduct(displayName, languageName, contentItemGuid, pageId);
}
return await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
}
_ = await CreatePageWrapperForProduct(displayName, languageName, languageId, contentTypeId, contentItemGuid);
return await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
}
// ... existing code ...
Implement publishing, unpublishing, and deletion
Now that we’ve handled creation of page wrappers and their language variants, we can move on to the logic we need to handle other events: delete, publish, and unpublish.
For deletion and unpublishing, loop through all wrapper pages that reference the reusable product, in case editors have manually created duplicates.
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
/// <summary>
/// Deletes all page wrappers for a given product content item in a specific language.
/// </summary>
/// <param name="guid">GUID of the reusable content item</param>
/// <param name="languageName">Language name of the variant to delete</param>
/// <param name="languageId">Language ID of the variant to delete (must match language name)</param>
internal async Task DeleteProductPageWrappers(Guid guid, string? languageName, int? languageId)
{
var productPages = await GetProductPagesForProduct(guid, languageName, languageId);
foreach (var productPage in productPages)
{
await webPageManager.Delete(
new DeleteWebPageParameters(productPage.SystemFields.WebPageItemID, languageName)
{
Permanently = true,
});
}
}
/// <summary>
/// Unpublishes all page wrappers for a given product content item in a specific language.
/// </summary>
/// <param name="contentItemGuid">GUID of the product content item</param>
/// <param name="languageName">Language name of the variant to unpublish</param>
/// <param name="languageId">Language ID of the variant to unpublish (must match language name)</param>
/// <param name="contentItemId">ID of the product content item</param>
internal async Task UnpublishProductPageWrappers(Guid contentItemGuid, string languageName, int languageId, int contentItemId)
{
var productPages = await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
foreach (var productPage in productPages)
{
if (!await webPageManager.TryUnpublish(productPage.SystemFields.WebPageItemID, languageName))
{
logger.LogError(EventIds.ProductWrapperUnpublishFailed,
"Unpublish failed for product page with ID {WebPageItemID} for product content item ID {ContentItemID} in language {LanguageName}.",
productPage.SystemFields.WebPageItemID,
contentItemId,
languageName);
}
}
}
// ... existing code ...
When publishing, we must additionally mind the version status of the page wrapper as the IWebPageManager will only publish items from the Draft or Initial draft statuses. Consider the status a page wrapper will have depending on the circumstances of the reusable product’s publishable state:
|
Reusable item status |
Page wrapper status |
|
When you first create a reusable product, it has the status |
Our handler will create a new page wrapper with the |
|
When you create a new version of an unpublished reusable product, it has the status |
Our page wrapper will have the |
|
When you create a new version of a published reusable product, it has the status |
Our page wrapper will have the |
// Partial — see the complete ProductPageWrapperService.cs for all using directives and full implementation
// ... existing code ...
/// <summary>
/// Publishes all page wrappers for a given product content item in a specific language.
/// </summary>
/// <param name="displayName">Display name of the product content item</param>
/// <param name="languageName">Language name of the variant to publish</param>
/// <param name="languageId">Language ID of the variant to publish (must match language name)</param>
/// <param name="contentTypeId">Content type ID of the product content item</param>
/// <param name="contentItemGuid">GUID of the product content item</param>
/// <param name="contentItemId">ID of the product content item</param>
internal async Task PublishProductPageWrappers(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid, int contentItemId)
{
var productPages = await EnsureProductPageWrapperForLanguage(displayName, languageName, languageId, contentTypeId, contentItemGuid);
foreach (var productPage in productPages)
{
bool published = productPage.SystemFields.ContentItemCommonDataVersionStatus is VersionStatus.Published;
bool unpublished = productPage.SystemFields.ContentItemCommonDataVersionStatus is VersionStatus.Unpublished;
// If the page is unpublished, try to create a draft.
bool draftCreated = unpublished && await webPageManager.TryCreateDraft(productPage.SystemFields.WebPageItemID, languageName);
// Try to publish the associated page wrapper IF it is NOT already published.
if (!published && !await webPageManager.TryPublish(productPage.SystemFields.WebPageItemID, languageName))
{
string errorMessage = "Publish failed for product page with ID {WebPageItemID} for product content item ID {ContentItemID} in language {LanguageName}."
+ ((unpublished && !draftCreated) ? " Draft creation failed for the unpublished page." : string.Empty);
logger.LogError(EventIds.ProductWrapperPublishFailed,
errorMessage,
productPage.SystemFields.WebPageItemID,
contentItemId,
languageName);
}
}
}
// ... existing code ...
Implement event handlers and map each event to wrapper behavior
With our page orchestration logic in place, we can finally implement the event handlers. Each time a reusable product item is created, translated, published, unpublished, or deleted, the same should happen to its corresponding page wrapper in the given language.
Each handler should start with IsApplicableType and abort execution for non-product content types.
// Partial — see the complete ProductPageWrapperHandlerModule.cs for all using directives and full implementation
// ... existing code ...
// Handler triggered after a new product content item is created
internal class ProductPageCreateHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterCreateContentItemEvent>
{
public async Task HandleAsync(AfterCreateContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (data.ID is null || !service.IsApplicableType(data.ContentTypeName))
return;
if (data.Guid is not null)
{
await service.CreatePageWrapperForProduct(
displayName: data.DisplayName,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentTypeId: data.ContentTypeID,
contentItemGuid: data.Guid.Value);
}
}
}
// Handler triggered after a new language variant of a product content item is created
internal class ProductPageCreateLanguageVariantHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterCreateLanguageVariantEvent>
{
public async Task HandleAsync(AfterCreateLanguageVariantEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
await service.EnsureProductPageWrapperForLanguage(
displayName: data.DisplayName,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentTypeId: data.ContentTypeID,
contentItemGuid: data.Guid);
}
}
// Handler triggered after a product content item is deleted
internal class ProductPageDeleteHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterDeleteContentItemEvent>
{
public async Task HandleAsync(AfterDeleteContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
// If there is no existing page in the language being deleted, we don't need to create a new one, so we will not use the ensure method.
await service.DeleteProductPageWrappers(
guid: data.Guid,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID);
}
}
// Handler triggered after a product content item is published
internal class ProductPagePublishHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterPublishContentItemEvent>
{
public async Task HandleAsync(AfterPublishContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
await service.PublishProductPageWrappers(
displayName: data.DisplayName,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentTypeId: data.ContentTypeID,
contentItemGuid: data.Guid,
contentItemId: data.ID);
}
}
// Handler triggered after a product content item is unpublished
internal class ProductPageUnpublishHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterUnpublishContentItemEvent>
{
public async Task HandleAsync(AfterUnpublishContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
await service.UnpublishProductPageWrappers(
contentItemGuid: data.Guid,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentItemId: data.ID);
}
}
// ... existing code ...
Add logging event IDs
Finally, add EventId fields in your shared EventIds class for the new failure logs used by the handler.
At minimum, include IDs for product wrapper create failure, wrapper language-variant create failure, wrapper publish failure, wrapper unpublish failure, and parent-page language-variant create failure.
Structured event IDs make diagnostics and monitoring much easier over time.
internal static class EventIds
{
// ... existing code ...
public static readonly EventId ProductWrapperPublishFailed = new(1013, nameof(ProductWrapperPublishFailed));
public static readonly EventId ProductWrapperUnpublishFailed = new(1014, nameof(ProductWrapperUnpublishFailed));
public static readonly EventId ProductWrapperCreateFailed = new(1015, nameof(ProductWrapperCreateFailed));
public static readonly EventId ProductWrapperLanguageVariantCreateFailed = new(1016, nameof(ProductWrapperLanguageVariantCreateFailed));
public static readonly EventId ProductParentPageLanguageVariantCreateFailed = new(1017, nameof(ProductParentPageLanguageVariantCreateFailed));
public static readonly EventId ProductWrapperDraftCreateFailed = new(1018, nameof(ProductWrapperDraftCreateFailed));
}
Check your progress
Now you can see the handler in action, reacting to product content item creation, translation, and (un)publishing actions to update pages in the website channel:
You can also try creating new product content types to test the parent page creation functionality. Just remember to generate code files for the content type before testing, as the GetApplicableTypeNames method relies on the classes and interfaces in code.
Troubleshooting
If wrapper pages are not being created or updated as expected, start with these checks.
Event handlers are not firing
Confirm the module is registered with [assembly: RegisterModule(typeof(ProductPageWrapperHandlerModule))], verify that OnInit executes and subscribes to events, and make sure the incoming content item type actually passes IsApplicableType.
If your environment prevents you from debugging with breakpoints, you can place a log entry at the start of each event handler to verify execution order and event payload values.
No parent page found or created
Verify that a page with the tree path /Store exists in the target channel and language (or a fallback language), that CHANNEL_NAME and WEB_CHANNEL_GUID point to the intended website channel, and that RetrieveWebPageByPathWithoutContext<EmptyPage> is called with the expected languageName and forPreview values.
Wrapper pages are created in the wrong place
Check the return value of EnsureParentPageInLanguage, confirm StoreSection.StoreSectionContentTypes contains the expected product content type GUID, and verify the parent lookup uses WhereContains(nameof(StoreSection.StoreSectionContentTypes), contentTypeGuid.ToString()).
Language variant is missing
Verify the event payload provides the expected ContentLanguageName and ContentLanguageID, confirm EnsureProductPageWrapperForLanguage is called during create-language-variant and publish events, and check whether CreateParentPageLanguageVariant is needed first (a missing parent variant blocks child placement).
Publish or unpublish does not match product state
Confirm wrapper lookup returns pages for the same product GUID and language, verify publish/unpublish loops call TryPublish and TryUnpublish with the event language, and check logs for ProductWrapperPublishFailed and ProductWrapperUnpublishFailed event IDs.
Complete files for the event handler and service
In the end, your handler and service should look like these:
using CMS;
using CMS.Base;
using CMS.ContentEngine;
using CMS.Core;
using CMS.DataEngine;
using TrainingGuides.Web.Commerce.EventHandlers;
[assembly: RegisterModule(typeof(ProductPageWrapperHandlerModule))]
namespace TrainingGuides.Web.Commerce.EventHandlers;
public class ProductPageWrapperHandlerModule : Module
{
public ProductPageWrapperHandlerModule() : base(nameof(ProductPageWrapperHandlerModule)) { }
protected override void OnPreInit(ModulePreInitParameters parameters)
{
base.OnPreInit(parameters);
parameters.Services.AddSingleton<ProductPageWrapperService>();
parameters.Services.AddEventHandler<AfterCreateContentItemEvent, ProductPageCreateHandler>();
parameters.Services.AddEventHandler<AfterCreateLanguageVariantEvent, ProductPageCreateLanguageVariantHandler>();
parameters.Services.AddEventHandler<AfterDeleteContentItemEvent, ProductPageDeleteHandler>();
parameters.Services.AddEventHandler<AfterPublishContentItemEvent, ProductPagePublishHandler>();
parameters.Services.AddEventHandler<AfterUnpublishContentItemEvent, ProductPageUnpublishHandler>();
}
}
// Handler triggered after a new product content item is created
internal class ProductPageCreateHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterCreateContentItemEvent>
{
public async Task HandleAsync(AfterCreateContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (data.ID is null || !service.IsApplicableType(data.ContentTypeName))
return;
if (data.Guid is not null)
{
await service.CreatePageWrapperForProduct(
displayName: data.DisplayName,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentTypeId: data.ContentTypeID,
contentItemGuid: data.Guid.Value);
}
}
}
// Handler triggered after a new language variant of a product content item is created
internal class ProductPageCreateLanguageVariantHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterCreateLanguageVariantEvent>
{
public async Task HandleAsync(AfterCreateLanguageVariantEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
await service.EnsureProductPageWrapperForLanguage(
displayName: data.DisplayName,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentTypeId: data.ContentTypeID,
contentItemGuid: data.Guid);
}
}
// Handler triggered after a product content item is deleted
internal class ProductPageDeleteHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterDeleteContentItemEvent>
{
public async Task HandleAsync(AfterDeleteContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
// If there is no existing page in the language being deleted, we don't need to create a new one, so we will not use the ensure method.
await service.DeleteProductPageWrappers(
guid: data.Guid,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID);
}
}
// Handler triggered after a product content item is published
internal class ProductPagePublishHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterPublishContentItemEvent>
{
public async Task HandleAsync(AfterPublishContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
await service.PublishProductPageWrappers(
displayName: data.DisplayName,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentTypeId: data.ContentTypeID,
contentItemGuid: data.Guid,
contentItemId: data.ID);
}
}
// Handler triggered after a product content item is unpublished
internal class ProductPageUnpublishHandler(ProductPageWrapperService service) : IAsyncEventHandler<AfterUnpublishContentItemEvent>
{
public async Task HandleAsync(AfterUnpublishContentItemEvent asyncEvent, CancellationToken cancellationToken)
{
var data = asyncEvent.Data;
if (!service.IsApplicableType(data.ContentTypeName))
return;
await service.UnpublishProductPageWrappers(
contentItemGuid: data.Guid,
languageName: data.ContentLanguageName,
languageId: data.ContentLanguageID,
contentItemId: data.ID);
}
}
using CMS.ContentEngine;
using CMS.Core;
using CMS.DataEngine;
using CMS.Membership;
using Kentico.PageBuilder.Web.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using TrainingGuides.ProductStock;
using TrainingGuides.Web.Features.Commerce.Products.Widgets.ProductListing;
using TrainingGuides.Web.Features.Commerce.Products.Widgets.ProductWidget;
using TrainingGuides.Web.Features.Shared.Logging;
using TrainingGuides.Web.Features.Shared.OptionProviders.ColorScheme;
using TrainingGuides.Web.Features.Shared.OptionProviders.ColumnLayout;
using TrainingGuides.Web.Features.Shared.OptionProviders.CornerStyle;
using TrainingGuides.Web.Features.Shared.Sections.General;
using TrainingGuides.Web.Features.Shared.Services;
namespace TrainingGuides.Web.Commerce.EventHandlers;
// Shared service containing the page wrapper orchestration logic, injected into each handler
internal class ProductPageWrapperService
{
// Username of an admin user
private const string ADMIN = "administrator";
// Path to the store section of the site
private const string STORE_PATH = "/Store";
// Name of the website channel
private const string CHANNEL_NAME = "TrainingGuidesPages";
// GUID of the website channel (Note this is the value of WebsiteChannelGUID, not ChannelGUID)
private const string WEB_CHANNEL_GUID = "FDBA40FE-1ECE-4821-9D57-EAA1D89E13B1";
private readonly IWebPageManager webPageManager;
private readonly IContentItemRetrieverService contentItemRetrieverService;
private readonly ILogger<ProductPageWrapperService> logger;
public ProductPageWrapperService(
IWebPageManagerFactory webPageManagerFactory,
IContentItemRetrieverService contentItemRetrieverService,
IInfoProvider<UserInfo> userInfoProvider,
IInfoProvider<WebsiteChannelInfo> websiteChannelInfoProvider,
ILogger<ProductPageWrapperService> logger)
{
this.contentItemRetrieverService = contentItemRetrieverService;
this.logger = logger;
var user = userInfoProvider.Get()
.WhereEquals(nameof(UserInfo.UserName), ADMIN)
.FirstOrDefault();
var webChannel = websiteChannelInfoProvider.Get()
.WhereEquals(nameof(WebsiteChannelInfo.WebsiteChannelGUID), new Guid(WEB_CHANNEL_GUID))
.FirstOrDefault();
webPageManager = webPageManagerFactory.Create(webChannel?.WebsiteChannelID ?? 0, user?.UserID ?? 0);
}
/// <summary>
/// Creates a page wrapper for the specified product content item in the specified language, and returns the ID of the page. Ensures the parent page exists.
/// </summary>
/// <param name="displayName">The display name of the product content item, used for naming the page wrapper.</param>
/// <param name="languageName">The language for which to create the page wrapper.</param>
/// <param name="languageId">The ID of the language for which to create the page wrapper.</param>
/// <param name="contentTypeId">The content type ID of the product content item, used to find or create a parent page if necessary.</param>
/// <param name="contentItemGuid">The GUID of the product content item, used to link the page wrapper to the product.</param>
/// <returns>The ID of the created page.</returns>
/// </summary>
internal async Task<int> CreatePageWrapperForProduct(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid)
{
var itemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(ProductPage.ProductPageProducts), new List<ContentItemReference>()
{ new() { Identifier = contentItemGuid } } },
});
var contentItemParameters = new ContentItemParameters(ProductPage.CONTENT_TYPE_NAME, itemData);
var createPageParameters = new CreateWebPageParameters(displayName, languageName, contentItemParameters)
{
ParentWebPageItemID = await EnsureParentPageInLanguage(contentTypeId, languageName, languageId)
};
createPageParameters.SetPageBuilderConfiguration(GetProductWidgetsConfiguration(), GetPageTemplateConfiguration());
int id = await webPageManager.Create(createPageParameters);
if (id <= 0)
{
logger.LogError(EventIds.ProductWrapperCreateFailed,
"Page wrapper creation failed for product content item with GUID {ContentItemGuid} in language {LanguageName}.",
contentItemGuid,
languageName);
}
return id;
}
/// <summary>
/// Ensures that a product page exists for the specified product and language. If it doesn't exist, it will be created. If a page exists in another language, a language variant will be created for the current language.
/// </summary>
/// <param name="displayName">Display name for new variant if creation required</param>
/// <param name="languageName">Language name of variant to retrieve</param>
/// <param name="languageId">Language ID of variant to retrieve (must match language name)</param>
/// <param name="contentTypeId">ID of the reusable product item's content type</param>
/// <param name="contentItemGuid">GUID of the reusable content item</param>
/// <returns>An enumerable collection of wrapper pages linking the specified product</returns>
internal async Task<IEnumerable<IWebPageFieldsSource>> EnsureProductPageWrapperForLanguage(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid)
{
var langSpecificProductPages = await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
if (langSpecificProductPages.Any())
{
return langSpecificProductPages;
}
var allLanguageProductPages = await GetProductPagesForProduct(contentItemGuid, null, null);
if (allLanguageProductPages.Any())
{
var uniquePageIds = allLanguageProductPages.Select(page => page.SystemFields.WebPageItemID).Distinct();
foreach (int pageId in uniquePageIds)
{
// We should still make sure the parent exists in the current language, but we don't need its ID to create a new page
_ = await EnsureParentPageInLanguage(contentTypeId, languageName, languageId);
await CreatePageWrapperLanguageVariantForProduct(displayName, languageName, contentItemGuid, pageId);
}
return await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
}
_ = await CreatePageWrapperForProduct(displayName, languageName, languageId, contentTypeId, contentItemGuid);
return await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
}
/// <summary>
/// Publishes all page wrappers for a given product content item in a specific language.
/// </summary>
/// <param name="displayName">Display name of the product content item</param>
/// <param name="languageName">Language name of the variant to publish</param>
/// <param name="languageId">Language ID of the variant to publish (must match language name)</param>
/// <param name="contentTypeId">Content type ID of the product content item</param>
/// <param name="contentItemGuid">GUID of the product content item</param>
/// <param name="contentItemId">ID of the product content item</param>
internal async Task PublishProductPageWrappers(string displayName, string languageName, int languageId, int contentTypeId, Guid contentItemGuid, int contentItemId)
{
var productPages = await EnsureProductPageWrapperForLanguage(displayName, languageName, languageId, contentTypeId, contentItemGuid);
foreach (var productPage in productPages)
{
bool published = productPage.SystemFields.ContentItemCommonDataVersionStatus is VersionStatus.Published;
bool unpublished = productPage.SystemFields.ContentItemCommonDataVersionStatus is VersionStatus.Unpublished;
// If the page is unpublished, try to create a draft.
bool draftCreated = unpublished && await webPageManager.TryCreateDraft(productPage.SystemFields.WebPageItemID, languageName);
// Try to publish the associated page wrapper IF it is NOT already published.
if (!published && !await webPageManager.TryPublish(productPage.SystemFields.WebPageItemID, languageName))
{
string errorMessage = "Publish failed for product page with ID {WebPageItemID} for product content item ID {ContentItemID} in language {LanguageName}."
+ ((unpublished && !draftCreated) ? " Draft creation failed for the unpublished page." : string.Empty);
logger.LogError(EventIds.ProductWrapperPublishFailed,
errorMessage,
productPage.SystemFields.WebPageItemID,
contentItemId,
languageName);
}
}
}
/// <summary>
/// Deletes all page wrappers for a given product content item in a specific language.
/// </summary>
/// <param name="guid">GUID of the reusable content item</param>
/// <param name="languageName">Language name of the variant to delete</param>
/// <param name="languageId">Language ID of the variant to delete (must match language name)</param>
internal async Task DeleteProductPageWrappers(Guid guid, string? languageName, int? languageId)
{
var productPages = await GetProductPagesForProduct(guid, languageName, languageId);
foreach (var productPage in productPages)
{
await webPageManager.Delete(
new DeleteWebPageParameters(productPage.SystemFields.WebPageItemID, languageName)
{
Permanently = true,
});
}
}
/// <summary>
/// Unpublishes all page wrappers for a given product content item in a specific language.
/// </summary>
/// <param name="contentItemGuid">GUID of the product content item</param>
/// <param name="languageName">Language name of the variant to unpublish</param>
/// <param name="languageId">Language ID of the variant to unpublish (must match language name)</param>
/// <param name="contentItemId">ID of the product content item</param>
internal async Task UnpublishProductPageWrappers(Guid contentItemGuid, string languageName, int languageId, int contentItemId)
{
var productPages = await GetProductPagesForProduct(contentItemGuid, languageName, languageId);
foreach (var productPage in productPages)
{
if (!await webPageManager.TryUnpublish(productPage.SystemFields.WebPageItemID, languageName))
{
logger.LogError(EventIds.ProductWrapperUnpublishFailed,
"Unpublish failed for product page with ID {WebPageItemID} for product content item ID {ContentItemID} in language {LanguageName}.",
productPage.SystemFields.WebPageItemID,
contentItemId,
languageName);
}
}
}
/// <summary>
/// Checks if the specified content type is one that should have a product page wrapper.
/// </summary>
/// <param name="contentTypeName">The name of the content type to check.</param>
/// <returns>True if the content type should have a product page wrapper; otherwise, false.</returns>
internal bool IsApplicableType(string contentTypeName) =>
GetApplicableTypeNames().Contains(contentTypeName);
/// <summary>
/// Retrieves the content type names of all product types that should have a page wrapper.
/// This is determined by finding all classes that implement IProductSchema but not IProductVariantSchema, and retrieving the value of their CONTENT_TYPE_NAME constant.
/// </summary>
/// <returns>A list of content type names</returns>
/// <remarks>
/// Make sure the content type of the page wrapper does not meet the criteria defined in this method, otherwise it will cause an infinite loop of page creation.
/// </remarks>
private IEnumerable<string> GetApplicableTypeNames()
{
// We know this class is stored in the Entities project, so we can use it to access the assembly
var entitiesAssembly = typeof(ProductAvailableStockInfo).Assembly;
var types = entitiesAssembly
.GetTypes()
.Where(type =>
type.IsClass
&& !type.IsAbstract
&& typeof(IProductSchema).IsAssignableFrom(type)
&& !typeof(IProductVariantSchema).IsAssignableFrom(type));
return types.Select(type =>
{
var field = type.GetField("CONTENT_TYPE_NAME");
if (field is not null)
{
// The field is a constant, so we don't need an instance to retrieve its value - we can use null instead
return field.GetValue(null) as string;
}
// If there is no CONTENT_TYPE_NAME constant, we skip this type
return null;
})
// Filter out the classes with no CONTENT_TYPE_NAME constant
.Where(name => name is not null)!;
}
/// <summary>
/// Creates Page Builder JSON for the product wrapper page, with a product widget configured to display the current product
/// </summary>
/// <returns>JSON string representing the product page's Page Builder content</returns>
private string GetProductWidgetsConfiguration()
{
var config = new EditableAreasConfiguration
{
EditableAreas =
{
new EditableAreaConfiguration
{
Identifier = "areaMain",
Sections =
{
new SectionConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Sections.GENERAL,
Properties = new GeneralSectionProperties
{
ColorScheme = ColorSchemeOption.TransparentDark.ToString(),
CornerStyle = CornerStyleOption.Round.ToString(),
ColumnLayout = ColumnLayoutOption.OneColumn.ToString(),
},
Zones =
{
new ZoneConfiguration
{
Identifier = Guid.NewGuid(),
Name = "zoneMain",
Widgets =
{
new WidgetConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Widgets.PRODUCT,
Variants =
{
new WidgetVariantConfiguration
{
Identifier = Guid.NewGuid(),
Properties = new ProductWidgetProperties
{
DisplayCurrentPage = true,
ShowVariantSelection = true,
ShowVariantDetails = true,
ShowCallToAction = false,
CallToActionText = "View Product",
}
}
}
}
}
}
}
}
}
}
}
};
return JsonConvert.SerializeObject(config, Formatting.None, GetSerializerSettings());
}
/// <summary>
/// Creates Page Builder JSON for the parent page of the product wrapper, with a product listing widget configured to display products that exist beneath it in the tree
/// </summary>
/// <returns>JSON string representing the parent page's Page Builder content</returns>
private string GetParentWidgetsConfiguration()
{
var config = new EditableAreasConfiguration
{
EditableAreas =
{
new EditableAreaConfiguration
{
Identifier = "areaMain",
Sections =
{
new SectionConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Sections.GENERAL,
Properties = new GeneralSectionProperties
{
ColorScheme = ColorSchemeOption.TransparentDark.ToString(),
CornerStyle = CornerStyleOption.Round.ToString(),
ColumnLayout = ColumnLayoutOption.OneColumn.ToString(),
},
Zones =
{
new ZoneConfiguration
{
Identifier = Guid.NewGuid(),
Name = "zoneMain",
Widgets =
{
new WidgetConfiguration
{
Identifier = Guid.NewGuid(),
TypeIdentifier = ComponentIdentifiers.Widgets.PRODUCT_LISTING,
Variants =
{
new WidgetVariantConfiguration
{
Identifier = Guid.NewGuid(),
Properties = new ProductListingWidgetProperties
{
SecuredItemsDisplayMode = SecuredOption.PromptForLogin.ToString(),
}
}
}
}
}
}
}
}
}
}
}
};
return JsonConvert.SerializeObject(config, Formatting.None, GetSerializerSettings());
}
/// <summary>
/// Creates JSON serializer settings for dealing with Page Builder JSON
/// </summary>
/// <returns>JSON serializer settings</returns>
private JsonSerializerSettings GetSerializerSettings() =>
new()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Include,
};
/// <summary>
/// Creates page template configuration JSON for the product wrapper page and its parent page, using the General template with default properties
/// </summary>
/// <returns>JSON string representing the page template configuration</returns>
private string GetPageTemplateConfiguration() =>
"{\"identifier\":\"TrainingGuides.GeneralPageTemplate\",\"properties\":null,\"fieldIdentifiers\":null}";
/// <summary>
/// Retrieves all product pages that reference the specified product.
/// </summary>
/// <param name="guid">GUID of the product item being referenced</param>
/// <param name="languageName">Language version to check</param>
/// <param name="languageId">Language ID to check (must match the language name)</param>
/// <returns>A collection of product pages that reference the specified product</returns>
private async Task<IEnumerable<IWebPageFieldsSource>> GetProductPagesForProduct(Guid guid, string? languageName, int? languageId)
{
Func<ContentQueryParameters, ContentQueryParameters> whereFilter = config => config
// Normally we would use the .Linking(...) method in the customContentTypeQueryParameters function.
// However, during the delete event, the linking data is already removed,
// so we must manually check for the GUID in ProductPageProducts.
.Where(where => where.WhereContains(nameof(ProductPage.ProductPageProducts), guid.ToString()));
var customContentQueryParameters = languageId is int intLanguageId
? (config => whereFilter(config
// Filter by language ID to ensure we only get pages in the specific language, without falling back to other languages
.Where(where => where.WhereEquals(
nameof(ProductPage.SystemFields.ContentItemCommonDataContentLanguageID), intLanguageId))))
: whereFilter;
return await contentItemRetrieverService
.RetrieveWebPageChildrenByPathWithoutContext(
contentTypeNames: [ProductPage.CONTENT_TYPE_NAME],
parentPagePath: STORE_PATH,
customContentTypeQueryParameters: query => query,
customContentQueryParameters: customContentQueryParameters,
forPreview: true,
includeSecuredItems: true,
depth: 0,
languageName: languageName,
channelName: CHANNEL_NAME);
}
/// <summary>
/// Ensures a parent page exists for the specified product content type in the current language
/// </summary>
/// <param name="contentTypeId">The ID of the product content type</param>
/// <param name="languageName">The language in which to ensure the parent page exists</param>
/// <param name="languageId">The ID of the language</param>
/// <returns>The WebPageItemID of the parent page</returns>
private async Task<int> EnsureParentPageInLanguage(int contentTypeId, string languageName, int languageId)
{
var contentType = DataClassInfoProvider.GetDataClassInfo(contentTypeId);
var contentTypeGuid = contentType?.ClassGUID ?? Guid.Empty;
// Get any language version of the parent page
var existingParentPages = await contentItemRetrieverService
.RetrieveWebPageChildrenByPathWithoutContext(
contentTypeNames: [StoreSection.CONTENT_TYPE_NAME],
parentPagePath: STORE_PATH,
customContentTypeQueryParameters: query => query,
customContentQueryParameters: config => config
// Filter store section pages that are assigned to the product's content type
.Where(where => where.WhereContains(nameof(StoreSection.StoreSectionContentTypes), contentTypeGuid.ToString()))
.TopN(1),
forPreview: true,
includeSecuredItems: true,
depth: 0,
languageName: null,
channelName: CHANNEL_NAME);
if (!existingParentPages.Any())
{
// If there is no parent page for the product type, create one and return its ID
return await CreateParentPage(
displayName: contentType?.ClassDisplayName ?? "Store section",
languageName: languageName,
contentTypeGuid: contentTypeGuid
);
}
else
{
// If there are no language variants in the specified language, create one
if (!existingParentPages
.Where(page => page.SystemFields.ContentItemCommonDataContentLanguageID == languageId)
.Any())
{
await CreateParentPageLanguageVariant(
displayName: contentType?.ClassDisplayName ?? "Store section",
languageName: languageName,
contentTypeGuid: contentTypeGuid,
webPageItemID: existingParentPages.First().SystemFields.WebPageItemID);
}
// Return the existing parent page ID
return existingParentPages.First().SystemFields.WebPageItemID;
}
}
/// <summary>
/// Creates a parent page for a given product content type and language
/// </summary>
/// <param name="displayName">The display name of the parent page</param>
/// <param name="languageName">The language in which to create the parent page</param>
/// <param name="contentTypeGuid">The GUID of the product content type</param>
/// <returns>The WebPageItemID of the created parent page</returns>
private async Task<int> CreateParentPage(string displayName, string languageName, Guid contentTypeGuid)
{
var parentData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(StoreSection.StoreSectionContentTypes), new List<Guid>(){ contentTypeGuid } },
});
var parentContentItemParameters = new ContentItemParameters(StoreSection.CONTENT_TYPE_NAME, parentData);
var createParentPageParameters = new CreateWebPageParameters(displayName,
languageName,
parentContentItemParameters)
{
ParentWebPageItemID = await GetStorePageId(languageName) ?? 0
};
createParentPageParameters.SetPageBuilderConfiguration(GetParentWidgetsConfiguration(), GetPageTemplateConfiguration());
return await webPageManager.Create(createParentPageParameters);
}
/// <summary>
/// Creates a language variant for an existing parent page.
/// </summary>
/// <param name="displayName">The display name of the parent page</param>
/// <param name="languageName">The language in which to create the language variant</param>
/// <param name="contentTypeGuid">The GUID of the product content type</param>
/// <param name="webPageItemID">The WebPageItemID of the parent page</param>
private async Task CreateParentPageLanguageVariant(string displayName, string languageName, Guid contentTypeGuid, int webPageItemID)
{
var parentData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(StoreSection.StoreSectionContentTypes), new List<Guid>(){ contentTypeGuid } },
});
var createLanguageVariantParameters =
new CMS.Websites.CreateLanguageVariantParameters(webPageItemID,
languageName,
displayName,
parentData);
createLanguageVariantParameters.SetPageBuilderConfiguration(GetParentWidgetsConfiguration(), GetPageTemplateConfiguration());
if (!await webPageManager.TryCreateLanguageVariant(createLanguageVariantParameters))
{
logger.LogError(EventIds.ProductParentPageLanguageVariantCreateFailed,
"Parent page language variant creation failed for product content type with GUID {ContentTypeGuid} in language {LanguageName}.",
contentTypeGuid,
languageName);
}
}
/// <summary>
/// Retrieves the page ID of the store's main page
/// </summary>
/// <param name="languageName">The language to query</param>
/// <returns>The WebPageItemID of the store's main page, or null if not found</returns>
private async Task<int?> GetStorePageId(string languageName)
{
var page = await contentItemRetrieverService.RetrieveWebPageByPathWithoutContext<EmptyPage>(
pathToMatch: STORE_PATH,
includeSecuredItems: true,
languageName: languageName,
channelName: CHANNEL_NAME,
forPreview: true);
return page?.SystemFields.WebPageItemID;
}
/// <summary>
/// Creates a language variant of a product's page wrapper in the specified language
/// </summary>
/// <param name="displayName">The display name of the content item to apply to the page wrapper</param>
/// <param name="languageName">The language in which to create the variant</param>
/// <param name="contentItemGuid">The GUID of the content item to be referenced by the page wrapper</param>
/// <param name="existingPageId">The WebPageItemID of the existing page</param>
private async Task CreatePageWrapperLanguageVariantForProduct(string displayName, string languageName, Guid contentItemGuid, int existingPageId)
{
var itemData = new ContentItemData(new Dictionary<string, object>
{
{ nameof(ProductPage.ProductPageProducts), new List<ContentItemReference>()
{ new() { Identifier = contentItemGuid } } },
});
var createLanguageVariantParameters =
new CMS.Websites.CreateLanguageVariantParameters(existingPageId,
languageName,
displayName,
itemData);
createLanguageVariantParameters.SetPageBuilderConfiguration(GetProductWidgetsConfiguration(), GetPageTemplateConfiguration());
if (!await webPageManager.TryCreateLanguageVariant(createLanguageVariantParameters))
{
logger.LogError(EventIds.ProductWrapperLanguageVariantCreateFailed,
"Page wrapper language variant creation failed for product content item with GUID {ContentItemGuid} in language {LanguageName}.",
contentItemGuid,
languageName);
}
}
}
What’s next?
If you haven’t already, check out our other commerce-related guides for developers:
- Explore the Training guides product catalog implementation
- Implement product display patterns with KentiCopilot
- Implement faceted filtering for product catalog
These resources may also help: