Skip to content

Beispiel: Testimonial Widget

Ein komplettes Pagebuilder-Widget von null — nach Copy-Paste und Cache-Rebuild einsetzbar. Das testimonial-Widget zeigt ein Zitat mit Autor-Name, Position und Foto. Es demonstriert alle wichtigen Konzepte: bootstrap.php im Plugin, Widget-Unterordner mit Klasse, widget_menu-Felder, icon.png, Vue-Template mit Props-Binding.

Ziel

Ein Widget mit vier Feldern:

FeldTypVerwendung
Nametext (title)Autor des Testimonials
Positiontext (subtitle)Berufsbezeichnung, Firma
Quotetextarea (text)Das Zitat selbst
Fotofilemanager (image)Avatar-Bild

Struktur

Wir erweitern ein bestehendes Plugin (widgetsBase als Beispiel) um dieses Widget:

_public/extensions/core/backend/widgetsBase/
├── bootstrap.php                 # Plugin-bootstrap (Widget-Registrierung hier ergaenzen)
└── widgets/
    └── testimonial/              # Neuer Widget-Ordner
        ├── bootstrap.php         # Widget-Klasse
        ├── icon.png              # Picker-Icon
        └── template/
            └── index.vue         # Frontend-Komponente

Eigenes Plugin fuer Widgets

Im Projektalltag legt man Widgets meist in einem eigenen Plugin ab (z. B. testimonials/), damit bei De-/Installation alles zusammen wandert. Fuer das Beispiel nutzen wir der Einfachheit halber widgetsBase — in der Produktion waere ein separates Plugin sauberer. Siehe Plugins › Hello World.

1. Widget-Registrierung in widgetsBase/bootstrap.php

Im install()-Block des Plugins wird das pagebuilder_widgets-Array erweitert. Den neuen Eintrag am Ende der Liste einfuegen:

php
// Bestehendes $this->pagebuilder_widgets = [...] wird erweitert:
$this->pagebuilder_widgets[] = '{
    "widget_title":    "Testimonial",
    "widget_icon":     "icon.png",
    "widget_subtitle": "Quote with author name and photo",
    "widget_template": "testimonial",
    "widget_menu": [
        {"row": "title",    "type": "text",        "placeholder": "Author name"},
        {"row": "subtitle", "type": "text",        "placeholder": "Position / Company"},
        {"row": "text",     "type": "textarea",    "placeholder": "Quote"},
        {"row": "image",    "type": "filemanager", "placeholder": "Photo"}
    ]
}';

2. Widget-Klasse: widgets/testimonial/bootstrap.php

php
<?php

class testimonial_PageBuilderPlugin
{
    public static function getVersion(): string
    {
        return "1.0.0";
    }

    public static function getName(): string
    {
        return "Testimonial";
    }

    public static function getWidgetContent($widget_array, $backend = false)
    {
        // Bild-URL serverseitig aufloesen — das Formular-Feld enthaelt nur
        // die Media-ID als URL-encoded JSON [{"id":"123"}], nicht die URL selbst.
        $imageField = $widget_array['widget_image'] ?? '';
        $imageUrl   = !empty($imageField) ? files::loadURL($imageField) : '';

        return ['imageUrl' => $imageUrl];
    }
}

Drei statische Methoden, keine Vererbung. getWidgetContent() ist nicht null, weil wir die Media-ID aus widget_image serverseitig zu einer echten URL aufloesen muessen — files::loadURL() parst das URL-encoded JSON-Format [{"id":"123"}] und liefert den absoluten Asset-Pfad. Die aufgeloeste URL steht im Vue-Template unter widget.content.imageUrl.

Text-Felder (title, subtitle, text) stehen dagegen direkt im Vue-Template unter widget.data.* bereit — die brauchen keine Server-Aufloesung.

3. Icon: widgets/testimonial/icon.png

Ein einfaches 64×64-PNG-Icon im Widget-Root — zeigt sich im Picker des Pagebuilders neben widget_title und widget_subtitle.

Font-Awesome statt PNG

Wer lieber Font-Awesome-Icons nutzt: icon.png trotzdem als Platzhalter ablegen (der Loader erwartet die Datei), und im template/index.vue das Icon rendern. Ein Mechanismus fuer SVG- oder FA-Picker-Icons existiert aktuell nicht im Core.

4. Vue-Template: widgets/testimonial/template/index.vue

