Add email templates, page layouts, and shop themes (template system)
Deploy to Production (witstore.wende.it) / deploy (push) Successful in 7s

- E-Mail-Templates: DB-gespeicherte HTML-Templates mit {{placeholder}}-System,
  Admin-UI zum Bearbeiten von Betreff und Body, alle 4 Mailables aktualisiert
- Seiten-Layouts: 4 Layouts für CMS-Seiten (Standard, Sidebar, Volle Breite, Landing),
  Layout-Auswahl im Admin-Formular
- Shop-Themes: 5 Farbthemen via CSS-Variablen, Picker in Einstellungen
- Deployment: EmailTemplateSeeder wird automatisch beim Deploy ausgeführt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 22:04:54 +02:00
co-authored by Claude Sonnet 4.6
parent 4f42b64f24
commit 5dad8b9324
23 changed files with 700 additions and 55 deletions
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\EmailTemplate;
use Illuminate\Http\Request;
class EmailTemplateController extends Controller
{
public function index()
{
$templates = EmailTemplate::orderBy('label')->get();
return view('admin.email-templates.index', compact('templates'));
}
public function edit(EmailTemplate $emailTemplate)
{
return view('admin.email-templates.edit', ['template' => $emailTemplate]);
}
public function update(Request $request, EmailTemplate $emailTemplate)
{
$request->validate([
'subject' => 'required|string|max:255',
'body' => 'required|string',
]);
$emailTemplate->update([
'subject' => $request->input('subject'),
'body' => $request->input('body'),
]);
return back()->with('success', 'E-Mail-Template gespeichert.');
}
}
@@ -59,11 +59,13 @@ class PageController extends Controller
'show_in_footer' => 'boolean',
'show_in_nav' => 'boolean',
'nav_sort' => 'integer|min:0',
'layout' => 'nullable|in:default,sidebar,full,landing',
]);
$data['slug'] = $data['slug'] ? Str::slug($data['slug']) : Str::slug($data['title']);
$data['published'] = $request->boolean('published');
$data['show_in_footer'] = $request->boolean('show_in_footer');
$data['show_in_nav'] = $request->boolean('show_in_nav');
$data['layout'] = $request->input('layout', 'default');
return $data;
}
}
@@ -13,6 +13,7 @@ class SettingsController extends Controller
'bank_name','bank_iban','bank_bic','invoice_payment_days',
'smtp_host','smtp_port','smtp_user','smtp_password','smtp_from_name','smtp_from_email',
'cookie_consent','google_analytics','require_customer_approval',
'theme_name',
];
public function index()
+66 -2
View File
@@ -2,6 +2,7 @@
namespace App\Mail;
use App\Models\EmailTemplate;
use App\Models\Order;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
@@ -16,8 +17,71 @@ class OrderConfirmationMail extends Mailable
public function build()
{
$order = $this->order;
$template = EmailTemplate::getByKey('order_confirmation');
if (!$template) {
return $this
->subject('Bestellbestätigung ' . $order->order_number . ' – ' . Setting::get('shop_name'))
->view('emails.order_confirmation');
}
$itemRows = '';
foreach ($order->items as $item) {
$variant = $item->variant_label ? ' <small>(' . e($item->variant_label) . ')</small>' : '';
$itemRows .= '<tr>'
. '<td style="padding:8px;border-bottom:1px solid #eee">' . e($item->product_name) . $variant . '</td>'
. '<td style="padding:8px;border-bottom:1px solid #eee">' . $item->quantity . '</td>'
. '<td style="padding:8px;border-bottom:1px solid #eee">' . number_format($item->unit_price, 2, ',', '.') . ' €</td>'
. '<td style="padding:8px;border-bottom:1px solid #eee">' . number_format($item->line_total, 2, ',', '.') . ' €</td>'
. '</tr>';
}
$itemsTable = '<table style="width:100%;border-collapse:collapse">'
. '<thead><tr>'
. '<th style="padding:8px;background:#f8fafc;text-align:left">Artikel</th>'
. '<th style="padding:8px;background:#f8fafc;text-align:left">Menge</th>'
. '<th style="padding:8px;background:#f8fafc;text-align:left">Einzelpreis</th>'
. '<th style="padding:8px;background:#f8fafc;text-align:left">Gesamt</th>'
. '</tr></thead><tbody>' . $itemRows . '</tbody>'
. '<tfoot>'
. '<tr><td colspan="3" style="padding:8px">Zwischensumme</td><td style="padding:8px">' . number_format($order->subtotal, 2, ',', '.') . ' €</td></tr>'
. '<tr><td colspan="3" style="padding:8px">Versand</td><td style="padding:8px">' . number_format($order->shipping_cost, 2, ',', '.') . ' €</td></tr>'
. '<tr style="font-weight:bold"><td colspan="3" style="padding:8px">Gesamt (inkl. MwSt.)</td><td style="padding:8px">' . number_format($order->total, 2, ',', '.') . ' €</td></tr>'
. '</tfoot></table>';
if ($order->payment_method === 'prepayment') {
$paymentInfo = '<div style="background:#f8fafc;padding:16px;border-radius:8px;margin:16px 0">'
. '<strong>Bitte überweisen Sie den Betrag auf folgendes Konto:</strong><br>'
. 'Empfänger: ' . e(Setting::get('shop_owner')) . '<br>'
. 'Bank: ' . e(Setting::get('bank_name')) . '<br>'
. 'IBAN: ' . e(Setting::get('bank_iban')) . '<br>'
. 'BIC: ' . e(Setting::get('bank_bic')) . '<br>'
. 'Verwendungszweck: <strong>' . e($order->order_number) . '</strong><br>'
. 'Betrag: <strong>' . number_format($order->total, 2, ',', '.') . ' €</strong>'
. '</div>';
} else {
$days = Setting::get('invoice_payment_days', 14);
$paymentInfo = '<div style="background:#f8fafc;padding:16px;border-radius:8px;margin:16px 0">'
. 'Sie erhalten Ihre Rechnung separat per E-Mail.<br>'
. 'Zahlungsziel: ' . $days . ' Tage nach Rechnungserhalt.'
. '</div>';
}
$customerName = $order->billingAddress()?->full_name ?? $order->guest_email;
$data = [
'customer_name' => $customerName,
'order_number' => $order->order_number,
'order_total' => number_format($order->total, 2, ',', '.'),
'payment_info' => $paymentInfo,
'order_items_table' => $itemsTable,
'shop_name' => Setting::get('shop_name'),
'shop_email' => Setting::get('shop_email'),
'shop_address' => Setting::get('shop_address'),
];
return $this
->subject('Bestellbestätigung ' . $this->order->order_number . ' – ' . Setting::get('shop_name'))
->view('emails.order_confirmation');
->subject($template->renderSubject($data))
->html($template->renderBody($data));
}
}
+27 -2
View File
@@ -2,6 +2,7 @@
namespace App\Mail;
use App\Models\EmailTemplate;
use App\Models\Order;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
@@ -16,8 +17,32 @@ class OrderShippedMail extends Mailable
public function build()
{
$order = $this->order;
$template = EmailTemplate::getByKey('order_shipped');
if (!$template) {
return $this
->subject('Ihre Bestellung wurde versendet – ' . $order->order_number)
->view('emails.order_shipped');
}
$trackingInfo = $order->tracking_code
? '<div style="background:#f0fdf4;padding:16px;border-radius:8px;border:1px solid #86efac;margin:16px 0">'
. '<strong>Sendungsverfolgung:</strong><br>'
. 'Tracking-Nummer: <strong>' . e($order->tracking_code) . '</strong>'
. '</div>'
: '';
$data = [
'order_number' => $order->order_number,
'tracking_code' => $order->tracking_code ?? '',
'tracking_info' => $trackingInfo,
'shop_name' => Setting::get('shop_name'),
'shop_email' => Setting::get('shop_email'),
];
return $this
->subject('Ihre Bestellung wurde versendet – ' . $this->order->order_number)
->view('emails.order_shipped');
->subject($template->renderSubject($data))
->html($template->renderBody($data));
}
}
+23 -2
View File
@@ -2,6 +2,7 @@
namespace App\Mail;
use App\Models\EmailTemplate;
use App\Models\Order;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
@@ -20,9 +21,29 @@ class PaymentReceivedMail extends Mailable
public function build()
{
$order = $this->order;
$template = EmailTemplate::getByKey('payment_received');
if (!$template) {
return $this
->subject('Ihre Rechnung – ' . $order->order_number . ' – ' . Setting::get('shop_name'))
->view('emails.payment_received')
->attachData($this->pdfContent, $this->pdfFilename, ['mime' => 'application/pdf']);
}
$customerName = $order->billingAddress()?->full_name ?? $order->guest_email;
$data = [
'customer_name' => $customerName,
'order_number' => $order->order_number,
'order_total' => number_format($order->total, 2, ',', '.'),
'shop_name' => Setting::get('shop_name'),
'shop_email' => Setting::get('shop_email'),
];
return $this
->subject('Ihre Rechnung – ' . $this->order->order_number . ' – ' . Setting::get('shop_name'))
->view('emails.payment_received')
->subject($template->renderSubject($data))
->html($template->renderBody($data))
->attachData($this->pdfContent, $this->pdfFilename, ['mime' => 'application/pdf']);
}
}
+27 -4
View File
@@ -2,6 +2,7 @@
namespace App\Mail;
use App\Models\EmailTemplate;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
@@ -18,10 +19,32 @@ class WiderrufsformularMail extends Mailable
public function build()
{
$subject = $this->isConfirmation
? 'Bestätigung Ihres Widerrufs – ' . Setting::get('shop_name')
: 'Neues Widerrufsformular eingegangen';
if (!$this->isConfirmation) {
$subject = 'Neues Widerrufsformular eingegangen';
return $this->subject($subject)->view('emails.widerruf');
}
return $this->subject($subject)->view('emails.widerruf');
$template = EmailTemplate::getByKey('widerruf_confirmation');
if (!$template) {
return $this
->subject('Bestätigung Ihres Widerrufs – ' . Setting::get('shop_name'))
->view('emails.widerruf');
}
$vars = [
'customer_name' => $this->data['name'],
'widerruf_name' => $this->data['name'],
'widerruf_email' => $this->data['email'],
'widerruf_order_number' => $this->data['order_number'] ?? '—',
'widerruf_product' => $this->data['product'],
'widerruf_date' => $this->data['date'],
'widerruf_reason' => $this->data['reason'] ?? '—',
'shop_name' => Setting::get('shop_name'),
];
return $this
->subject($template->renderSubject($vars))
->html($template->renderBody($vars));
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EmailTemplate extends Model
{
protected $fillable = ['key', 'label', 'subject', 'body', 'variables'];
protected $casts = ['variables' => 'array'];
public static function getByKey(string $key): ?self
{
return static::where('key', $key)->first();
}
public function renderBody(array $data): string
{
$body = $this->body;
foreach ($data as $k => $v) {
$body = str_replace('{{' . $k . '}}', (string) $v, $body);
}
return $body;
}
public function renderSubject(array $data): string
{
$subject = $this->subject;
foreach ($data as $k => $v) {
$subject = str_replace('{{' . $k . '}}', (string) $v, $subject);
}
return $subject;
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class Page extends Model
{
protected $fillable = ['slug','title','content','type','image','meta_title','meta_description','published','published_at','show_in_footer','show_in_nav','nav_sort'];
protected $fillable = ['slug','title','content','type','image','meta_title','meta_description','published','published_at','show_in_footer','show_in_nav','nav_sort','layout'];
protected $casts = ['published' => 'boolean', 'published_at' => 'datetime', 'show_in_footer' => 'boolean', 'show_in_nav' => 'boolean'];
}