Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Building a Custom "Points Banner" Content Type for Page Builder

Building a Custom "Points Banner" Content Type for Page Builder

~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Chapter 58 drew a clear line: a custom content type usually shows static, editorial content, not a live points balance. This chapter's "Points Banner" takes that seriously - it promotes the loyalty program itself ("Earn points on every order!"), but never shows any individual customer's balance. Freely placeable via drag and drop on any landing page, with a heading, body text, call-to-action, and background color, all editable in the admin.

Content type registration

app/code/Mironsoft/Loyalty/etc/pagebuilder/content_type.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_PageBuilder:etc/content_type.xsd">
    <type name="mironsoft_loyalty_points_banner"
          label="Points Banner"
          translate="label"
          component="Magento_PageBuilder/js/content-type"
          preview_component="Mironsoft_Loyalty/js/content-type/points-banner/preview"
          master_component="Magento_PageBuilder/js/content-type/master"
          form="mironsoft_loyalty_points_banner_form"
          icon="icon-pagebuilder-banner"
          menu_section="elements"
          sortOrder="200">
        <appearances>
            <appearance name="default" default="true"/>
        </appearances>
    </type>
</config>

Tipp: This file shows the essential shape, not the complete XSD - see the tip at the end of chapter 58 about the built-in Banner content type as a complete reference example in your own installation.

Admin form for the properties

form="mironsoft_loyalty_points_banner_form" points at a UI component form definition with the five fields heading, body_text, cta_label, cta_url, and background_color - technically the same UI component form technique the reward admin grid already used back in chapter 16. This catalog's separate "Admin Grids & Forms in Magento 2" tutorial series (admin-grids-formulare DE / magento-admin-grids-forms EN) covers UI component forms in detail - the field list here is enough to understand what PointsBanner::getHeading() and friends read below.

The content type class

app/code/Mironsoft/Loyalty/Block/PageBuilder/PointsBanner.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Block\PageBuilder;

use Magento\Framework\View\Element\Template\Context;
use Magento\PageBuilder\Block\ContentType\ContentTypeAbstract;

/**
 * Renders the "Points Banner" Page Builder content type: a static, admin-editable marketing
 * banner promoting the loyalty program. Deliberately holds no dependency on
 * ViewModel\PointsBalance (chapter 48) or PointsBalanceWidget (chapter 56) - unlike those,
 * this content type shows the exact same content to every visitor, so it needs no
 * per-customer data at all (chapter 58 explains why that distinction matters here).
 * Extends the same base class Magento's own Banner content type extends.
 */
class PointsBanner extends ContentTypeAbstract
{
    /**
     * @param Context $context Framework template context.
     * @param array<string, mixed> $data Additional block data, populated by Page Builder from the saved attributes.
     */
    public function __construct(
        Context $context,
        array $data = [],
    ) {
        parent::__construct($context, $data);
    }

    /**
     * Returns the configured banner heading, falling back to a sensible default.
     *
     * @return string
     */
    public function getHeading(): string
    {
        return (string) ($this->getData('heading') ?: __('Earn points on every order'));
    }

    /**
     * Returns the configured banner body text, empty string if the editor left it blank.
     *
     * @return string
     */
    public function getBodyText(): string
    {
        return (string) $this->getData('body_text');
    }

    /**
     * Returns the configured call-to-action label, empty string if unset.
     *
     * @return string
     */
    public function getCtaLabel(): string
    {
        return (string) $this->getData('cta_label');
    }

    /**
     * Returns the configured call-to-action target URL, empty string if unset.
     *
     * @return string
     */
    public function getCtaUrl(): string
    {
        return (string) $this->getData('cta_url');
    }

    /**
     * Returns the configured background color as a CSS-safe hex value, defaulting to the
     * brand-dark color when the stored value is missing or malformed.
     *
     * @return string
     */
    public function getBackgroundColor(): string
    {
        $color = (string) $this->getData('background_color');

        return $color !== '' && preg_match('/^#[0-9a-fA-F]{6}$/', $color) === 1 ? $color : '#0f172a';
    }
}

\Magento\PageBuilder\Block\ContentType\ContentTypeAbstract is the base class Page Builder itself dictates, already announced back in chapter 47 as the third case where Magento's core conventions force a real block subclass - the exact same class the built-in Banner content type extends too.

The frontend template

app/code/Mironsoft/Loyalty/view/frontend/templates/pagebuilder/points-banner/default.phtml
<?php

declare(strict_types=1);

use Magento\Framework\Escaper;
use Mironsoft\Loyalty\Block\PageBuilder\PointsBanner;

/**
 * @var PointsBanner $block
 * @var Escaper $escaper
 */
?>
<div class="my-8 overflow-hidden rounded-2xl px-6 py-10 text-center sm:px-12"
     style="background-color: <?= $escaper->escapeHtmlAttr($block->getBackgroundColor()) ?>;">
    <h2 class="text-2xl font-bold text-white sm:text-3xl">
        <?= $escaper->escapeHtml($block->getHeading()) ?>
    </h2>
    <?php if ($block->getBodyText() !== ''): ?>
        <p class="mx-auto mt-3 max-w-2xl text-base text-white/80">
            <?= $escaper->escapeHtml($block->getBodyText()) ?>
        </p>
    <?php endif; ?>
    <?php if ($block->getCtaLabel() !== '' && $block->getCtaUrl() !== ''): ?>
        <a href="<?= $escaper->escapeUrl($block->getCtaUrl()) ?>"
           class="mt-6 inline-flex items-center rounded-full bg-white px-6 py-3 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-100">
            <?= $escaper->escapeHtml($block->getCtaLabel()) ?>
        </a>
    <?php endif; ?>
</div>

The same block+template pair plays two roles: it produces the frozen master format HTML when the page is saved in the admin (chapter 58) AND - should a content type ever require it - serves the same output for direct frontend rendering. For a purely static content type like this one, that's an implementation detail with no visible difference; it only starts mattering for content types that use the {{widget}} placeholder trick from chapter 58.

Achtung: Deliberately NO connection to ViewModel\PointsBalance or PointsBalanceWidget. Trying to show the logged-in customer's name or points balance here would either show the same value (the editor's, at save time) to EVERY visitor, or - depending on how the master format was generated - no meaningful value at all, never the actual current value for the visitor looking at the page. That's exactly the structural difference from a widget described in chapter 58, not an avoidable implementation mistake.

Tipp: Just like widget.xml in chapter 55: content_type.xml lands in the config cache. bin/cache-clean config after every change, so the new content type shows up in Page Builder's block palette in the admin.