vue
<template>
  <div class="testimonial-widget">
    <div class="testimonial-content">
      <blockquote v-if="widget.data?.text" v-html="widget.data.text"></blockquote>

      <div class="testimonial-author">
        <img
          v-if="widget.content?.imageUrl"
          :src="widget.content.imageUrl"
          :alt="widget.data?.title || 'Testimonial'"
          class="testimonial-photo"
        />
        <div class="testimonial-author-meta">
          <strong v-if="widget.data?.title">{{ widget.data.title }}</strong>
          <span v-if="widget.data?.subtitle">{{ widget.data.subtitle }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
interface WidgetData {
    title?: string
    subtitle?: string
    text?: string
}

interface WidgetContent {
    imageUrl?: string
}

defineProps<{
    widget: { data?: WidgetData; content?: WidgetContent }
}>()
</script>

<style scoped>
.testimonial-widget {
    padding: 2rem;
    border-radius: 0.75rem;
    background: #f8fafc;
}
.testimonial-content blockquote {
    font-size: 1.25rem;
    line-height: 1.6;
    font-style: italic;
    color: #111827;
    margin: 0 0 1.5rem 0;
}
.testimonial-author {
    display: flex;
    align-items: center;
    gap: 1rem;
}
.testimonial-photo {
    width: 64px;
    height: 64px;
    border-radius: 50%;
    object-fit: cover;
}
.testimonial-author-meta {
    display: flex;
    flex-direction: column;
}
.testimonial-author-meta strong {
    font-weight: 600;
    color: #111827;
}
.testimonial-author-meta span {
    font-size: 0.875rem;
    color: #6b7280;
}
</style>

Hinweise zum Template

  • widget.data?.title — Formular-Wert direkt. Optional Chaining (?.), falls Formular noch leer ist.
  • widget.content?.imageUrl — die von getWidgetContent() serverseitig aufgeloeste Media-URL. Text-Felder kommen aus widget.data, aufgeloeste Bilder/externe Daten aus widget.content.
  • v-html fuer text — Anfuehrungszeichen und Zeilenumbrueche werden korrekt gerendert. Nur sicher, weil der Content vom Redakteur kommt (CMS-trusted). Siehe Pitfall-Sektion unten.
  • Scoped Styles — lokal auf diese Komponente beschraenkt, vermeidet CSS-Kollisionen mit anderen Widgets.

5. Widget-ID fuer Custom-CSS

Im Pagebuilder-Properties-Panel kann der Redakteur zusaetzlich Custom-CSS eingeben. Der Wrapper ist .widget_{base_id}:

css
/* Custom-CSS-Eingabe fuer dieses Widget — im Editor */
background: #4F46E5;

blockquote {
    color: white;
    font-size: 1.5rem;
}

.testimonial-author-meta strong {
    color: white;
}

Kompiliert zu:

css
.widget_217 { background: #4F46E5; }
.widget_217 blockquote { color: white; font-size: 1.5rem; }
.widget_217 .testimonial-author-meta strong { color: white; }

Details zum Wrapper-System: Custom-CSS-Konvention.

6. Plugin re-installieren und Cache rebuilden

1. /admin/plugin-manager → "widgetsBase" deinstallieren → neu installieren
   (damit das neue Widget in page_widgets landet)

2. /admin/cache → "Cache leeren"
   (damit plugin-registry.js mit der neuen testimonial-Komponente neu generiert wird)

7. Testen

  1. Beliebige Seite im Pagebuilder oeffnen (/admin/pagebuilder?id=…)
  2. Widget-Picker oeffnen (Neues Widget) → "Testimonial" sollte neben Title/HTML/Image auftauchen, mit deiner icon.png
  3. Widget auf die Seite droppen
  4. Felder befuellen (Name, Position, Quote, Photo)
  5. Page publishen
  6. Frontend-Seite aufrufen — das Testimonial rendert

Erweiterungen

Was das Widget als naechstes koennen koennte:

  • Mehrere Testimonialsitems-Feldtyp statt einzelnen Feldern, siehe Widget-Items / Repeater
  • Multi-Language → automatisch, sobald das Pagebuilder multilanguage unterstuetzt (gilt aber nur auf Widget-Item-Ebene, nicht pro Feld)
  • Responsive Bilder / Srcset → zusaetzlich files::loadURL() in mehreren Groessen aufrufen und als imageSrcset in widget.content bereitstellen — siehe getWidgetContent()
  • Scroll-Animation → GSAP-Preset im Properties-Panel aktivieren (Tab "Animations"), Details in CLAUDE.md-Abschnitt "Pagebuilder — Scroll Animations"

Haeufige Fehler beim Nachbauen

Widget nicht im Picker

Nach Hinzufuegen eines neuen Widgets: Plugin re-installieren (oder page_widgets manuell erweitern) und Cache unter /admin/cache rebuilden. Ohne beides ist das Widget nicht sichtbar.

Klassenname ≠ widget_template

Ordner widgets/testimonial/ → JSON "widget_template": "testimonial" → Klasse testimonial_PageBuilderPlugin. Alles drei muss exakt uebereinstimmen.

Asymmetrische Keys bei "row": "text"

Bei "row": "text" im widget_menu:

  • PHP (getWidgetContent): Zugriff via $widget_array['widget_html']PageService.php aliased die text-Spalte zu widget_html.
  • Vue (template/index.vue): Zugriff via widget.data.text — unveraendert die row-Spalte.

Das ist asymmetrisch: der Vue-Key sieht die DB-Spalte direkt, der PHP-Key heisst anders. Wer im PHP $widget_array['widget_text'] liest oder im Vue widget.data.html liest, bekommt null.

v-html bei nicht-trusted Content

v-html rendert HTML unescapt — fuer Pagebuilder-Widgets ist das ok, weil der Content vom Redakteur (CMS-trusted) kommt. Niemals bei User-Input aus dem Frontend (Form-Submits, Comments) — XSS-Risiko.

Icon-Datei fehlt

widget_icon: "icon.png" erwartet die Datei im Widget-Root. Ohne icon.png zeigt der Picker ein Broken-Image.

Custom-CSS mit .widget_217 { ... } (Selektor mit Wrapper)

Der Editor erwartet nur den Wrapper-Inhalt. Siehe Custom-CSS-Konvention › Haeufige Fehler.

Siehe auch