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
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', 'Admin') — {{ \App\Models\Setting::get('shop_name', 'Shop') }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@stack('head')
</head>
<body class="bg-gray-100 text-gray-800 min-h-screen flex">
{{-- Sidebar --}}
<aside class="w-64 bg-gray-900 text-white flex flex-col shrink-0">
<div class="p-4 text-lg font-bold border-b border-gray-700">
🛒 Admin
</div>
<nav class="flex-1 p-4 space-y-1 text-sm">
<a href="{{ route('admin.dashboard') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.dashboard') ? 'bg-gray-700' : '' }}">Dashboard</a>
<div class="pt-2 pb-1 text-xs text-gray-400 uppercase tracking-wide">Sortiment</div>
<a href="{{ route('admin.categories.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.categories.*') ? 'bg-gray-700' : '' }}">Kategorien</a>
<a href="{{ route('admin.products.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.products.*') ? 'bg-gray-700' : '' }}">Artikel</a>
<a href="{{ route('admin.attributes.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.attributes.*') ? 'bg-gray-700' : '' }}">Attribute</a>
<div class="pt-2 pb-1 text-xs text-gray-400 uppercase tracking-wide">Kunden & Bestellungen</div>
<a href="{{ route('admin.customers.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.customers.*') ? 'bg-gray-700' : '' }}">Kunden</a>
<a href="{{ route('admin.orders.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.orders.*') ? 'bg-gray-700' : '' }}">Bestellungen</a>
<div class="pt-2 pb-1 text-xs text-gray-400 uppercase tracking-wide">CMS</div>
<a href="{{ route('admin.pages.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.pages.*') ? 'bg-gray-700' : '' }}">Seiten</a>
<a href="{{ route('admin.banners.index') }}" class="block px-3 py-2 rounded hover:bg-gray-700 {{ request()->routeIs('admin.banners.*') ? 'bg-gray-700' : '' }}">Banner</a>
<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.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">
{{ auth()->user()->name }}<br>
<form method="POST" action="{{ route('logout') }}">@csrf
<button class="text-gray-400 hover:text-white mt-1">Abmelden</button>
</form>
</div>
</aside>
{{-- Main --}}
<div class="flex-1 flex flex-col overflow-hidden">
<header class="bg-white shadow px-6 py-3 flex items-center justify-between">
<h1 class="text-xl font-semibold">@yield('title', 'Admin')</h1>
<a href="{{ url('/') }}" class="text-sm text-blue-600 hover:underline" target="_blank">Shop ansehen</a>
</header>
<main class="flex-1 overflow-y-auto p-6">
@if(session('success'))
<div class="mb-4 bg-green-100 text-green-800 px-4 py-3 rounded">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-4 bg-red-100 text-red-800 px-4 py-3 rounded">{{ session('error') }}</div>
@endif
@yield('content')
</main>
</div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
@include('layouts.navigation')
<!-- Page Heading -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endisset
<!-- Page Content -->
<main>
{{ $slot }}
</main>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans text-gray-900 antialiased">
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
<div>
<a href="/">
<x-application-logo class="w-20 h-20 fill-current text-gray-500" />
</a>
</div>
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
{{ $slot }}
</div>
</div>
</body>
</html>
@@ -0,0 +1,100 @@
<nav x-data="{ open: false }" class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}">
<x-application-logo class="block h-9 w-auto fill-current text-gray-800" />
</a>
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
</div>
</div>
<!-- Settings Dropdown -->
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150">
<div>{{ Auth::user()->name }}</div>
<div class="ms-1">
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</button>
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-dropdown-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-responsive-nav-link>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="px-4">
<div class="font-medium text-base text-gray-800">{{ Auth::user()->name }}</div>
<div class="font-medium text-sm text-gray-500">{{ Auth::user()->email }}</div>
</div>
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')">
{{ __('Profile') }}
</x-responsive-nav-link>
<!-- Authentication -->
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')"
onclick="event.preventDefault();
this.closest('form').submit();">
{{ __('Log Out') }}
</x-responsive-nav-link>
</form>
</div>
</div>
</div>
</nav>
+215
View File
@@ -0,0 +1,215 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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'])
</head>
<body class="bg-gray-50 text-gray-800 flex flex-col min-h-screen">
{{-- Cookie-Consent Banner --}}
@if(\App\Models\Setting::get('cookie_consent') && !Cookie::has('cookie_consent'))
<div id="cookie-banner" class="fixed bottom-0 left-0 right-0 bg-gray-900 text-white p-4 z-50 flex flex-col sm:flex-row items-center justify-between gap-3">
<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>
</div>
</div>
<script>
function setCookieConsent(accept) {
document.cookie = 'cookie_consent=' + (accept ? '1' : '0') + '; path=/; max-age=31536000';
document.getElementById('cookie-banner').remove();
}
</script>
@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();
@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 --}}
<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">
{{ \App\Models\Setting::get('shop_name', 'Shop') }}
</span>
@endif
</a>
{{-- Haupt-Navigation rechts --}}
<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">
Alle Produkte
</a>
@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' : '' }}">
{{ $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">
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">
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">
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">
Registrieren
</a>
@endauth
<a href="{{ route('widerruf') }}"
class="px-3 py-2 rounded-lg text-sm font-medium border border-gray-300 text-gray-600 hover:border-blue-400 hover:text-blue-700 transition flex items-center gap-1.5"
title="Widerrufsformular">
<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 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
Widerruf
</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">
<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>
@endif
</a>
</nav>
</div>
{{-- 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="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' : '' }}">
{{ $cat->name }}
</a>
@endforeach
</div>
</div>
@endif
</header>
{{-- Flash Messages --}}
<div class="max-w-6xl mx-auto px-4 mt-4 w-full">
@if(session('success'))
<div class="bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded-lg mb-3">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="bg-red-100 border border-red-300 text-red-800 px-4 py-3 rounded-lg mb-3">{{ session('error') }}</div>
@endif
</div>
<main class="flex-1 max-w-6xl mx-auto px-4 py-6 w-full">
@yield('content')
</main>
{{-- 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();
@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">
<div>
@if($logo)
<img src="{{ asset('storage/'.$logo) }}" alt="{{ \App\Models\Setting::get('shop_name') }}" class="h-8 w-auto object-contain mb-2 brightness-0 invert opacity-70">
@else
<h3 class="text-white font-semibold mb-2">{{ \App\Models\Setting::get('shop_name') }}</h3>
@endif
<p class="text-xs leading-relaxed">{{ \App\Models\Setting::get('shop_address') }}</p>
</div>
<div>
<h3 class="text-white font-semibold mb-2">Service</h3>
<ul class="space-y-1">
@if($hasBlog)
<li><a href="{{ route('blog') }}" class="hover:text-white transition">Blog / News</a></li>
@endif
@if($hasFaq)
<li><a href="{{ route('faq') }}" class="hover:text-white transition">FAQ</a></li>
@endif
<li><a href="{{ route('widerruf') }}" class="hover:text-white transition">Widerruf</a></li>
<li><a href="{{ route('order.track') }}" class="hover:text-white transition">Bestellung verfolgen</a></li>
@foreach($footerPages as $fp)
<li><a href="{{ route('page', $fp->slug) }}" class="hover:text-white transition">{{ $fp->title }}</a></li>
@endforeach
</ul>
</div>
<div>
<h3 class="text-white font-semibold mb-2">Rechtliches</h3>
<ul class="space-y-1">
@forelse($legalPages as $lp)
<li><a href="{{ route('page', $lp->slug) }}" class="hover:text-white transition">{{ $lp->title }}</a></li>
@empty
<li class="text-gray-600 text-xs">Noch keine Seiten angelegt.</li>
@endforelse
</ul>
</div>
<div>
<h3 class="text-white font-semibold mb-2">Konto</h3>
<ul class="space-y-1">
@auth
<li><a href="{{ route('account.index') }}" class="hover:text-white transition">Mein Konto</a></li>
<li><a href="{{ route('account.orders') }}" class="hover:text-white transition">Bestellungen</a></li>
@else
<li><a href="{{ route('login') }}" class="hover:text-white transition">Anmelden</a></li>
<li><a href="{{ route('register') }}" class="hover:text-white transition">Registrieren</a></li>
@endauth
</ul>
</div>
</div>
<div class="border-t border-gray-700 text-center py-4 text-xs text-gray-500">
&copy; {{ date('Y') }} {{ \App\Models\Setting::get('shop_name') }}. Alle Rechte vorbehalten.
</div>
</footer>
@stack('scripts')
</body>
</html>