Initial commit: WitShop Laravel Online-Shop

- Laravel 11, Blade, Alpine.js, Tailwind CSS
- Produkt- & Kategorieverwaltung mit Varianten
- Checkout (Vorkasse + Rechnung), Session-Warenkorb
- Admin-Backend (Dashboard, Bestellungen, Kunden, CMS)
- DSGVO-konform: Cookie-Consent, Impressum, Widerruf, AGB
- PDF-Rechnung via dompdf, SMTP-Mailversand
- Kundenverwaltung mit manueller Freischaltung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 00:34:09 +02:00
co-authored by Claude Sonnet 4.6
commit 41078fdc08
208 changed files with 21725 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
@extends('layouts.admin')
@section('title', isset($page) ? 'Seite bearbeiten' : 'Neue Seite')
@push('head')
{{-- Quill WYSIWYG --}}
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
<style>
.ql-container { font-size: 14px; min-height: 320px; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; }
.ql-toolbar { border-top-left-radius: 6px; border-top-right-radius: 6px; background: #f9fafb; }
.ql-editor { min-height: 320px; line-height: 1.7; }
.ql-editor h1 { font-size: 1.8em; font-weight: 700; margin-bottom: .5em; }
.ql-editor h2 { font-size: 1.4em; font-weight: 600; margin-bottom: .4em; }
.ql-editor h3 { font-size: 1.15em; font-weight: 600; margin-bottom: .3em; }
.ql-editor a { color: #2563eb; text-decoration: underline; }
.ql-editor blockquote { border-left: 4px solid #d1d5db; padding-left: 1em; color: #6b7280; font-style: italic; }
.ql-editor pre { background: #1e293b; color: #e2e8f0; padding: 1em; border-radius: 6px; font-family: monospace; overflow-x: auto; }
.ql-editor ul, .ql-editor ol { padding-left: 1.5em; }
.ql-editor img { max-width: 100%; border-radius: 4px; }
</style>
@endpush
@section('content')
<div class="max-w-4xl">
<form id="page-form"
method="POST"
action="{{ isset($page) ? route('admin.pages.update', $page) : route('admin.pages.store') }}"
enctype="multipart/form-data"
class="space-y-4">
@csrf @if(isset($page)) @method('PUT') @endif
{{-- Metadaten --}}
<div class="bg-white rounded shadow p-6 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Titel *</label>
<input type="text" name="title" id="title-input"
value="{{ old('title', $page->title ?? '') }}"
class="w-full border rounded px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium mb-1">
Slug (URL)
<span class="text-xs text-gray-400 font-normal">– automatisch aus Titel</span>
</label>
<div class="flex items-center border rounded overflow-hidden">
<span class="px-2 py-2 bg-gray-50 text-gray-400 text-xs border-r select-none">/seite/</span>
<input type="text" name="slug" id="slug-input"
value="{{ old('slug', $page->slug ?? '') }}"
class="flex-1 px-3 py-2 text-sm font-mono focus:outline-none"
placeholder="wird-automatisch-generiert">
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Typ</label>
<select name="type" class="w-full border rounded px-3 py-2 text-sm">
<option value="static" {{ old('type', $page->type ?? 'static') === 'static' ? 'selected' : '' }}>Statische Seite (Impressum, AGB, …)</option>
<option value="blog" {{ old('type', $page->type ?? '') === 'blog' ? 'selected' : '' }}>Blog-Beitrag</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Titelbild</label>
@if(isset($page) && $page->image)
<img src="{{ asset('storage/'.$page->image) }}" class="h-10 mb-1 rounded">
@endif
<input type="file" name="image" accept="image/*" class="text-sm">
</div>
</div>
</div>
{{-- WYSIWYG Editor --}}
<div class="bg-white rounded shadow overflow-hidden">
<div class="px-5 py-3 border-b flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700">Inhalt</span>
<button type="button" id="toggle-source"
class="text-xs px-3 py-1 border rounded hover:bg-gray-50 text-gray-500">
HTML-Quellcode
</button>
</div>
{{-- Quill Editor --}}
<div id="quill-editor" class="border-0">
{!! old('content', $page->content ?? '') !!}
</div>
{{-- Roher HTML-Quellcode (Toggle) --}}
<textarea id="source-editor" name="_content_source" rows="16"
class="hidden w-full px-4 py-3 text-sm font-mono border-0 focus:outline-none resize-none"
style="min-height: 320px">{{ old('content', $page->content ?? '') }}</textarea>
{{-- Verstecktes Feld das beim Submit gefüllt wird --}}
<input type="hidden" name="content" id="content-hidden"
value="{{ old('content', $page->content ?? '') }}">
</div>
{{-- SEO & Optionen --}}
<div class="bg-white rounded shadow p-6 space-y-4">
<h3 class="text-sm font-semibold text-gray-700">SEO & Einstellungen</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Meta-Titel <span class="text-xs text-gray-400 font-normal">(Browser-Tab)</span></label>
<input type="text" name="meta_title"
value="{{ old('meta_title', $page->meta_title ?? '') }}"
class="w-full border rounded px-3 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium mb-1">Meta-Beschreibung <span class="text-xs text-gray-400 font-normal">(Suchergebnisse)</span></label>
<input type="text" name="meta_description"
value="{{ old('meta_description', $page->meta_description ?? '') }}"
class="w-full border rounded px-3 py-2 text-sm"
maxlength="160">
</div>
<div>
<label class="block text-sm font-medium mb-1">Veröffentlichungsdatum</label>
<input type="date" name="published_at"
value="{{ old('published_at', isset($page->published_at) ? $page->published_at->format('Y-m-d') : '') }}"
class="w-full border rounded px-3 py-2 text-sm">
</div>
<div class="flex items-end pb-2">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" name="published" value="1"
{{ old('published', $page->published ?? true) ? 'checked' : '' }}
class="w-4 h-4 accent-blue-600">
<span>Veröffentlicht</span>
</label>
</div>
</div>
<hr class="border-gray-100">
<h3 class="text-sm font-semibold text-gray-700">Navigation</h3>
<div class="grid grid-cols-3 gap-4">
<label class="flex items-start gap-2 text-sm cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input type="checkbox" name="show_in_footer" value="1"
{{ old('show_in_footer', $page->show_in_footer ?? true) ? 'checked' : '' }}
class="mt-0.5 accent-blue-600">
<div>
<span class="font-medium">Footer</span>
<p class="text-xs text-gray-400 mt-0.5">Link in der Fußzeile des Shops</p>
</div>
</label>
<label class="flex items-start gap-2 text-sm cursor-pointer p-3 border rounded-lg hover:bg-gray-50">
<input type="checkbox" name="show_in_nav" value="1"
{{ old('show_in_nav', $page->show_in_nav ?? false) ? 'checked' : '' }}
class="mt-0.5 accent-blue-600">
<div>
<span class="font-medium">Hauptmenü</span>
<p class="text-xs text-gray-400 mt-0.5">Link in der Top-Navigation</p>
</div>
</label>
<div>
<label class="block text-sm font-medium mb-1">Reihenfolge im Menü</label>
<input type="number" name="nav_sort" value="{{ old('nav_sort', $page->nav_sort ?? 0) }}"
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>
</div>
@if($errors->any())
<div class="bg-red-50 border border-red-200 rounded px-4 py-3 text-sm text-red-700">
@foreach($errors->all() as $e)<p>• {{ $e }}</p>@endforeach
</div>
@endif
<div class="flex gap-3 pb-8">
<button type="submit" class="bg-blue-600 text-white px-6 py-2.5 rounded-lg hover:bg-blue-700 text-sm font-medium">
Speichern
</button>
<a href="{{ route('admin.pages.index') }}"
class="px-6 py-2.5 rounded-lg border text-sm hover:bg-gray-50">
Abbrechen
</a>
</div>
</form>
</div>
<script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>
<script>
// ── Quill initialisieren ────────────────────────────────────────────────────
const quill = new Quill('#quill-editor', {
theme: 'snow',
modules: {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
['blockquote', 'code-block'],
['link', 'image'],
['clean']
]
},
placeholder: 'Seiteninhalt hier eingeben …',
});
// ── Slug automatisch aus Titel generieren ───────────────────────────────────
const titleInput = document.getElementById('title-input');
const slugInput = document.getElementById('slug-input');
let slugManuallyEdited = slugInput.value.length > 0;
titleInput.addEventListener('input', () => {
if (slugManuallyEdited) return;
slugInput.value = titleInput.value
.toLowerCase()
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
});
slugInput.addEventListener('input', () => { slugManuallyEdited = true; });
// ── HTML-Quellcode-Toggle ───────────────────────────────────────────────────
const toggleBtn = document.getElementById('toggle-source');
const sourceEditor = document.getElementById('source-editor');
const quillWrapper = document.getElementById('quill-editor');
let sourceMode = false;
toggleBtn.addEventListener('click', () => {
sourceMode = !sourceMode;
if (sourceMode) {
// Quill → HTML
sourceEditor.value = quill.root.innerHTML === '<p><br></p>' ? '' : quill.root.innerHTML;
quillWrapper.style.display = 'none';
sourceEditor.classList.remove('hidden');
toggleBtn.textContent = 'WYSIWYG';
toggleBtn.classList.add('bg-yellow-50', 'text-yellow-700', 'border-yellow-300');
} else {
// HTML → Quill
quill.root.innerHTML = sourceEditor.value;
sourceEditor.classList.add('hidden');
quillWrapper.style.display = '';
toggleBtn.textContent = 'HTML-Quellcode';
toggleBtn.classList.remove('bg-yellow-50', 'text-yellow-700', 'border-yellow-300');
}
});
// ── Beim Absenden: Quill-HTML in hidden field schreiben ─────────────────────
document.getElementById('page-form').addEventListener('submit', () => {
const html = sourceMode
? sourceEditor.value
: (quill.root.innerHTML === '<p><br></p>' ? '' : quill.root.innerHTML);
document.getElementById('content-hidden').value = html;
});
</script>
@push('styles')
<style>
/* Quill-Bilder klickbar im Editor */
.ql-editor img { cursor: pointer; }
</style>
@endpush
@endsection
@@ -0,0 +1,29 @@
@extends('layouts.admin')
@section('title', 'Seiten')
@section('content')
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-semibold">Seiten</h2>
<a href="{{ route('admin.pages.create') }}" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm">+ Neue Seite</a>
</div>
<div class="bg-white rounded shadow overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-left"><tr><th class="px-4 py-3">Titel</th><th class="px-4 py-3">Slug</th><th class="px-4 py-3">Typ</th><th class="px-4 py-3">Veröffentlicht</th><th class="px-4 py-3"></th></tr></thead>
<tbody class="divide-y">
@forelse($pages as $page)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium">{{ $page->title }}</td>
<td class="px-4 py-3 font-mono text-gray-500">/seite/{{ $page->slug }}</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs bg-gray-100">{{ $page->type }}</span></td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs {{ $page->published ? 'bg-green-100 text-green-700' : 'bg-gray-100' }}">{{ $page->published ? 'Ja' : 'Nein' }}</span></td>
<td class="px-4 py-3 text-right space-x-2">
<a href="{{ route('admin.pages.edit', $page) }}" class="text-blue-600 hover:underline">Bearbeiten</a>
<form method="POST" action="{{ route('admin.pages.destroy', $page) }}" class="inline" onsubmit="return confirm('Löschen?')">@csrf @method('DELETE')<button class="text-red-600 hover:underline">Löschen</button></form>
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400">Keine Seiten.</td></tr>
@endforelse
</tbody>
</table>
</div>
@endsection