From 092dae608577213c6539dab61007701ccbac2410 Mon Sep 17 00:00:00 2001 From: Marco Wende Date: Sun, 21 Jun 2026 11:07:14 +0200 Subject: [PATCH] Add coupons, newsletter, country-based shipping, sale prices, USP bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gutscheincodes: DB-Verwaltung (%, €-Rabatt, Mindestbestellwert, Ablauf), Eingabefeld im Checkout, Admin unter Marketing → Gutscheine - Newsletter: Double-Opt-In per E-Mail, Formular im Footer, Admin-Liste mit CSV-Export - Länderzonen-Versand: DE / DACH / EU / WORLD, Versandkosten nach Lieferland dynamisch berechnet, Coupon-Rabatt in Bestellung gespeichert - Sale-Preise: sale_price auf Produkten, durchgestrichener UVP auf Produktkarte, Sonderangebote-Sektion auf Startseite - USP-Leiste auf Startseite: Versand, Zahlung, Rückgabe, Service Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Admin/CouponController.php | 68 +++++++++++++++++ .../Admin/NewsletterController.php | 35 +++++++++ .../Controllers/Admin/ProductController.php | 2 + .../Controllers/Shop/CheckoutController.php | 66 +++++++++++++++-- app/Http/Controllers/Shop/HomeController.php | 13 ++-- .../Controllers/Shop/NewsletterController.php | 57 ++++++++++++++ app/Models/Coupon.php | 36 +++++++++ app/Models/NewsletterSubscriber.php | 16 ++++ app/Models/Order.php | 2 + app/Models/Product.php | 14 +++- app/Models/ShippingRate.php | 27 ++++++- app/Services/OrderService.php | 64 ++++++++++------ ...2024_01_01_000019_create_coupons_table.php | 36 +++++++++ ...20_create_newsletter_subscribers_table.php | 23 ++++++ ...1_01_000021_add_sale_price_to_products.php | 21 ++++++ resources/views/admin/coupons/form.blade.php | 74 +++++++++++++++++++ resources/views/admin/coupons/index.blade.php | 59 +++++++++++++++ .../views/admin/newsletter/index.blade.php | 55 ++++++++++++++ resources/views/admin/products/form.blade.php | 6 +- .../views/admin/settings/shipping.blade.php | 12 ++- resources/views/layouts/admin.blade.php | 3 + resources/views/layouts/shop.blade.php | 22 +++++- .../views/shop/checkout/confirm.blade.php | 5 ++ resources/views/shop/checkout/index.blade.php | 35 ++++++++- resources/views/shop/home.blade.php | 40 ++++++++++ resources/views/shop/products/_card.blade.php | 22 ++++-- routes/web.php | 20 +++++ 27 files changed, 782 insertions(+), 51 deletions(-) create mode 100644 app/Http/Controllers/Admin/CouponController.php create mode 100644 app/Http/Controllers/Admin/NewsletterController.php create mode 100644 app/Http/Controllers/Shop/NewsletterController.php create mode 100644 app/Models/Coupon.php create mode 100644 app/Models/NewsletterSubscriber.php create mode 100644 database/migrations/2024_01_01_000019_create_coupons_table.php create mode 100644 database/migrations/2024_01_01_000020_create_newsletter_subscribers_table.php create mode 100644 database/migrations/2024_01_01_000021_add_sale_price_to_products.php create mode 100644 resources/views/admin/coupons/form.blade.php create mode 100644 resources/views/admin/coupons/index.blade.php create mode 100644 resources/views/admin/newsletter/index.blade.php diff --git a/app/Http/Controllers/Admin/CouponController.php b/app/Http/Controllers/Admin/CouponController.php new file mode 100644 index 0000000..75e06d6 --- /dev/null +++ b/app/Http/Controllers/Admin/CouponController.php @@ -0,0 +1,68 @@ +get(); + return view('admin.coupons.index', compact('coupons')); + } + + public function create() + { + return view('admin.coupons.form'); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'code' => 'required|string|max:50|unique:coupons,code', + 'type' => 'required|in:percent,fixed', + 'value' => 'required|numeric|min:0.01', + 'min_order_value' => 'nullable|numeric|min:0', + 'expires_at' => 'nullable|date', + 'max_uses' => 'nullable|integer|min:1', + 'active' => 'boolean', + ]); + $data['code'] = strtoupper(trim($data['code'])); + $data['active'] = $request->boolean('active'); + + Coupon::create($data); + return redirect()->route('admin.coupons.index')->with('success', 'Gutschein erstellt.'); + } + + public function edit(Coupon $coupon) + { + return view('admin.coupons.form', compact('coupon')); + } + + public function update(Request $request, Coupon $coupon) + { + $data = $request->validate([ + 'code' => 'required|string|max:50|unique:coupons,code,' . $coupon->id, + 'type' => 'required|in:percent,fixed', + 'value' => 'required|numeric|min:0.01', + 'min_order_value' => 'nullable|numeric|min:0', + 'expires_at' => 'nullable|date', + 'max_uses' => 'nullable|integer|min:1', + 'active' => 'boolean', + ]); + $data['code'] = strtoupper(trim($data['code'])); + $data['active'] = $request->boolean('active'); + + $coupon->update($data); + return redirect()->route('admin.coupons.index')->with('success', 'Gutschein aktualisiert.'); + } + + public function destroy(Coupon $coupon) + { + $coupon->delete(); + return redirect()->route('admin.coupons.index')->with('success', 'Gutschein gelöscht.'); + } +} diff --git a/app/Http/Controllers/Admin/NewsletterController.php b/app/Http/Controllers/Admin/NewsletterController.php new file mode 100644 index 0000000..2079754 --- /dev/null +++ b/app/Http/Controllers/Admin/NewsletterController.php @@ -0,0 +1,35 @@ +get(); + return view('admin.newsletter.index', compact('subscribers')); + } + + public function export() + { + $rows = NewsletterSubscriber::whereNotNull('confirmed_at') + ->orderBy('email') + ->pluck('email'); + + $csv = "E-Mail\n" . $rows->implode("\n"); + + return response($csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="newsletter-' . date('Y-m-d') . '.csv"', + ]); + } + + public function destroy(NewsletterSubscriber $subscriber) + { + $subscriber->delete(); + return back()->with('success', 'Abonnent gelöscht.'); + } +} diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 52b5b2c..2e3d983 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -39,6 +39,7 @@ class ProductController extends Controller 'category_id' => 'nullable|exists:categories,id', 'description' => 'nullable|string', 'base_price' => 'required|numeric|min:0', + 'sale_price' => 'nullable|numeric|min:0', 'tax_rate' => 'required|numeric|in:7,19', 'weight' => 'nullable|numeric|min:0', 'active' => 'boolean', @@ -83,6 +84,7 @@ class ProductController extends Controller 'category_id' => 'nullable|exists:categories,id', 'description' => 'nullable|string', 'base_price' => 'required|numeric|min:0', + 'sale_price' => 'nullable|numeric|min:0', 'tax_rate' => 'required|numeric|in:7,19', 'weight' => 'nullable|numeric|min:0', 'active' => 'boolean', diff --git a/app/Http/Controllers/Shop/CheckoutController.php b/app/Http/Controllers/Shop/CheckoutController.php index c9e1213..b467407 100644 --- a/app/Http/Controllers/Shop/CheckoutController.php +++ b/app/Http/Controllers/Shop/CheckoutController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Shop; use App\Http\Controllers\Controller; +use App\Models\Coupon; use App\Models\ShippingRate; use App\Services\CartService; use App\Services\OrderService; @@ -21,12 +22,49 @@ class CheckoutController extends Controller return redirect()->route('cart.index')->with('error', 'Ihr Warenkorb ist leer.'); } $subtotal = $this->cart->subtotal(); - $shippingRate = ShippingRate::forCart($subtotal); + $country = session('checkout_country', 'DE'); + $shippingRate = ShippingRate::forCart($subtotal, $country); $shippingCost = $shippingRate ? $shippingRate->getCostForTotal($subtotal) : 0; - $total = $subtotal + $shippingCost; + $discount = $this->getCouponDiscount($subtotal); + $total = max(0, $subtotal - $discount) + $shippingCost; $userAddresses = auth()->user()?->addresses ?? collect(); + $couponCode = session('coupon_code'); - return view('shop.checkout.index', compact('subtotal', 'shippingCost', 'total', 'userAddresses')); + return view('shop.checkout.index', compact('subtotal', 'shippingCost', 'total', 'userAddresses', 'discount', 'couponCode')); + } + + public function applyCoupon(Request $request) + { + $code = trim($request->input('coupon_code', '')); + if (!$code) { + return back()->with('coupon_error', 'Bitte Gutscheincode eingeben.'); + } + $coupon = Coupon::findValid($code); + if (!$coupon) { + return back()->with('coupon_error', 'Gutscheincode ungültig oder abgelaufen.'); + } + $subtotal = $this->cart->subtotal(); + if ($coupon->min_order_value && $subtotal < $coupon->min_order_value) { + return back()->with('coupon_error', 'Mindestbestellwert von ' . number_format($coupon->min_order_value, 2, ',', '.') . ' € nicht erreicht.'); + } + session(['coupon_code' => $coupon->code]); + return back()->with('coupon_success', 'Gutschein "' . $coupon->code . '" angewendet!'); + } + + public function removeCoupon() + { + session()->forget('coupon_code'); + return back()->with('coupon_success', 'Gutschein entfernt.'); + } + + public function shippingCost(Request $request) + { + $country = strtoupper($request->input('country', 'DE')); + session(['checkout_country' => $country]); + $subtotal = $this->cart->subtotal(); + $shippingRate = ShippingRate::forCart($subtotal, $country); + $cost = $shippingRate ? $shippingRate->getCostForTotal($subtotal) : 0; + return response()->json(['cost' => $cost, 'formatted' => number_format($cost, 2, ',', '.') . ' €']); } public function storeAddress(Request $request) @@ -83,10 +121,13 @@ class CheckoutController extends Controller if ($request->isMethod('get')) { $items = $this->cart->get(); $subtotal = $this->cart->subtotal(); - $shippingRate = ShippingRate::forCart($subtotal); + $country = $checkoutData['shipping']['country'] ?? $checkoutData['billing']['country'] ?? 'DE'; + $shippingRate = ShippingRate::forCart($subtotal, $country); $shippingCost = $shippingRate ? $shippingRate->getCostForTotal($subtotal) : 0; - $total = $subtotal + $shippingCost; - return view('shop.checkout.confirm', compact('checkoutData', 'items', 'subtotal', 'shippingCost', 'total')); + $discount = $this->getCouponDiscount($subtotal); + $total = max(0, $subtotal - $discount) + $shippingCost; + $couponCode = session('coupon_code'); + return view('shop.checkout.confirm', compact('checkoutData', 'items', 'subtotal', 'shippingCost', 'total', 'discount', 'couponCode')); } $order = $this->orderService->createFromSession( @@ -94,7 +135,7 @@ class CheckoutController extends Controller $checkoutData['payment_method'] ); - session()->forget('checkout_data'); + session()->forget(['checkout_data', 'checkout_country']); return redirect()->route('checkout.thankyou', $order->order_number); } @@ -104,4 +145,15 @@ class CheckoutController extends Controller $order = \App\Models\Order::where('order_number', $order)->firstOrFail(); return view('shop.checkout.thankyou', compact('order')); } + + private function getCouponDiscount(float $subtotal): float + { + if ($code = session('coupon_code')) { + $coupon = Coupon::findValid($code); + if ($coupon) { + return $coupon->calcDiscount($subtotal); + } + } + return 0; + } } diff --git a/app/Http/Controllers/Shop/HomeController.php b/app/Http/Controllers/Shop/HomeController.php index 895f00a..6dcb9ec 100644 --- a/app/Http/Controllers/Shop/HomeController.php +++ b/app/Http/Controllers/Shop/HomeController.php @@ -12,12 +12,13 @@ class HomeController extends Controller { public function index() { - $banners = Banner::where('active', true)->orderBy('sort_order')->get(); - $categories = Category::whereNull('parent_id')->where('active', true)->orderBy('sort_order')->get(); - $topProducts = Product::where('active', true)->where('featured', true)->latest()->take(8)->get(); - $newProducts = Product::where('active', true)->latest()->take(8)->get(); - $blogPosts = Page::where('type', 'blog')->where('published', true)->latest('published_at')->take(3)->get(); + $banners = Banner::where('active', true)->orderBy('sort_order')->get(); + $categories = Category::whereNull('parent_id')->where('active', true)->orderBy('sort_order')->get(); + $topProducts = Product::where('active', true)->where('featured', true)->with('images','variants','category')->latest()->take(8)->get(); + $newProducts = Product::where('active', true)->with('images','variants','category')->latest()->take(8)->get(); + $saleProducts = Product::where('active', true)->whereNotNull('sale_price')->with('images','variants','category')->latest()->take(8)->get(); + $blogPosts = Page::where('type', 'blog')->where('published', true)->latest('published_at')->take(3)->get(); - return view('shop.home', compact('banners', 'categories', 'topProducts', 'newProducts', 'blogPosts')); + return view('shop.home', compact('banners', 'categories', 'topProducts', 'newProducts', 'saleProducts', 'blogPosts')); } } diff --git a/app/Http/Controllers/Shop/NewsletterController.php b/app/Http/Controllers/Shop/NewsletterController.php new file mode 100644 index 0000000..6af5d0b --- /dev/null +++ b/app/Http/Controllers/Shop/NewsletterController.php @@ -0,0 +1,57 @@ +validate(['newsletter_email' => 'required|email|max:255']); + $email = strtolower($request->input('newsletter_email')); + + $existing = NewsletterSubscriber::where('email', $email)->first(); + + if ($existing && $existing->isConfirmed()) { + return back()->with('newsletter_info', 'Diese E-Mail-Adresse ist bereits angemeldet.'); + } + + $token = Str::random(64); + + if ($existing) { + $existing->update(['token' => $token]); + } else { + NewsletterSubscriber::create(['email' => $email, 'token' => $token]); + } + + $confirmUrl = route('newsletter.confirm', $token); + $shopName = \App\Models\Setting::get('shop_name', 'Shop'); + + Mail::raw( + "Bitte bestätigen Sie Ihre Newsletter-Anmeldung:\n\n{$confirmUrl}\n\n" + . "Falls Sie sich nicht angemeldet haben, können Sie diese E-Mail ignorieren.\n\n" + . "– {$shopName}", + fn($m) => $m->to($email)->subject("Newsletter-Anmeldung bestätigen – {$shopName}") + ); + + return back()->with('newsletter_success', 'Bitte prüfen Sie Ihren Posteingang und bestätigen Sie die Anmeldung.'); + } + + public function confirm(string $token) + { + $subscriber = NewsletterSubscriber::where('token', $token)->whereNull('confirmed_at')->first(); + + if (!$subscriber) { + return redirect('/')->with('error', 'Ungültiger oder bereits verwendeter Bestätigungslink.'); + } + + $subscriber->update(['confirmed_at' => now(), 'token' => null]); + + return redirect('/')->with('success', 'Newsletter-Anmeldung erfolgreich bestätigt!'); + } +} diff --git a/app/Models/Coupon.php b/app/Models/Coupon.php new file mode 100644 index 0000000..1e63b80 --- /dev/null +++ b/app/Models/Coupon.php @@ -0,0 +1,36 @@ + 'boolean', + 'expires_at' => 'datetime', + 'value' => 'decimal:2', + 'min_order_value' => 'decimal:2', + ]; + + public static function findValid(string $code): ?self + { + return self::where('code', strtoupper(trim($code))) + ->where('active', true) + ->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>=', now())) + ->where(fn($q) => $q->whereNull('max_uses')->orWhereColumn('used_count', '<', 'max_uses')) + ->first(); + } + + public function calcDiscount(float $subtotal): float + { + if ($this->min_order_value && $subtotal < $this->min_order_value) { + return 0.0; + } + if ($this->type === 'percent') { + return round($subtotal * $this->value / 100, 2); + } + return min((float) $this->value, $subtotal); + } +} diff --git a/app/Models/NewsletterSubscriber.php b/app/Models/NewsletterSubscriber.php new file mode 100644 index 0000000..dd0c0d6 --- /dev/null +++ b/app/Models/NewsletterSubscriber.php @@ -0,0 +1,16 @@ + 'datetime']; + + public function isConfirmed(): bool + { + return $this->confirmed_at !== null; + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php index 2ca03c0..1e3a5d5 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -10,12 +10,14 @@ class Order extends Model { protected $fillable = [ 'order_number','user_id','guest_email','status','payment_method', + 'coupon_code','discount_amount', 'subtotal','tax_total','shipping_cost','total','tracking_code','notes','paid_at', ]; protected $casts = [ 'paid_at' => 'datetime', 'subtotal' => 'decimal:2', + 'discount_amount' => 'decimal:2', 'tax_total' => 'decimal:2', 'shipping_cost' => 'decimal:2', 'total' => 'decimal:2', diff --git a/app/Models/Product.php b/app/Models/Product.php index 6500d5a..f918ded 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -8,9 +8,19 @@ use Illuminate\Database\Eloquent\Relations\HasMany; class Product extends Model { - protected $fillable = ['category_id','name','slug','description','base_price','tax_rate','weight','active','featured','track_stock','sort_order']; + protected $fillable = ['category_id','name','slug','description','base_price','sale_price','tax_rate','weight','active','featured','track_stock','sort_order']; - protected $casts = ['active' => 'boolean', 'featured' => 'boolean', 'track_stock' => 'boolean', 'base_price' => 'decimal:2', 'tax_rate' => 'decimal:2']; + protected $casts = ['active' => 'boolean', 'featured' => 'boolean', 'track_stock' => 'boolean', 'base_price' => 'decimal:2', 'sale_price' => 'decimal:2', 'tax_rate' => 'decimal:2']; + + public function isOnSale(): bool + { + return $this->sale_price !== null && (float)$this->sale_price < (float)$this->base_price; + } + + public function getEffectivePriceAttribute(): float + { + return $this->isOnSale() ? (float)$this->sale_price : (float)$this->base_price; + } public function category(): BelongsTo { diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php index 36136f1..faa8494 100644 --- a/app/Models/ShippingRate.php +++ b/app/Models/ShippingRate.php @@ -10,9 +10,32 @@ class ShippingRate extends Model protected $casts = ['active' => 'boolean', 'price' => 'decimal:2', 'free_from' => 'decimal:2']; - public static function forCart(float $total, string $zone = 'DE'): ?self + // Länder → Zonen-Mapping + public static array $countryZones = [ + 'DE' => 'DE', + 'AT' => 'DACH', + 'CH' => 'DACH', + 'LI' => 'DACH', + 'FR' => 'EU', 'NL' => 'EU', 'BE' => 'EU', 'LU' => 'EU', + 'IT' => 'EU', 'ES' => 'EU', 'PT' => 'EU', 'PL' => 'EU', + 'CZ' => 'EU', 'SK' => 'EU', 'HU' => 'EU', 'RO' => 'EU', + 'BG' => 'EU', 'HR' => 'EU', 'SI' => 'EU', 'GR' => 'EU', + 'DK' => 'EU', 'SE' => 'EU', 'FI' => 'EU', 'EE' => 'EU', + 'LV' => 'EU', 'LT' => 'EU', 'IE' => 'EU', 'MT' => 'EU', + 'CY' => 'EU', + ]; + + public static function zoneForCountry(string $country): string { - return self::where('zone', $zone)->where('active', true)->first(); + return self::$countryZones[strtoupper($country)] ?? 'WORLD'; + } + + public static function forCart(float $total, string $country = 'DE'): ?self + { + $zone = self::zoneForCountry($country); + $rate = self::where('zone', $zone)->where('active', true)->first(); + // Fallback auf DE wenn keine Zone gefunden + return $rate ?? self::where('zone', 'DE')->where('active', true)->first(); } public function getCostForTotal(float $total): float diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 81bb603..c61900d 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Mail\OrderConfirmationMail; use App\Mail\OrderShippedMail; +use App\Models\Coupon; use App\Models\Order; use App\Models\OrderAddress; use App\Models\OrderItem; @@ -17,40 +18,58 @@ class OrderService public function createFromSession(array $addressData, string $paymentMethod): Order { - $items = $this->cart->get(); + $items = $this->cart->get(); $subtotal = $this->cart->subtotal(); - $shippingRate = ShippingRate::forCart($subtotal); + $country = $addressData['shipping']['country'] ?? $addressData['billing']['country'] ?? 'DE'; + $shippingRate = ShippingRate::forCart($subtotal, $country); $shippingCost = $shippingRate ? $shippingRate->getCostForTotal($subtotal) : 0; - $taxTotal = array_sum(array_map(function ($item) { - $net = $item['price'] / (1 + $item['tax_rate'] / 100); - return round(($item['price'] - $net) * $item['qty'], 2); + // Gutschein aus Session + $couponCode = null; + $discountAmount = 0; + if ($code = session('coupon_code')) { + $coupon = Coupon::findValid($code); + if ($coupon) { + $discountAmount = $coupon->calcDiscount($subtotal); + $couponCode = $coupon->code; + $coupon->increment('used_count'); + } + } + + $taxable = max(0, $subtotal - $discountAmount); + $taxTotal = array_sum(array_map(function ($item) use ($subtotal, $discountAmount) { + $ratio = $subtotal > 0 ? ($item['price'] * $item['qty']) / $subtotal : 0; + $itemNet = ($item['price'] * $item['qty'] - $discountAmount * $ratio) / (1 + $item['tax_rate'] / 100); + return round(($item['price'] * $item['qty'] - $discountAmount * $ratio) - $itemNet, 2); }, $items)); + $total = max(0, $subtotal - $discountAmount) + $shippingCost; + $order = Order::create([ - 'order_number' => Order::generateOrderNumber(), - 'user_id' => auth()->id(), - 'guest_email' => auth()->check() ? null : ($addressData['email'] ?? null), - 'status' => 'new', - 'payment_method' => $paymentMethod, - 'subtotal' => $subtotal, - 'tax_total' => $taxTotal, - 'shipping_cost' => $shippingCost, - 'total' => $subtotal + $shippingCost, + 'order_number' => Order::generateOrderNumber(), + 'user_id' => auth()->id(), + 'guest_email' => auth()->check() ? null : ($addressData['email'] ?? null), + 'status' => 'new', + 'payment_method' => $paymentMethod, + 'coupon_code' => $couponCode, + 'discount_amount' => $discountAmount, + 'subtotal' => $subtotal, + 'tax_total' => $taxTotal, + 'shipping_cost' => $shippingCost, + 'total' => $total, ]); foreach ($items as $item) { OrderItem::create([ - 'order_id' => $order->id, - 'product_variant_id'=> $item['variant_id'], - 'product_name' => $item['product_name'], - 'variant_label' => $item['variant_label'], - 'quantity' => $item['qty'], - 'unit_price' => $item['price'], - 'tax_rate' => $item['tax_rate'], + 'order_id' => $order->id, + 'product_variant_id' => $item['variant_id'], + 'product_name' => $item['product_name'], + 'variant_label' => $item['variant_label'], + 'quantity' => $item['qty'], + 'unit_price' => $item['price'], + 'tax_rate' => $item['tax_rate'], ]); - // Bestand reduzieren — nur wenn track_stock aktiv $variant = ProductVariant::with('product')->find($item['variant_id']); if ($variant && $variant->product->track_stock) { $variant->decrement('stock', $item['qty']); @@ -63,6 +82,7 @@ class OrderService } $this->cart->clear(); + session()->forget('coupon_code'); Mail::to($order->guest_email ?? $order->user?->email)->send(new OrderConfirmationMail($order)); diff --git a/database/migrations/2024_01_01_000019_create_coupons_table.php b/database/migrations/2024_01_01_000019_create_coupons_table.php new file mode 100644 index 0000000..247189f --- /dev/null +++ b/database/migrations/2024_01_01_000019_create_coupons_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('code')->unique(); + $table->enum('type', ['percent', 'fixed'])->default('fixed'); + $table->decimal('value', 10, 2); + $table->decimal('min_order_value', 10, 2)->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->unsignedInteger('max_uses')->nullable(); + $table->unsignedInteger('used_count')->default(0); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->string('coupon_code')->nullable()->after('payment_method'); + $table->decimal('discount_amount', 10, 2)->default(0)->after('coupon_code'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn(['coupon_code', 'discount_amount']); + }); + Schema::dropIfExists('coupons'); + } +}; diff --git a/database/migrations/2024_01_01_000020_create_newsletter_subscribers_table.php b/database/migrations/2024_01_01_000020_create_newsletter_subscribers_table.php new file mode 100644 index 0000000..4b82e17 --- /dev/null +++ b/database/migrations/2024_01_01_000020_create_newsletter_subscribers_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('email')->unique(); + $table->string('token', 64)->nullable(); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('newsletter_subscribers'); + } +}; diff --git a/database/migrations/2024_01_01_000021_add_sale_price_to_products.php b/database/migrations/2024_01_01_000021_add_sale_price_to_products.php new file mode 100644 index 0000000..e86e8de --- /dev/null +++ b/database/migrations/2024_01_01_000021_add_sale_price_to_products.php @@ -0,0 +1,21 @@ +decimal('sale_price', 10, 2)->nullable()->after('base_price'); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('sale_price'); + }); + } +}; diff --git a/resources/views/admin/coupons/form.blade.php b/resources/views/admin/coupons/form.blade.php new file mode 100644 index 0000000..10dec35 --- /dev/null +++ b/resources/views/admin/coupons/form.blade.php @@ -0,0 +1,74 @@ +@extends('layouts.admin') +@section('title', isset($coupon) ? 'Gutschein bearbeiten' : 'Neuer Gutschein') +@section('content') +
+ ← Zurück +

{{ isset($coupon) ? 'Gutschein bearbeiten' : 'Neuer Gutschein' }}

+
+ +
+ @csrf + @isset($coupon) @method('PUT') @endisset + + @if($errors->any()) +
+ @foreach($errors->all() as $e)

• {{ $e }}

@endforeach +
+ @endif + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + + +
+ + Abbrechen +
+
+@endsection diff --git a/resources/views/admin/coupons/index.blade.php b/resources/views/admin/coupons/index.blade.php new file mode 100644 index 0000000..2dddea6 --- /dev/null +++ b/resources/views/admin/coupons/index.blade.php @@ -0,0 +1,59 @@ +@extends('layouts.admin') +@section('title', 'Gutscheine') +@section('content') +
+

Gutscheine

+ + Neuer Gutschein +
+ +@if(session('success')) +
{{ session('success') }}
+@endif + +
+ + + + + + + + + + + + + + + @forelse($coupons as $coupon) + + + + + + + + + + + @empty + + @endforelse + +
CodeTypWertMind. BestellwertLäuft abVerwendetStatus
{{ $coupon->code }}{{ $coupon->type === 'percent' ? 'Prozent' : 'Betrag' }} + {{ number_format($coupon->value, 2, ',', '.') }}{{ $coupon->type === 'percent' ? ' %' : ' €' }} + {{ $coupon->min_order_value ? number_format($coupon->min_order_value, 2, ',', '.') . ' €' : '–' }}{{ $coupon->expires_at ? $coupon->expires_at->format('d.m.Y') : '–' }}{{ $coupon->used_count }}{{ $coupon->max_uses ? ' / ' . $coupon->max_uses : '' }} + @if($coupon->active) + Aktiv + @else + Inaktiv + @endif + + Bearbeiten +
+ @csrf @method('DELETE') + +
+
Noch keine Gutscheine angelegt.
+
+@endsection diff --git a/resources/views/admin/newsletter/index.blade.php b/resources/views/admin/newsletter/index.blade.php new file mode 100644 index 0000000..01b2b5f --- /dev/null +++ b/resources/views/admin/newsletter/index.blade.php @@ -0,0 +1,55 @@ +@extends('layouts.admin') +@section('title', 'Newsletter-Abonnenten') +@section('content') +
+

Newsletter-Abonnenten

+ CSV exportieren +
+ +@if(session('success')) +
{{ session('success') }}
+@endif + +
+ {{ $subscribers->whereNotNull('confirmed_at')->count() }} bestätigt, + {{ $subscribers->whereNull('confirmed_at')->count() }} ausstehend +
+ +
+ + + + + + + + + + + + @forelse($subscribers as $sub) + + + + + + + + @empty + + @endforelse + +
E-MailStatusAngemeldet amBestätigt am
{{ $sub->email }} + @if($sub->isConfirmed()) + Bestätigt + @else + Ausstehend + @endif + {{ $sub->created_at->format('d.m.Y H:i') }}{{ $sub->confirmed_at?->format('d.m.Y H:i') ?? '–' }} +
+ @csrf @method('DELETE') + +
+
Keine Abonnenten.
+
+@endsection diff --git a/resources/views/admin/products/form.blade.php b/resources/views/admin/products/form.blade.php index eec35e0..d94a9f6 100644 --- a/resources/views/admin/products/form.blade.php +++ b/resources/views/admin/products/form.blade.php @@ -21,11 +21,15 @@ -
+
+
+ + +
-
+
+ +
diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index cff38d5..7b213a4 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -27,6 +27,9 @@ Seiten Banner FAQ +
Marketing
+ Gutscheine + Newsletter
System
Admin-Benutzer E-Mail-Templates diff --git a/resources/views/layouts/shop.blade.php b/resources/views/layouts/shop.blade.php index 286e239..e82da0b 100644 --- a/resources/views/layouts/shop.blade.php +++ b/resources/views/layouts/shop.blade.php @@ -196,7 +196,27 @@ $hasBlog = \App\Models\Page::where('type','blog')->where('published', true)->exists(); $hasFaq = \App\Models\Faq::exists(); @endphp -