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,48 @@
@extends('layouts.admin')
@section('title', 'E-Mail-Template: ' . $template->label)
@section('content')
<div class="flex items-center gap-3 mb-6">
<a href="{{ route('admin.email-templates.index') }}" class="text-gray-400 hover:text-gray-600">← Zurück</a>
<h2 class="text-xl font-bold">{{ $template->label }}</h2>
</div>
@if(session('success'))
<div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded mb-4">{{ session('success') }}</div>
@endif
<form method="POST" action="{{ route('admin.email-templates.update', $template) }}" class="space-y-5">
@csrf @method('PUT')
<div class="bg-white rounded shadow p-6 space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Betreff</label>
<input type="text" name="subject" value="{{ old('subject', $template->subject) }}"
class="w-full border rounded px-3 py-2 text-sm @error('subject') border-red-500 @enderror">
@error('subject')<p class="text-red-600 text-xs mt-1">{{ $message }}</p>@enderror
</div>
<div>
<label class="block text-sm font-medium mb-1">HTML-Inhalt</label>
<textarea name="body" rows="24"
class="w-full border rounded px-3 py-2 text-sm font-mono @error('body') border-red-500 @enderror">{{ old('body', $template->body) }}</textarea>
@error('body')<p class="text-red-600 text-xs mt-1">{{ $message }}</p>@enderror
</div>
</div>
@if($template->variables)
<div class="bg-blue-50 border border-blue-200 rounded p-4 text-sm">
<p class="font-medium text-blue-800 mb-2">Verfügbare Platzhalter:</p>
<div class="flex flex-wrap gap-2">
@foreach($template->variables as $var)
<code class="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">{{"{{"}}{{ $var }}{{"}}"}}</code>
@endforeach
</div>
</div>
@endif
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700">Speichern</button>
<a href="{{ route('admin.email-templates.index') }}" class="border px-6 py-2 rounded hover:bg-gray-50 text-sm">Abbrechen</a>
</div>
</form>
@endsection
@@ -0,0 +1,43 @@
@extends('layouts.admin')
@section('title', 'E-Mail-Templates')
@section('content')
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">E-Mail-Templates</h2>
</div>
@if(session('success'))
<div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded mb-4">{{ session('success') }}</div>
@endif
@if($templates->isEmpty())
<div class="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded">
Keine Templates gefunden. Bitte führen Sie <code>php artisan db:seed --class=EmailTemplateSeeder</code> aus.
</div>
@else
<div class="bg-white rounded shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 uppercase text-xs">
<tr>
<th class="px-4 py-3 text-left">Template</th>
<th class="px-4 py-3 text-left">Betreff</th>
<th class="px-4 py-3 text-left">Zuletzt geändert</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($templates as $tpl)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium">{{ $tpl->label }}</td>
<td class="px-4 py-3 text-gray-600 max-w-xs truncate">{{ $tpl->subject }}</td>
<td class="px-4 py-3 text-gray-500">{{ $tpl->updated_at->format('d.m.Y H:i') }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('admin.email-templates.edit', $tpl) }}"
class="text-blue-600 hover:underline">Bearbeiten</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@endsection
@@ -152,6 +152,14 @@
class="w-full border rounded px-3 py-2 text-sm" min="0">
<p class="text-xs text-gray-400 mt-1">Niedrigere Zahl = weiter links</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Seiten-Layout</label>
<select name="layout" class="w-full border rounded px-3 py-2 text-sm">
@foreach(['default'=>'Standard (zentriert)','sidebar'=>'Mit Sidebar','full'=>'Volle Breite','landing'=>'Landing Page (ohne Header/Footer)'] as $val => $label)
<option value="{{ $val }}" {{ old('layout', $page->layout ?? 'default') === $val ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
</div>
+1
View File
@@ -29,6 +29,7 @@
<a href="{{ route('admin.faqs.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.faqs.*') ? 'bg-gray-700' : '' }}">FAQ</a>
<div class="pt-2 pb-1 text-xs text-gray-400 uppercase tracking-wide">System</div>
<a href="{{ route('admin.admins.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.admins.*') ? 'bg-gray-700' : '' }}">Admin-Benutzer</a>
<a href="{{ route('admin.email-templates.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.email-templates.*') ? 'bg-gray-700' : '' }}">E-Mail-Templates</a>
<a href="{{ route('admin.settings.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.settings.*') ? 'bg-gray-700' : '' }}">Einstellungen</a>
</nav>
<div class="p-4 border-t border-gray-700 text-xs text-gray-400">
+69 -42
View File
@@ -6,6 +6,33 @@
<title>@yield('title', \App\Models\Setting::get('shop_name', 'Shop'))</title>
<meta name="description" content="@yield('meta_description', '')">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@php
$themes = config('themes');
$themeName = \App\Models\Setting::get('theme_name', 'default');
$theme = $themes[$themeName] ?? $themes['default'];
@endphp
<style>
:root {
--primary: {{ $theme['primary'] }};
--primary-dark: {{ $theme['dark'] }};
--primary-light: {{ $theme['light'] }};
--primary-text: {{ $theme['text'] }};
}
.btn-primary { background-color: var(--primary); color: #fff; }
.btn-primary:hover { background-color: var(--primary-dark); }
.text-primary { color: var(--primary); }
.bg-primary { background-color: var(--primary); }
.bg-primary-dark { background-color: var(--primary-dark); }
.bg-primary-light { background-color: var(--primary-light); }
.border-primary { border-color: var(--primary); }
.hover-primary:hover { background-color: var(--primary-light); color: var(--primary-text); }
.cat-bar { background-color: var(--primary); }
.cat-bar a { color: rgba(255,255,255,0.85); }
.cat-bar a:hover { background-color: var(--primary-dark); color: #fff; }
.cat-bar a.active { background-color: var(--primary-dark); color: #fff; }
.cart-badge { color: var(--primary); }
.shop-name-text { color: var(--primary); }
</style>
</head>
<body class="bg-gray-50 text-gray-800 flex flex-col min-h-screen">
@@ -15,7 +42,7 @@
<p class="text-sm">Wir verwenden Cookies, um Ihnen die bestmögliche Nutzererfahrung zu bieten. Mit dem Klick auf „Akzeptieren" stimmen Sie der Verwendung aller Cookies zu. <a href="{{ route('page', 'datenschutz') }}" class="underline">Mehr erfahren</a></p>
<div class="flex gap-2 shrink-0">
<button onclick="setCookieConsent(false)" class="px-4 py-2 text-sm border border-gray-500 rounded hover:bg-gray-700">Ablehnen</button>
<button onclick="setCookieConsent(true)" class="px-4 py-2 text-sm bg-blue-500 rounded hover:bg-blue-600">Akzeptieren</button>
<button onclick="setCookieConsent(true)" class="px-4 py-2 text-sm btn-primary rounded">Akzeptieren</button>
</div>
</div>
<script>
@@ -27,73 +54,73 @@
@endif
@php
$legalSlugs = ['impressum','datenschutz','agb','widerrufsbelehrung'];
$navPages = \App\Models\Page::where('published', true)
->where('show_in_nav', true)
->orderBy('nav_sort')
->orderBy('title')
->get();
$legalSlugs = ['impressum','datenschutz','agb','widerrufsbelehrung'];
$navPages = \App\Models\Page::where('published', true)
->where('show_in_nav', true)
->orderBy('nav_sort')
->orderBy('title')
->get();
$navCategories = \App\Models\Category::whereNull('parent_id')
->where('active', true)
->where('show_in_nav', true)
->orderBy('sort_order')
->get();
->where('active', true)
->where('show_in_nav', true)
->orderBy('sort_order')
->get();
@endphp
{{-- Header: Logo-Leiste --}}
<header class="bg-white border-b shadow-sm">
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between gap-4">
{{-- Logo: Bild (wenn hinterlegt) oder Shop-Name als Text --}}
{{-- Logo --}}
<a href="{{ url('/') }}" class="shrink-0 flex items-center">
@php $logo = \App\Models\Setting::get('shop_logo'); @endphp
@if($logo)
<img src="{{ asset('storage/'.$logo) }}" alt="{{ \App\Models\Setting::get('shop_name', 'Shop') }}" class="h-12 w-auto object-contain">
@else
<span class="text-2xl font-bold text-blue-700 tracking-tight">
<span class="text-2xl font-bold tracking-tight shop-name-text">
{{ \App\Models\Setting::get('shop_name', 'Shop') }}
</span>
@endif
</a>
{{-- Haupt-Navigation rechts --}}
{{-- Haupt-Navigation --}}
<nav class="flex items-center gap-1">
<a href="{{ route('shop.products.index') }}"
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition">
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover-primary transition">
Alle Produkte
</a>
@foreach($navCategories as $navCat)
<a href="{{ route('shop.category', $navCat->slug) }}"
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition
{{ request()->route('slug') === $navCat->slug ? 'bg-blue-50 text-blue-700' : '' }}">
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover-primary transition
{{ request()->route('slug') === $navCat->slug ? 'bg-primary-light text-primary-text' : '' }}">
{{ $navCat->name }}
</a>
@endforeach
@foreach($navPages as $navPage)
<a href="{{ route('page', $navPage->slug) }}"
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition
{{ request()->route('slug') === $navPage->slug ? 'bg-blue-50 text-blue-700' : '' }}">
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover-primary transition
{{ request()->route('slug') === $navPage->slug ? 'bg-primary-light text-primary-text' : '' }}">
{{ $navPage->title }}
</a>
@endforeach
@auth
<a href="{{ route('account.index') }}"
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition">
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover-primary transition">
Mein Konto
</a>
@if(auth()->user()->isAdmin())
<a href="{{ route('admin.dashboard') }}"
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition">
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover-primary transition">
Admin
</a>
@endif
@else
<a href="{{ route('login') }}"
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition">
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover-primary transition">
Anmelden
</a>
<a href="{{ route('register') }}"
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-blue-50 hover:text-blue-700 transition">
class="px-3 py-2 rounded-lg text-sm font-medium text-gray-700 hover-primary transition">
Registrieren
</a>
@endauth
@@ -108,14 +135,14 @@
</a>
<a href="{{ route('cart.index') }}"
class="relative ml-2 px-3 py-2 rounded-lg text-sm font-medium bg-blue-700 text-white hover:bg-blue-800 transition flex items-center gap-1.5">
class="relative ml-2 px-3 py-2 rounded-lg text-sm font-medium btn-primary transition flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
Warenkorb
@php $cartCount = array_sum(array_column(session('cart', []), 'qty')); @endphp
@if($cartCount > 0)
<span class="bg-white text-blue-700 text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center leading-none">{{ $cartCount }}</span>
<span class="bg-white cart-badge text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center leading-none">{{ $cartCount }}</span>
@endif
</a>
</nav>
@@ -124,12 +151,12 @@
{{-- Kategorien-Navigation --}}
@php $navCats = \App\Models\Category::whereNull('parent_id')->where('active', true)->orderBy('sort_order')->get(); @endphp
@if($navCats->count())
<div class="bg-blue-700">
<div class="cat-bar">
<div class="max-w-6xl mx-auto px-4 flex gap-1 overflow-x-auto">
@foreach($navCats as $cat)
<a href="{{ route('shop.category', $cat->slug) }}"
class="whitespace-nowrap px-4 py-2.5 text-sm font-medium text-blue-100 hover:text-white hover:bg-blue-600 transition
{{ request()->route('slug') === $cat->slug ? 'bg-blue-800 text-white' : '' }}">
class="whitespace-nowrap px-4 py-2.5 text-sm font-medium transition
{{ request()->route('slug') === $cat->slug ? 'active' : '' }}">
{{ $cat->name }}
</a>
@endforeach
@@ -154,20 +181,20 @@
{{-- Footer --}}
@php
$footerPages = \App\Models\Page::where('published', true)
->where('type', 'static')
->where('show_in_footer', true)
->whereNotIn('slug', $legalSlugs)
->orderBy('title')
->get();
$legalPages = \App\Models\Page::where('published', true)
->where('type', 'static')
->whereIn('slug', $legalSlugs)
->get()
->sortBy(fn($p) => array_search($p->slug, $legalSlugs))
->values();
$hasBlog = \App\Models\Page::where('type','blog')->where('published', true)->exists();
$hasFaq = \App\Models\Faq::exists();
$footerPages = \App\Models\Page::where('published', true)
->where('type', 'static')
->where('show_in_footer', true)
->whereNotIn('slug', $legalSlugs)
->orderBy('title')
->get();
$legalPages = \App\Models\Page::where('published', true)
->where('type', 'static')
->whereIn('slug', $legalSlugs)
->get()
->sortBy(fn($p) => array_search($p->slug, $legalSlugs))
->values();
$hasBlog = \App\Models\Page::where('type','blog')->where('published', true)->exists();
$hasFaq = \App\Models\Faq::exists();
@endphp
<footer class="bg-gray-900 text-gray-400 text-sm mt-12">
<div class="max-w-6xl mx-auto px-4 py-8 grid grid-cols-2 md:grid-cols-4 gap-6">
+50 -2
View File
@@ -1,11 +1,59 @@
@php $layout = $page->layout ?? 'default'; @endphp
@if($layout === 'landing')
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $page->meta_title ?: $page->title }}</title>
<meta name="description" content="{{ $page->meta_description }}">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-gray-50 text-gray-800">
<div class="prose max-w-none text-gray-700 p-8">
{!! $page->content !!}
</div>
</body>
</html>
@else
@extends('layouts.shop')
@section('title', $page->meta_title ?: $page->title)
@section('meta_description', $page->meta_description)
@section('content')
<div class="max-w-3xl mx-auto">
@switch($layout)
@case('full')
<h1 class="text-3xl font-bold mb-6">{{ $page->title }}</h1>
<div class="prose max-w-none text-gray-700">
{!! $page->content !!}
</div>
</div>
@break
@case('sidebar')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<h1 class="text-3xl font-bold mb-6">{{ $page->title }}</h1>
<div class="prose max-w-none text-gray-700">
{!! $page->content !!}
</div>
</div>
<aside class="space-y-6">
@include('shop.partials._sidebar')
</aside>
</div>
@break
@default
<div class="max-w-3xl mx-auto">
<h1 class="text-3xl font-bold mb-6">{{ $page->title }}</h1>
<div class="prose max-w-none text-gray-700">
{!! $page->content !!}
</div>
</div>
@endswitch
@endsection
@endif
@@ -0,0 +1,39 @@
{{-- Kategorien --}}
@php
$sidebarCats = \App\Models\Category::whereNull('parent_id')->where('active', true)->orderBy('sort_order')->get();
$sidebarBlogPages = \App\Models\Page::where('type','blog')->where('published',true)->orderByDesc('published_at')->limit(5)->get();
@endphp
@if($sidebarCats->count())
<div class="bg-white rounded shadow p-4">
<h3 class="font-semibold mb-3 text-gray-800">Kategorien</h3>
<ul class="space-y-1">
@foreach($sidebarCats as $cat)
<li>
<a href="{{ route('shop.category', $cat->slug) }}"
class="block px-2 py-1.5 rounded text-sm text-gray-700 hover-primary transition">
{{ $cat->name }}
</a>
</li>
@endforeach
</ul>
</div>
@endif
@if($sidebarBlogPages->count())
<div class="bg-white rounded shadow p-4">
<h3 class="font-semibold mb-3 text-gray-800">Aktuelle Beiträge</h3>
<ul class="space-y-3">
@foreach($sidebarBlogPages as $bp)
<li>
<a href="{{ route('page', $bp->slug) }}" class="text-sm text-primary hover:underline">
{{ $bp->title }}
</a>
@if($bp->published_at)
<p class="text-xs text-gray-400">{{ $bp->published_at->format('d.m.Y') }}</p>
@endif
</li>
@endforeach
</ul>
</div>
@endif