This commit is contained in:
66
app/Http/Controllers/Admin/AdminAuthController.php
Normal file
66
app/Http/Controllers/Admin/AdminAuthController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ManagesCaptcha;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AdminAuthController extends Controller
|
||||
{
|
||||
use ManagesCaptcha;
|
||||
|
||||
private const LOGIN_CAPTCHA_CONTEXT = 'admin_login';
|
||||
|
||||
public function showLoginForm(Request $request)
|
||||
{
|
||||
return view('admin.auth.login', [
|
||||
'captchaQuestion' => $this->buildCaptchaQuestion($request, self::LOGIN_CAPTCHA_CONTEXT),
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'captcha' => ['required', 'string', 'max:10'],
|
||||
]);
|
||||
|
||||
if (!$this->captchaIsValid($request, self::LOGIN_CAPTCHA_CONTEXT)) {
|
||||
return back()->withErrors(['captcha' => 'Неверный ответ на капчу.'])->withInput();
|
||||
}
|
||||
|
||||
$credentials = [
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
];
|
||||
|
||||
if (!Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
return back()->withErrors(['email' => 'Неверный email или пароль.'])->withInput();
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
$this->clearCaptcha($request, self::LOGIN_CAPTCHA_CONTEXT);
|
||||
|
||||
if (!$request->user()->is_admin) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return back()->withErrors(['email' => 'Доступ разрешен только администраторам.']);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Admin/AdminDashboardController.php
Normal file
24
app/Http/Controllers/Admin/AdminDashboardController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
|
||||
class AdminDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.dashboard', [
|
||||
'stats' => [
|
||||
'categories' => Category::count(),
|
||||
'products' => Product::count(),
|
||||
'orders' => Order::count(),
|
||||
'revenue' => (float) Order::sum('total'),
|
||||
],
|
||||
'recentOrders' => Order::query()->latest('id')->take(8)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Admin/CategoryController.php
Normal file
94
app/Http/Controllers/Admin/CategoryController.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.categories.index', [
|
||||
'categories' => Category::query()->latest('id')->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('admin.categories.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $this->validateCategory($request);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name']);
|
||||
Category::create($data);
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('status', 'Категория создана.');
|
||||
}
|
||||
|
||||
public function edit(Category $category)
|
||||
{
|
||||
return view('admin.categories.edit', [
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Category $category)
|
||||
{
|
||||
$data = $this->validateCategory($request, $category);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name'], $category->id);
|
||||
$category->update($data);
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('status', 'Категория обновлена.');
|
||||
}
|
||||
|
||||
public function destroy(Category $category)
|
||||
{
|
||||
$category->delete();
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('status', 'Категория удалена.');
|
||||
}
|
||||
|
||||
private function validateCategory(Request $request, ?Category $category = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('categories', 'slug')->ignore($category?->id),
|
||||
],
|
||||
'description' => ['nullable', 'string'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]) + ['is_active' => $request->boolean('is_active')];
|
||||
}
|
||||
|
||||
private function makeUniqueSlug(string $value, ?int $ignoreId = null): string
|
||||
{
|
||||
$base = Str::slug($value);
|
||||
if ($base === '') {
|
||||
$base = 'category';
|
||||
}
|
||||
|
||||
$slug = $base;
|
||||
$index = 2;
|
||||
|
||||
while (
|
||||
Category::query()
|
||||
->where('slug', $slug)
|
||||
->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))
|
||||
->exists()
|
||||
) {
|
||||
$slug = "{$base}-{$index}";
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
178
app/Http/Controllers/Admin/ChatController.php
Normal file
178
app/Http/Controllers/Admin/ChatController.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ChatConversation;
|
||||
use App\Models\ChatMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$conversations = ChatConversation::query()
|
||||
->with('user', 'latestMessage')
|
||||
->withCount([
|
||||
'messages as unread_count' => fn ($query) => $query
|
||||
->where('sender', 'customer')
|
||||
->where('is_read', false),
|
||||
])
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('id')
|
||||
->paginate(30);
|
||||
|
||||
$selectedConversation = $this->resolveSelectedConversation($request, $conversations);
|
||||
$initialMessages = collect();
|
||||
|
||||
if ($selectedConversation) {
|
||||
$this->markCustomerMessagesAsRead($selectedConversation);
|
||||
$initialMessages = $this->conversationMessages($selectedConversation);
|
||||
}
|
||||
|
||||
return view('admin.chats.index', [
|
||||
'conversations' => $conversations,
|
||||
'selectedConversation' => $selectedConversation,
|
||||
'initialMessages' => $initialMessages->map(fn (ChatMessage $message) => $this->messagePayload($message))->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function messages(ChatConversation $conversation): JsonResponse
|
||||
{
|
||||
$this->markCustomerMessagesAsRead($conversation);
|
||||
|
||||
return response()->json([
|
||||
'conversation' => $this->conversationPayload($conversation->loadMissing('user')),
|
||||
'messages' => $this->conversationMessages($conversation)
|
||||
->map(fn (ChatMessage $message) => $this->messagePayload($message))
|
||||
->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeMessage(Request $request, ChatConversation $conversation): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$messageText = $this->sanitizeMessage((string) $validated['message']);
|
||||
if ($messageText === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'message' => 'Сообщение содержит недопустимые символы.',
|
||||
]);
|
||||
}
|
||||
|
||||
$message = $conversation->messages()->create([
|
||||
'sender' => 'admin',
|
||||
'body' => $messageText,
|
||||
'is_read' => false,
|
||||
]);
|
||||
|
||||
$conversation->update([
|
||||
'status' => 'open',
|
||||
'last_message_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $this->messagePayload($message),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request, ChatConversation $conversation): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', 'string', Rule::in([
|
||||
ChatConversation::STATUS_OPEN,
|
||||
ChatConversation::STATUS_CLOSED,
|
||||
])],
|
||||
]);
|
||||
|
||||
$conversation->update([
|
||||
'status' => $validated['status'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.chats.index', ['conversation' => $conversation->id])
|
||||
->with('status', $conversation->isClosed() ? 'Чат закрыт.' : 'Чат открыт.');
|
||||
}
|
||||
|
||||
public function destroy(ChatConversation $conversation): RedirectResponse
|
||||
{
|
||||
$conversation->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.chats.index')
|
||||
->with('status', 'Чат удален.');
|
||||
}
|
||||
|
||||
private function resolveSelectedConversation(Request $request, $conversations): ?ChatConversation
|
||||
{
|
||||
$selectedId = $request->integer('conversation');
|
||||
if ($selectedId > 0) {
|
||||
return ChatConversation::query()->with('user')->find($selectedId);
|
||||
}
|
||||
|
||||
$firstConversation = $conversations->first();
|
||||
if (!$firstConversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ChatConversation::query()->with('user')->find($firstConversation->id);
|
||||
}
|
||||
|
||||
private function markCustomerMessagesAsRead(ChatConversation $conversation): void
|
||||
{
|
||||
$conversation->messages()
|
||||
->where('sender', 'customer')
|
||||
->where('is_read', false)
|
||||
->update(['is_read' => true]);
|
||||
}
|
||||
|
||||
private function conversationMessages(ChatConversation $conversation)
|
||||
{
|
||||
return $conversation->messages()
|
||||
->orderBy('id')
|
||||
->limit(200)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function messagePayload(ChatMessage $message): array
|
||||
{
|
||||
return [
|
||||
'id' => $message->id,
|
||||
'sender' => $message->sender,
|
||||
'body' => $message->body,
|
||||
'created_at' => $message->created_at?->toIso8601String(),
|
||||
'time' => $message->created_at?->format('H:i'),
|
||||
];
|
||||
}
|
||||
|
||||
private function conversationPayload(ChatConversation $conversation): array
|
||||
{
|
||||
$preview = $conversation->latestMessage?->body;
|
||||
|
||||
return [
|
||||
'id' => $conversation->id,
|
||||
'title' => $conversation->display_name,
|
||||
'subtitle' => $conversation->user?->email ?: ('Токен: ' . Str::limit($conversation->visitor_token, 14, '...')),
|
||||
'preview' => $preview ? Str::limit($preview, 80) : 'Нет сообщений',
|
||||
'status' => $conversation->status,
|
||||
'is_closed' => $conversation->isClosed(),
|
||||
];
|
||||
}
|
||||
|
||||
private function sanitizeMessage(string $value): string
|
||||
{
|
||||
$text = strip_tags($value);
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text) ?? $text;
|
||||
|
||||
return trim(Str::limit($text, 2000, ''));
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/Admin/HomeSlideController.php
Normal file
167
app/Http/Controllers/Admin/HomeSlideController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HomeSlide;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HomeSlideController extends Controller
|
||||
{
|
||||
private const ZONES = ['left', 'right'];
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$slides = HomeSlide::query()
|
||||
->orderByRaw("CASE WHEN zone = 'left' THEN 0 ELSE 1 END")
|
||||
->ordered()
|
||||
->get()
|
||||
->groupBy('zone');
|
||||
|
||||
return view('admin.home-slides.index', [
|
||||
'slides' => $slides,
|
||||
'zoneLabels' => $this->zoneLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$zone = in_array($request->query('zone'), self::ZONES, true) ? $request->query('zone') : 'left';
|
||||
|
||||
return view('admin.home-slides.create', [
|
||||
'slide' => new HomeSlide([
|
||||
'zone' => $zone,
|
||||
'show_title' => true,
|
||||
'show_subtitle' => true,
|
||||
'show_button' => true,
|
||||
'sort_order' => 100,
|
||||
'is_active' => true,
|
||||
]),
|
||||
'zoneLabels' => $this->zoneLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateSlide($request, true);
|
||||
$data['image_path'] = $this->storeImage($request);
|
||||
|
||||
HomeSlide::create($data);
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд добавлен.');
|
||||
}
|
||||
|
||||
public function edit(HomeSlide $homeSlide): View
|
||||
{
|
||||
return view('admin.home-slides.edit', [
|
||||
'slide' => $homeSlide,
|
||||
'zoneLabels' => $this->zoneLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, HomeSlide $homeSlide): RedirectResponse
|
||||
{
|
||||
$data = $this->validateSlide($request);
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$newPath = $this->storeImage($request);
|
||||
$oldPath = $homeSlide->image_path;
|
||||
|
||||
$data['image_path'] = $newPath;
|
||||
$homeSlide->update($data);
|
||||
|
||||
if ($oldPath && $oldPath !== $newPath) {
|
||||
$this->deleteImage($oldPath);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд обновлен.');
|
||||
}
|
||||
|
||||
$homeSlide->update($data);
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд обновлен.');
|
||||
}
|
||||
|
||||
public function destroy(HomeSlide $homeSlide): RedirectResponse
|
||||
{
|
||||
$this->deleteImage($homeSlide->image_path);
|
||||
$homeSlide->delete();
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд удален.');
|
||||
}
|
||||
|
||||
private function validateSlide(Request $request, bool $requireImage = false): array
|
||||
{
|
||||
return $request->validate([
|
||||
'zone' => ['required', Rule::in(self::ZONES)],
|
||||
'title' => ['nullable', 'string', 'max:160'],
|
||||
'show_title' => ['nullable', 'boolean'],
|
||||
'subtitle' => ['nullable', 'string', 'max:1500'],
|
||||
'show_subtitle' => ['nullable', 'boolean'],
|
||||
'button_text' => ['nullable', 'string', 'max:60'],
|
||||
'button_url' => ['nullable', 'string', 'max:255', 'regex:/^(\\/|https?:\\/\\/).+/i'],
|
||||
'show_button' => ['nullable', 'boolean'],
|
||||
'sort_order' => ['required', 'integer', 'min:0', 'max:9999'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'image' => [
|
||||
$requireImage ? 'required' : 'nullable',
|
||||
'image',
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'max:5120',
|
||||
],
|
||||
]) + [
|
||||
'show_title' => $request->boolean('show_title'),
|
||||
'show_subtitle' => $request->boolean('show_subtitle'),
|
||||
'show_button' => $request->boolean('show_button'),
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
];
|
||||
}
|
||||
|
||||
private function storeImage(Request $request): string
|
||||
{
|
||||
$file = $request->file('image');
|
||||
if (!$file) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
if ($extension === '') {
|
||||
$extension = 'jpg';
|
||||
}
|
||||
|
||||
$name = now()->format('YmdHis') . '-' . Str::uuid() . '.' . $extension;
|
||||
return (string) $file->storeAs('home-slides', $name, 'public');
|
||||
}
|
||||
|
||||
private function deleteImage(?string $path): void
|
||||
{
|
||||
if (!$path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'uploads/')) {
|
||||
$filePath = public_path(ltrim($path, '/'));
|
||||
if (File::exists($filePath)) {
|
||||
File::delete($filePath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
|
||||
private function zoneLabels(): array
|
||||
{
|
||||
return [
|
||||
'left' => 'Большой слайдер (2/3)',
|
||||
'right' => 'Малый слайдер (1/3)',
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Admin/OrderController.php
Normal file
38
app/Http/Controllers/Admin/OrderController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.orders.index', [
|
||||
'orders' => Order::query()->latest('id')->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Order $order)
|
||||
{
|
||||
$order->load('items.product', 'user');
|
||||
|
||||
return view('admin.orders.show', [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Order $order)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => ['required', 'string', Rule::in(['new', 'processing', 'paid', 'shipped', 'completed', 'cancelled'])],
|
||||
]);
|
||||
|
||||
$order->update($data);
|
||||
|
||||
return back()->with('status', 'Статус заказа обновлен.');
|
||||
}
|
||||
}
|
||||
816
app/Http/Controllers/Admin/ProductController.php
Normal file
816
app/Http/Controllers/Admin/ProductController.php
Normal file
@@ -0,0 +1,816 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.products.index', [
|
||||
'products' => Product::query()->with('category')->latest('id')->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$categories = Category::query()->where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.products.create', [
|
||||
'categories' => $categories,
|
||||
'categorySpecsById' => $this->categorySpecDefinitionsById($categories),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateProduct($request);
|
||||
unset($data['image'], $data['gallery_images'], $data['remove_image'], $data['remove_gallery_paths']);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name']);
|
||||
$category = Category::query()->find($data['category_id']);
|
||||
$data['specs'] = $this->parseSpecs(
|
||||
(array) $request->input('specs', []),
|
||||
$request->input('specs_text'),
|
||||
$category?->slug
|
||||
);
|
||||
|
||||
$imagePath = $request->hasFile('image') ? $this->storeImage($request) : null;
|
||||
$galleryPaths = $request->hasFile('gallery_images')
|
||||
? $this->storeGalleryImages($request)
|
||||
: [];
|
||||
|
||||
[$imagePath, $galleryPaths] = $this->normalizeProductImageSet($imagePath, $galleryPaths);
|
||||
|
||||
$data['image_path'] = $imagePath;
|
||||
$data['gallery_paths'] = $galleryPaths;
|
||||
|
||||
if ($data['image_path'] === null) {
|
||||
unset($data['image_path']);
|
||||
}
|
||||
|
||||
if ($data['gallery_paths'] === []) {
|
||||
unset($data['gallery_paths']);
|
||||
}
|
||||
|
||||
Product::create($data);
|
||||
|
||||
return redirect()->route('admin.products.index')->with('status', 'Товар создан.');
|
||||
}
|
||||
|
||||
public function edit(Product $product)
|
||||
{
|
||||
$categories = Category::query()->where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.products.edit', [
|
||||
'product' => $product,
|
||||
'categories' => $categories,
|
||||
'categorySpecsById' => $this->categorySpecDefinitionsById($categories),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$data = $this->validateProduct($request, $product);
|
||||
unset($data['image'], $data['gallery_images'], $data['remove_image'], $data['remove_gallery_paths']);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name'], $product->id);
|
||||
$category = Category::query()->find($data['category_id']);
|
||||
$data['specs'] = $this->parseSpecs(
|
||||
(array) $request->input('specs', []),
|
||||
$request->input('specs_text'),
|
||||
$category?->slug
|
||||
);
|
||||
|
||||
$oldImagePath = $product->image_path;
|
||||
$oldGalleryPaths = $this->normalizeImagePaths((array) ($product->gallery_paths ?? []));
|
||||
$removeImage = $request->boolean('remove_image');
|
||||
$removeGalleryPaths = $this->normalizeImagePaths((array) $request->input('remove_gallery_paths', []));
|
||||
|
||||
$nextImagePath = $oldImagePath;
|
||||
$nextGalleryPaths = $oldGalleryPaths;
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$nextImagePath = $this->storeImage($request);
|
||||
} elseif ($removeImage) {
|
||||
$nextImagePath = null;
|
||||
if ($oldImagePath) {
|
||||
$removeGalleryPaths[] = $oldImagePath;
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->hasFile('gallery_images')) {
|
||||
$nextGalleryPaths = $this->storeGalleryImages($request);
|
||||
} elseif ($removeGalleryPaths !== []) {
|
||||
$removablePaths = array_values(array_intersect($oldGalleryPaths, $removeGalleryPaths));
|
||||
$nextGalleryPaths = array_values(array_filter(
|
||||
$oldGalleryPaths,
|
||||
fn (string $path) => !in_array($path, $removablePaths, true)
|
||||
));
|
||||
}
|
||||
|
||||
[$nextImagePath, $nextGalleryPaths] = $this->normalizeProductImageSet($nextImagePath, $nextGalleryPaths);
|
||||
|
||||
$data['image_path'] = $nextImagePath;
|
||||
$data['gallery_paths'] = $nextGalleryPaths;
|
||||
|
||||
$product->update($data);
|
||||
|
||||
$activePaths = $this->normalizeImagePaths(
|
||||
array_merge(
|
||||
[$product->image_path],
|
||||
(array) ($product->gallery_paths ?? [])
|
||||
)
|
||||
);
|
||||
|
||||
if ($oldImagePath && !in_array($oldImagePath, $activePaths, true)) {
|
||||
$this->deleteImage($oldImagePath);
|
||||
}
|
||||
|
||||
foreach ($oldGalleryPaths as $oldPath) {
|
||||
if (!in_array($oldPath, $activePaths, true)) {
|
||||
$this->deleteImage($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.products.index')->with('status', 'Товар обновлен.');
|
||||
}
|
||||
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
{
|
||||
$this->deleteImages(array_merge([$product->image_path], (array) ($product->gallery_paths ?? [])));
|
||||
$product->delete();
|
||||
|
||||
return redirect()->route('admin.products.index')->with('status', 'Товар удален.');
|
||||
}
|
||||
|
||||
public function exportCsv(): StreamedResponse
|
||||
{
|
||||
$specKeys = $this->allSpecKeys();
|
||||
$headers = array_merge(
|
||||
[
|
||||
'id',
|
||||
'category_slug',
|
||||
'category_name',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
'price',
|
||||
'old_price',
|
||||
'stock',
|
||||
'short_description',
|
||||
'description',
|
||||
'is_active',
|
||||
],
|
||||
$specKeys,
|
||||
['specs_json']
|
||||
);
|
||||
|
||||
return response()->streamDownload(function () use ($headers, $specKeys) {
|
||||
$output = fopen('php://output', 'wb');
|
||||
if (!$output) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite($output, "\xEF\xBB\xBF");
|
||||
fputcsv($output, $headers, ';');
|
||||
|
||||
Product::query()
|
||||
->with('category')
|
||||
->orderBy('id')
|
||||
->chunk(200, function (EloquentCollection $products) use ($output, $specKeys) {
|
||||
foreach ($products as $product) {
|
||||
$specs = (array) ($product->specs ?? []);
|
||||
$row = [
|
||||
$product->id,
|
||||
$product->category?->slug ?? '',
|
||||
$product->category?->name ?? '',
|
||||
$product->name,
|
||||
$product->slug,
|
||||
$product->sku,
|
||||
$product->price,
|
||||
$product->old_price,
|
||||
$product->stock,
|
||||
$product->short_description,
|
||||
$product->description,
|
||||
$product->is_active ? 1 : 0,
|
||||
];
|
||||
|
||||
foreach ($specKeys as $key) {
|
||||
$row[] = $specs[$key] ?? '';
|
||||
}
|
||||
|
||||
$row[] = $specs === [] ? '' : json_encode($specs, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
fputcsv($output, $row, ';');
|
||||
}
|
||||
});
|
||||
|
||||
fclose($output);
|
||||
}, 'products-' . now()->format('Ymd-His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
public function importCsv(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'],
|
||||
]);
|
||||
|
||||
$path = $request->file('csv_file')?->getRealPath();
|
||||
if (!$path) {
|
||||
return back()->withErrors(['csv_file' => 'Не удалось прочитать файл.']);
|
||||
}
|
||||
|
||||
[$handle, $delimiter] = $this->openCsv($path);
|
||||
if (!$handle) {
|
||||
return back()->withErrors(['csv_file' => 'Не удалось открыть CSV файл.']);
|
||||
}
|
||||
|
||||
$headerRow = fgetcsv($handle, 0, $delimiter);
|
||||
if ($headerRow === false) {
|
||||
fclose($handle);
|
||||
return back()->withErrors(['csv_file' => 'CSV файл пустой.']);
|
||||
}
|
||||
|
||||
$headers = array_map(fn ($header) => $this->normalizeCsvHeader((string) $header), $headerRow);
|
||||
if (!in_array('name', $headers, true) || !in_array('price', $headers, true)) {
|
||||
fclose($handle);
|
||||
return back()->withErrors(['csv_file' => 'В CSV должны быть колонки name и price.']);
|
||||
}
|
||||
|
||||
if (!in_array('category_slug', $headers, true) && !in_array('category_name', $headers, true)) {
|
||||
fclose($handle);
|
||||
return back()->withErrors(['csv_file' => 'В CSV должна быть category_slug или category_name.']);
|
||||
}
|
||||
|
||||
$categories = Category::query()->get();
|
||||
$categoriesBySlug = $categories->keyBy(fn (Category $category) => mb_strtolower($category->slug));
|
||||
$categoriesByName = $categories->keyBy(fn (Category $category) => mb_strtolower($category->name));
|
||||
$allSpecKeys = $this->allSpecKeys();
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
|
||||
if ($this->rowIsEmpty($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowData = $this->mapCsvRow($headers, $row);
|
||||
$category = $this->resolveCategory($rowData, $categoriesBySlug, $categoriesByName);
|
||||
|
||||
$name = trim((string) ($rowData['name'] ?? ''));
|
||||
$price = $this->parseDecimal($rowData['price'] ?? null);
|
||||
$stock = $this->parseInteger($rowData['stock'] ?? 0);
|
||||
|
||||
if (!$category || $name === '' || $price === null || $stock === null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sku = $this->trimToNull((string) ($rowData['sku'] ?? ''));
|
||||
$slugInput = trim((string) ($rowData['slug'] ?? ''));
|
||||
|
||||
$existing = null;
|
||||
if ($slugInput !== '') {
|
||||
$existing = Product::query()->where('slug', Str::slug($slugInput))->first();
|
||||
}
|
||||
|
||||
if (!$existing && $sku !== null) {
|
||||
$existing = Product::query()->where('sku', $sku)->first();
|
||||
}
|
||||
|
||||
$slug = $this->makeUniqueSlug($slugInput !== '' ? $slugInput : $name, $existing?->id);
|
||||
$specs = $this->extractSpecsFromCsvRow($rowData, $allSpecKeys, $category->slug);
|
||||
|
||||
$payload = [
|
||||
'category_id' => $category->id,
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'sku' => $this->trimToNull((string) ($rowData['sku'] ?? '')),
|
||||
'price' => $price,
|
||||
'old_price' => $this->parseDecimal($rowData['old_price'] ?? null),
|
||||
'stock' => $stock,
|
||||
'short_description' => $this->trimToNull((string) ($rowData['short_description'] ?? '')),
|
||||
'description' => $this->trimToNull((string) ($rowData['description'] ?? '')),
|
||||
'is_active' => $this->parseBooleanValue($rowData['is_active'] ?? null, true),
|
||||
'specs' => $specs,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($payload);
|
||||
$updated++;
|
||||
} else {
|
||||
Product::create($payload);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return redirect()
|
||||
->route('admin.products.index')
|
||||
->with('status', "Импорт завершен: создано {$created}, обновлено {$updated}, пропущено {$skipped}.");
|
||||
}
|
||||
|
||||
private function validateProduct(Request $request, ?Product $product = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'category_id' => ['required', 'integer', 'exists:categories,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('products', 'slug')->ignore($product?->id),
|
||||
],
|
||||
'sku' => ['nullable', 'string', 'max:255'],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
'old_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'stock' => ['required', 'integer', 'min:0'],
|
||||
'short_description' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'gallery_images' => ['nullable', 'array', 'max:12'],
|
||||
'gallery_images.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'remove_image' => ['nullable', 'boolean'],
|
||||
'remove_gallery_paths' => ['nullable', 'array'],
|
||||
'remove_gallery_paths.*' => ['string', 'max:255'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'specs' => ['nullable', 'array'],
|
||||
'specs.*' => ['nullable', 'string', 'max:255'],
|
||||
'specs_text' => ['nullable', 'string'],
|
||||
]) + ['is_active' => $request->boolean('is_active')];
|
||||
}
|
||||
|
||||
private function makeUniqueSlug(string $value, ?int $ignoreId = null): string
|
||||
{
|
||||
$base = Str::slug($value);
|
||||
if ($base === '') {
|
||||
$base = 'product';
|
||||
}
|
||||
|
||||
$slug = $base;
|
||||
$index = 2;
|
||||
|
||||
while (
|
||||
Product::query()
|
||||
->where('slug', $slug)
|
||||
->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))
|
||||
->exists()
|
||||
) {
|
||||
$slug = "{$base}-{$index}";
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function parseSpecs(array $specsInput = [], ?string $specsText = null, ?string $categorySlug = null): array
|
||||
{
|
||||
$specs = [];
|
||||
|
||||
foreach ($specsInput as $key => $value) {
|
||||
$normalizedKey = $this->normalizeSpecKey((string) $key);
|
||||
$normalizedValue = trim((string) $value);
|
||||
|
||||
if ($normalizedKey === '' || $normalizedValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$specs[$normalizedKey] = $normalizedValue;
|
||||
}
|
||||
|
||||
if ($specsText !== null && trim($specsText) !== '') {
|
||||
$specs = array_merge($this->parseSpecsFromText($specsText), $specs);
|
||||
}
|
||||
|
||||
return $this->filterSpecsByCategory($specs, $categorySlug);
|
||||
}
|
||||
|
||||
private function parseSpecsFromText(string $specsText): array
|
||||
{
|
||||
$rows = preg_split('/\r\n|\r|\n/', $specsText);
|
||||
$specs = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$line = trim($row);
|
||||
if ($line === '' || !str_contains($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = array_map('trim', explode(':', $line, 2));
|
||||
$key = $this->normalizeSpecKey($key);
|
||||
|
||||
if ($key === '' || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$specs[$key] = $value;
|
||||
}
|
||||
|
||||
return $specs;
|
||||
}
|
||||
|
||||
private function normalizeSpecKey(string $key): string
|
||||
{
|
||||
return Str::of($key)
|
||||
->lower()
|
||||
->replace(' ', '_')
|
||||
->replace('-', '_')
|
||||
->trim()
|
||||
->toString();
|
||||
}
|
||||
|
||||
private function categorySpecDefinitionsById(Collection $categories): array
|
||||
{
|
||||
$specDefinitions = $this->specDefinitions();
|
||||
$savedValuesByCategory = $this->savedSpecValuesByCategory($categories);
|
||||
|
||||
return $categories
|
||||
->mapWithKeys(function (Category $category) use ($specDefinitions, $savedValuesByCategory) {
|
||||
$definitions = $specDefinitions[$category->slug] ?? [];
|
||||
$categoryValues = $savedValuesByCategory[$category->id] ?? [];
|
||||
|
||||
$prepared = array_map(function (array $definition) use ($categoryValues) {
|
||||
$key = (string) ($definition['key'] ?? '');
|
||||
if ($key === '') {
|
||||
return $definition;
|
||||
}
|
||||
|
||||
$presetOptions = collect((array) ($definition['options'] ?? []))
|
||||
->map(fn ($value) => trim((string) $value))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$savedOptions = collect($categoryValues[$key] ?? [])
|
||||
->map(fn ($value) => trim((string) $value))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$definition['options'] = $presetOptions
|
||||
->concat($savedOptions)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $definition;
|
||||
}, $definitions);
|
||||
|
||||
return [$category->id => $prepared];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function specDefinitions(): array
|
||||
{
|
||||
return config('product_specs.categories', []);
|
||||
}
|
||||
|
||||
private function allSpecKeys(): array
|
||||
{
|
||||
return collect($this->specDefinitions())
|
||||
->flatten(1)
|
||||
->pluck('key')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function savedSpecValuesByCategory(Collection $categories): array
|
||||
{
|
||||
$categoryIds = $categories->pluck('id')->filter()->values();
|
||||
if ($categoryIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = [];
|
||||
|
||||
Product::query()
|
||||
->select(['id', 'category_id', 'specs'])
|
||||
->whereIn('category_id', $categoryIds->all())
|
||||
->whereNotNull('specs')
|
||||
->chunkById(200, function (EloquentCollection $products) use (&$values) {
|
||||
foreach ($products as $product) {
|
||||
$specs = (array) ($product->specs ?? []);
|
||||
if ($specs === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($specs as $key => $value) {
|
||||
if (!is_scalar($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedKey = $this->normalizeSpecKey((string) $key);
|
||||
$normalizedValue = trim((string) $value);
|
||||
|
||||
if ($normalizedKey === '' || $normalizedValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$product->category_id][$normalizedKey][$normalizedValue] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($values as $categoryId => $specValues) {
|
||||
foreach ($specValues as $key => $valueSet) {
|
||||
$options = array_keys($valueSet);
|
||||
natcasesort($options);
|
||||
$values[$categoryId][$key] = array_values($options);
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function filterSpecsByCategory(array $specs, ?string $categorySlug): array
|
||||
{
|
||||
if (!$categorySlug) {
|
||||
return $specs;
|
||||
}
|
||||
|
||||
$allowedKeys = collect($this->specDefinitions()[$categorySlug] ?? [])
|
||||
->pluck('key')
|
||||
->all();
|
||||
|
||||
if ($allowedKeys === []) {
|
||||
return $specs;
|
||||
}
|
||||
|
||||
return collect($specs)->only($allowedKeys)->all();
|
||||
}
|
||||
|
||||
private function openCsv(string $path): array
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
return [null, ';'];
|
||||
}
|
||||
|
||||
$sample = fgets($handle);
|
||||
rewind($handle);
|
||||
|
||||
$delimiter = substr_count((string) $sample, ';') >= substr_count((string) $sample, ',') ? ';' : ',';
|
||||
|
||||
return [$handle, $delimiter];
|
||||
}
|
||||
|
||||
private function normalizeCsvHeader(string $header): string
|
||||
{
|
||||
$value = str_replace("\xEF\xBB\xBF", '', $header);
|
||||
|
||||
return Str::of($value)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace(' ', '_')
|
||||
->replace('-', '_')
|
||||
->toString();
|
||||
}
|
||||
|
||||
private function rowIsEmpty(array $row): bool
|
||||
{
|
||||
foreach ($row as $value) {
|
||||
if (trim((string) $value) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function mapCsvRow(array $headers, array $row): array
|
||||
{
|
||||
$row = array_pad($row, count($headers), '');
|
||||
$mapped = [];
|
||||
|
||||
foreach ($headers as $index => $header) {
|
||||
if ($header === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped[$header] = trim((string) ($row[$index] ?? ''));
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function resolveCategory(array $rowData, Collection $bySlug, Collection $byName): ?Category
|
||||
{
|
||||
$slug = mb_strtolower(trim((string) ($rowData['category_slug'] ?? '')));
|
||||
if ($slug !== '' && $bySlug->has($slug)) {
|
||||
return $bySlug->get($slug);
|
||||
}
|
||||
|
||||
$name = mb_strtolower(trim((string) ($rowData['category_name'] ?? '')));
|
||||
if ($name !== '' && $byName->has($name)) {
|
||||
return $byName->get($name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractSpecsFromCsvRow(array $rowData, array $allSpecKeys, ?string $categorySlug): array
|
||||
{
|
||||
$specs = [];
|
||||
|
||||
foreach ($allSpecKeys as $key) {
|
||||
$value = trim((string) ($rowData[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
$specs[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$specsJson = trim((string) ($rowData['specs_json'] ?? ''));
|
||||
if ($specsJson !== '') {
|
||||
$decoded = json_decode($specsJson, true);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $key => $value) {
|
||||
if (!is_scalar($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedKey = $this->normalizeSpecKey((string) $key);
|
||||
$normalizedValue = trim((string) $value);
|
||||
|
||||
if ($normalizedKey === '' || $normalizedValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$specs[$normalizedKey] = $normalizedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->filterSpecsByCategory($specs, $categorySlug);
|
||||
}
|
||||
|
||||
private function parseDecimal(mixed $value): ?float
|
||||
{
|
||||
$string = trim((string) $value);
|
||||
if ($string === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = str_replace([' ', ','], ['', '.'], $string);
|
||||
if (!is_numeric($normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decimal = (float) $normalized;
|
||||
|
||||
return $decimal < 0 ? null : $decimal;
|
||||
}
|
||||
|
||||
private function parseInteger(mixed $value): ?int
|
||||
{
|
||||
$string = trim((string) $value);
|
||||
if ($string === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!preg_match('/^-?\d+$/', $string)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$integer = (int) $string;
|
||||
|
||||
return $integer < 0 ? null : $integer;
|
||||
}
|
||||
|
||||
private function parseBooleanValue(mixed $value, bool $default = true): bool
|
||||
{
|
||||
$string = mb_strtolower(trim((string) $value));
|
||||
if ($string === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return match ($string) {
|
||||
'1', 'true', 'yes', 'y', 'да', 'active', 'on' => true,
|
||||
'0', 'false', 'no', 'n', 'нет', 'inactive', 'off' => false,
|
||||
default => $default,
|
||||
};
|
||||
}
|
||||
|
||||
private function storeImage(Request $request): string
|
||||
{
|
||||
$file = $request->file('image');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->storeUploadedImage($file);
|
||||
}
|
||||
|
||||
private function storeGalleryImages(Request $request): array
|
||||
{
|
||||
$files = $request->file('gallery_images', []);
|
||||
if (!is_array($files)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach ($files as $file) {
|
||||
if (!$file instanceof UploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $this->storeUploadedImage($file);
|
||||
if ($path !== '') {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeImagePaths($paths);
|
||||
}
|
||||
|
||||
private function storeUploadedImage(UploadedFile $file): string
|
||||
{
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
if ($extension === '') {
|
||||
$extension = 'jpg';
|
||||
}
|
||||
|
||||
$name = now()->format('YmdHis') . '-' . Str::uuid() . '.' . $extension;
|
||||
|
||||
return (string) $file->storeAs('products', $name, 'public');
|
||||
}
|
||||
|
||||
private function deleteImage(?string $path): void
|
||||
{
|
||||
if (!$path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'uploads/')) {
|
||||
$filePath = public_path(ltrim($path, '/'));
|
||||
if (File::exists($filePath)) {
|
||||
File::delete($filePath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
|
||||
private function deleteImages(array $paths): void
|
||||
{
|
||||
foreach ($this->normalizeImagePaths($paths) as $path) {
|
||||
$this->deleteImage($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeImagePaths(array $paths): array
|
||||
{
|
||||
return collect($paths)
|
||||
->filter(fn ($path) => is_string($path) && trim($path) !== '')
|
||||
->map(fn (string $path) => trim($path))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeProductImageSet(?string $imagePath, array $galleryPaths): array
|
||||
{
|
||||
$normalizedImagePath = $this->trimToNull((string) $imagePath);
|
||||
$normalizedGalleryPaths = $this->normalizeImagePaths($galleryPaths);
|
||||
|
||||
if ($normalizedImagePath === null && $normalizedGalleryPaths !== []) {
|
||||
$normalizedImagePath = array_shift($normalizedGalleryPaths);
|
||||
}
|
||||
|
||||
if ($normalizedImagePath !== null) {
|
||||
$normalizedGalleryPaths = array_values(array_filter(
|
||||
$normalizedGalleryPaths,
|
||||
fn (string $path) => $path !== $normalizedImagePath
|
||||
));
|
||||
}
|
||||
|
||||
return [$normalizedImagePath, $normalizedGalleryPaths];
|
||||
}
|
||||
|
||||
private function trimToNull(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/Concerns/ManagesCaptcha.php
Normal file
39
app/Http/Controllers/Concerns/ManagesCaptcha.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ManagesCaptcha
|
||||
{
|
||||
protected function buildCaptchaQuestion(Request $request, string $context): string
|
||||
{
|
||||
$first = random_int(2, 9);
|
||||
$second = random_int(1, 9);
|
||||
$isSubtraction = random_int(0, 1) === 1;
|
||||
|
||||
if ($isSubtraction && $second > $first) {
|
||||
[$first, $second] = [$second, $first];
|
||||
}
|
||||
|
||||
$question = $isSubtraction ? "{$first} - {$second}" : "{$first} + {$second}";
|
||||
$answer = (string) ($isSubtraction ? $first - $second : $first + $second);
|
||||
|
||||
$request->session()->put("captcha.{$context}.answer", $answer);
|
||||
|
||||
return $question;
|
||||
}
|
||||
|
||||
protected function captchaIsValid(Request $request, string $context, string $field = 'captcha'): bool
|
||||
{
|
||||
$expected = (string) $request->session()->get("captcha.{$context}.answer", '');
|
||||
$provided = trim((string) $request->input($field, ''));
|
||||
|
||||
return $expected !== '' && hash_equals($expected, $provided);
|
||||
}
|
||||
|
||||
protected function clearCaptcha(Request $request, string $context): void
|
||||
{
|
||||
$request->session()->forget("captcha.{$context}.answer");
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
33
app/Http/Controllers/Shop/AccountController.php
Normal file
33
app/Http/Controllers/Shop/AccountController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$user = $request->user()->load(['orders' => function ($query) {
|
||||
$query->latest('id');
|
||||
}]);
|
||||
|
||||
return view('shop.account', [
|
||||
'user' => $user,
|
||||
'orders' => $user->orders,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
|
||||
]);
|
||||
|
||||
$request->user()->update($validated);
|
||||
|
||||
return back()->with('status', 'Данные профиля обновлены.');
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/Shop/AuthController.php
Normal file
102
app/Http/Controllers/Shop/AuthController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Concerns\ManagesCaptcha;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
use ManagesCaptcha;
|
||||
|
||||
private const LOGIN_CAPTCHA_CONTEXT = 'shop_login';
|
||||
private const REGISTER_CAPTCHA_CONTEXT = 'shop_register';
|
||||
|
||||
public function showLoginForm(Request $request)
|
||||
{
|
||||
return view('shop.auth.login', [
|
||||
'captchaQuestion' => $this->buildCaptchaQuestion($request, self::LOGIN_CAPTCHA_CONTEXT),
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
'captcha' => ['required', 'string', 'max:10'],
|
||||
]);
|
||||
|
||||
if (!$this->captchaIsValid($request, self::LOGIN_CAPTCHA_CONTEXT)) {
|
||||
return back()
|
||||
->withInput($request->only('email', 'remember'))
|
||||
->withErrors(['captcha' => 'Неверный ответ на капчу.']);
|
||||
}
|
||||
|
||||
$credentials = [
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
];
|
||||
|
||||
$remember = $request->boolean('remember');
|
||||
|
||||
if (!Auth::attempt($credentials, $remember)) {
|
||||
return back()
|
||||
->withInput($request->only('email', 'remember'))
|
||||
->withErrors(['email' => 'Неверный email или пароль.']);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
$this->clearCaptcha($request, self::LOGIN_CAPTCHA_CONTEXT);
|
||||
|
||||
return redirect()->intended(route('account'));
|
||||
}
|
||||
|
||||
public function showRegisterForm(Request $request)
|
||||
{
|
||||
return view('shop.auth.register', [
|
||||
'captchaQuestion' => $this->buildCaptchaQuestion($request, self::REGISTER_CAPTCHA_CONTEXT),
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'captcha' => ['required', 'string', 'max:10'],
|
||||
]);
|
||||
|
||||
if (!$this->captchaIsValid($request, self::REGISTER_CAPTCHA_CONTEXT)) {
|
||||
return back()
|
||||
->withInput($request->only('name', 'email'))
|
||||
->withErrors(['captcha' => 'Неверный ответ на капчу.']);
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
$this->clearCaptcha($request, self::REGISTER_CAPTCHA_CONTEXT);
|
||||
|
||||
return redirect()->route('account');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('home');
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Shop/CartController.php
Normal file
106
app/Http/Controllers/Shop/CartController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CartController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$cart = (array) session()->get('cart', []);
|
||||
$products = Product::query()
|
||||
->whereIn('id', array_keys($cart))
|
||||
->where('is_active', true)
|
||||
->with('category')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$items = collect($cart)
|
||||
->map(function ($quantity, $productId) use ($products) {
|
||||
$product = $products->get((int) $productId);
|
||||
if (!$product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$qty = max(1, (int) $quantity);
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'quantity' => $qty,
|
||||
'subtotal' => (float) $product->price * $qty,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return view('shop.cart', [
|
||||
'items' => $items,
|
||||
'itemsCount' => $items->sum('quantity'),
|
||||
'total' => $items->sum('subtotal'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function add(Product $product)
|
||||
{
|
||||
if (!$product->is_active || $product->stock < 1) {
|
||||
return back()->with('status', 'Товар сейчас недоступен для заказа.');
|
||||
}
|
||||
|
||||
$cart = (array) session()->get('cart', []);
|
||||
$current = (int) ($cart[$product->id] ?? 0);
|
||||
|
||||
if ($current >= $product->stock) {
|
||||
return back()->with('status', 'В корзине уже максимальное доступное количество.');
|
||||
}
|
||||
|
||||
$cart[$product->id] = $current + 1;
|
||||
session()->put('cart', $cart);
|
||||
|
||||
return back()->with('status', "Товар \"{$product->name}\" добавлен в корзину.");
|
||||
}
|
||||
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:1', 'max:99'],
|
||||
]);
|
||||
|
||||
$cart = (array) session()->get('cart', []);
|
||||
if (!isset($cart[$product->id])) {
|
||||
return back();
|
||||
}
|
||||
|
||||
$available = max(0, (int) $product->stock);
|
||||
$quantity = min((int) $validated['quantity'], $available);
|
||||
|
||||
if ($quantity < 1) {
|
||||
unset($cart[$product->id]);
|
||||
session()->put('cart', $cart);
|
||||
|
||||
return back()->with('status', "Товар \"{$product->name}\" удален из корзины.");
|
||||
}
|
||||
|
||||
$cart[$product->id] = $quantity;
|
||||
session()->put('cart', $cart);
|
||||
|
||||
$message = $quantity < (int) $validated['quantity']
|
||||
? 'Количество ограничено текущим остатком.'
|
||||
: 'Количество товара обновлено.';
|
||||
|
||||
return back()->with('status', $message);
|
||||
}
|
||||
|
||||
public function remove(Product $product)
|
||||
{
|
||||
$cart = (array) session()->get('cart', []);
|
||||
if (isset($cart[$product->id])) {
|
||||
unset($cart[$product->id]);
|
||||
session()->put('cart', $cart);
|
||||
}
|
||||
|
||||
return back()->with('status', "Товар \"{$product->name}\" удален из корзины.");
|
||||
}
|
||||
}
|
||||
325
app/Http/Controllers/Shop/CatalogController.php
Normal file
325
app/Http/Controllers/Shop/CatalogController.php
Normal file
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CatalogController extends Controller
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$searchQuery = trim((string) $request->input('q', ''));
|
||||
$query = Product::query()
|
||||
->where('is_active', true)
|
||||
->with('category');
|
||||
|
||||
if ($searchQuery === '') {
|
||||
$query->whereRaw('1 = 0');
|
||||
} else {
|
||||
$this->applyNameSearch($query, $searchQuery);
|
||||
}
|
||||
|
||||
$sort = $request->string('sort')->toString();
|
||||
match ($sort) {
|
||||
'price_asc' => $query->orderBy('price'),
|
||||
'price_desc' => $query->orderByDesc('price'),
|
||||
'name_asc' => $query->orderBy('name'),
|
||||
default => $query->orderByDesc('id'),
|
||||
};
|
||||
|
||||
$products = $query->paginate(self::PER_PAGE)->withQueryString();
|
||||
|
||||
return view('shop.search', [
|
||||
'products' => $products,
|
||||
'searchQuery' => $searchQuery,
|
||||
'sort' => $sort ?: 'newest',
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Product::query()->where('is_active', true)->with('category');
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$this->applyNameSearch($query, (string) $request->input('q'));
|
||||
}
|
||||
|
||||
if ($request->filled('category')) {
|
||||
$query->whereHas('category', function ($builder) use ($request) {
|
||||
$builder->where('slug', $request->string('category'));
|
||||
});
|
||||
}
|
||||
|
||||
$products = $query->orderByDesc('id')->paginate(self::PER_PAGE)->withQueryString();
|
||||
$categories = Category::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('shop.catalog', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
public function category(Request $request, Category $category)
|
||||
{
|
||||
$baseQuery = Product::query()
|
||||
->where('category_id', $category->id)
|
||||
->where('is_active', true);
|
||||
$query = (clone $baseQuery)->with('category');
|
||||
|
||||
$appliedFilters = collect((array) $request->input('filters', []))
|
||||
->map(function ($value) {
|
||||
if (!is_scalar($value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $value);
|
||||
})
|
||||
->filter(fn (string $value) => $value !== '')
|
||||
->all();
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$searchTerm = (string) $request->input('q');
|
||||
$this->applyNameSearch($baseQuery, $searchTerm);
|
||||
$this->applyNameSearch($query, $searchTerm);
|
||||
}
|
||||
|
||||
$filters = $this->filtersForCategory($category->slug);
|
||||
$priceBounds = $this->priceBounds($baseQuery);
|
||||
|
||||
$priceFrom = $this->parseFilterNumber($request->input('price_from'));
|
||||
if ($priceFrom !== null) {
|
||||
$query->where('price', '>=', $priceFrom);
|
||||
}
|
||||
|
||||
$priceTo = $this->parseFilterNumber($request->input('price_to'));
|
||||
if ($priceTo !== null) {
|
||||
$query->where('price', '<=', $priceTo);
|
||||
}
|
||||
|
||||
$rangeFilters = [];
|
||||
foreach ($filters as $filter) {
|
||||
if (!$this->isRangeFilter($filter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = (string) ($filter['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bounds = $this->numericSpecBounds($baseQuery, $key);
|
||||
$fromParam = $key . '_from';
|
||||
$toParam = $key . '_to';
|
||||
|
||||
$rangeFrom = $this->parseFilterNumber($request->input($fromParam));
|
||||
if ($rangeFrom !== null) {
|
||||
$query->whereRaw(
|
||||
"NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric >= ?",
|
||||
[$key, $rangeFrom]
|
||||
);
|
||||
}
|
||||
|
||||
$rangeTo = $this->parseFilterNumber($request->input($toParam));
|
||||
if ($rangeTo !== null) {
|
||||
$query->whereRaw(
|
||||
"NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric <= ?",
|
||||
[$key, $rangeTo]
|
||||
);
|
||||
}
|
||||
|
||||
$rangeFilters[$key] = [
|
||||
'from' => $request->filled($fromParam)
|
||||
? trim((string) $request->input($fromParam))
|
||||
: $this->formatFilterNumber($bounds['min']),
|
||||
'to' => $request->filled($toParam)
|
||||
? trim((string) $request->input($toParam))
|
||||
: $this->formatFilterNumber($bounds['max']),
|
||||
'min' => $this->formatFilterNumber($bounds['min']),
|
||||
'max' => $this->formatFilterNumber($bounds['max']),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($filters as $filter) {
|
||||
if ($this->isRangeFilter($filter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = (string) ($filter['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $appliedFilters[$key] ?? null;
|
||||
if ($value !== null) {
|
||||
$query->whereRaw("specs->>? = ?", [$key, $value]);
|
||||
}
|
||||
}
|
||||
|
||||
$sort = $request->string('sort')->toString();
|
||||
match ($sort) {
|
||||
'price_asc' => $query->orderBy('price'),
|
||||
'price_desc' => $query->orderByDesc('price'),
|
||||
'name_asc' => $query->orderBy('name'),
|
||||
default => $query->orderByDesc('id'),
|
||||
};
|
||||
|
||||
$products = $query->paginate(self::PER_PAGE)->withQueryString();
|
||||
$filterOptions = $this->filterOptions($category, $filters);
|
||||
$priceFilter = [
|
||||
'from' => $request->filled('price_from')
|
||||
? trim((string) $request->input('price_from'))
|
||||
: $this->formatFilterNumber($priceBounds['min']),
|
||||
'to' => $request->filled('price_to')
|
||||
? trim((string) $request->input('price_to'))
|
||||
: $this->formatFilterNumber($priceBounds['max']),
|
||||
'min' => $this->formatFilterNumber($priceBounds['min']),
|
||||
'max' => $this->formatFilterNumber($priceBounds['max']),
|
||||
];
|
||||
|
||||
return view('shop.category', [
|
||||
'category' => $category,
|
||||
'products' => $products,
|
||||
'filters' => $filters,
|
||||
'filterOptions' => $filterOptions,
|
||||
'appliedFilters' => $appliedFilters,
|
||||
'rangeFilters' => $rangeFilters,
|
||||
'priceFilter' => $priceFilter,
|
||||
'sort' => $sort ?: 'newest',
|
||||
]);
|
||||
}
|
||||
|
||||
private function filtersForCategory(string $slug): array
|
||||
{
|
||||
return config('product_specs.categories.' . $slug, []);
|
||||
}
|
||||
|
||||
private function filterOptions(Category $category, array $filters): array
|
||||
{
|
||||
if (empty($filters)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$specs = $category->products()
|
||||
->where('is_active', true)
|
||||
->pluck('specs');
|
||||
|
||||
$options = [];
|
||||
foreach ($filters as $filter) {
|
||||
if ($this->isRangeFilter($filter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = (string) ($filter['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$valuesFromProducts = $specs
|
||||
->pluck($key)
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$presetValues = collect((array) ($filter['options'] ?? []))
|
||||
->filter(fn ($value) => $value !== null && $value !== '')
|
||||
->map(fn ($value) => (string) $value)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$options[$key] = collect($presetValues)
|
||||
->concat($valuesFromProducts)
|
||||
->map(fn ($value) => (string) $value)
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function applyNameSearch(Builder $query, string $term): void
|
||||
{
|
||||
$normalizedTerm = mb_strtolower(trim($term));
|
||||
if ($normalizedTerm === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereRaw('LOWER(name) LIKE ?', ['%' . $normalizedTerm . '%']);
|
||||
}
|
||||
|
||||
private function isRangeFilter(array $filter): bool
|
||||
{
|
||||
return (string) ($filter['filter'] ?? 'select') === 'range';
|
||||
}
|
||||
|
||||
private function parseFilterNumber(mixed $value): ?float
|
||||
{
|
||||
$string = trim((string) $value);
|
||||
if ($string === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = str_replace([' ', ','], ['', '.'], $string);
|
||||
if (!is_numeric($normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $normalized;
|
||||
}
|
||||
|
||||
private function formatFilterNumber(?float $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (abs($value - round($value)) < 0.000001) {
|
||||
return (string) (int) round($value);
|
||||
}
|
||||
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
private function priceBounds(Builder $query): array
|
||||
{
|
||||
$bounds = (clone $query)
|
||||
->selectRaw('MIN(price) as min_value, MAX(price) as max_value')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'min' => $this->parseFilterNumber($bounds?->min_value),
|
||||
'max' => $this->parseFilterNumber($bounds?->max_value),
|
||||
];
|
||||
}
|
||||
|
||||
private function numericSpecBounds(Builder $query, string $key): array
|
||||
{
|
||||
if ($key === '') {
|
||||
return ['min' => null, 'max' => null];
|
||||
}
|
||||
|
||||
$bounds = (clone $query)
|
||||
->selectRaw(
|
||||
"MIN(NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric) as min_value, MAX(NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric) as max_value",
|
||||
[$key, $key]
|
||||
)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'min' => $this->parseFilterNumber($bounds?->min_value),
|
||||
'max' => $this->parseFilterNumber($bounds?->max_value),
|
||||
];
|
||||
}
|
||||
}
|
||||
199
app/Http/Controllers/Shop/ChatController.php
Normal file
199
app/Http/Controllers/Shop/ChatController.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ChatConversation;
|
||||
use App\Models\ChatMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
private const SESSION_TOKEN_KEY = 'chat.visitor_token';
|
||||
|
||||
public function messages(Request $request): JsonResponse
|
||||
{
|
||||
$conversation = $this->resolveConversation($request);
|
||||
$messages = $this->conversationMessages($conversation);
|
||||
|
||||
$conversation->messages()
|
||||
->where('sender', 'admin')
|
||||
->where('is_read', false)
|
||||
->update(['is_read' => true]);
|
||||
|
||||
return response()->json([
|
||||
'conversation' => $this->conversationPayload($conversation),
|
||||
'conversation_id' => $conversation->id,
|
||||
'messages' => $messages->map(fn (ChatMessage $message) => $this->messagePayload($message))->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$messageText = $this->sanitizeMessage((string) $validated['message']);
|
||||
if ($messageText === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'message' => 'Сообщение содержит недопустимые символы.',
|
||||
]);
|
||||
}
|
||||
|
||||
$conversation = $this->resolveConversationForWriting($request);
|
||||
$message = $conversation->messages()->create([
|
||||
'sender' => 'customer',
|
||||
'body' => $messageText,
|
||||
'is_read' => false,
|
||||
]);
|
||||
|
||||
$conversation->update([
|
||||
'status' => 'open',
|
||||
'last_message_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'conversation' => $this->conversationPayload($conversation),
|
||||
'message' => $this->messagePayload($message),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveConversation(Request $request): ChatConversation
|
||||
{
|
||||
$token = $this->sessionToken($request);
|
||||
$user = $request->user();
|
||||
|
||||
if (!$user) {
|
||||
$conversation = ChatConversation::query()->where('visitor_token', $token)->first();
|
||||
if ($conversation && $conversation->user_id !== null) {
|
||||
$token = $this->rotateSessionToken($request);
|
||||
$conversation = null;
|
||||
}
|
||||
|
||||
return $conversation ?: ChatConversation::query()->firstOrCreate(
|
||||
['visitor_token' => $token],
|
||||
['status' => 'open']
|
||||
);
|
||||
}
|
||||
|
||||
$userConversation = ChatConversation::query()
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($userConversation) {
|
||||
return $userConversation;
|
||||
}
|
||||
|
||||
$guestConversation = ChatConversation::query()
|
||||
->where('visitor_token', $token)
|
||||
->whereNull('user_id')
|
||||
->first();
|
||||
|
||||
if ($guestConversation) {
|
||||
$guestConversation->update(['user_id' => $user->id]);
|
||||
return $guestConversation;
|
||||
}
|
||||
|
||||
return ChatConversation::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'visitor_token' => $this->generateUniqueToken(),
|
||||
'status' => ChatConversation::STATUS_OPEN,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveConversationForWriting(Request $request): ChatConversation
|
||||
{
|
||||
$conversation = $this->resolveConversation($request);
|
||||
if (!$conversation->isClosed()) {
|
||||
return $conversation;
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return ChatConversation::query()->create([
|
||||
'visitor_token' => $this->rotateSessionToken($request),
|
||||
'status' => ChatConversation::STATUS_OPEN,
|
||||
]);
|
||||
}
|
||||
|
||||
return ChatConversation::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'visitor_token' => $this->generateUniqueToken(),
|
||||
'status' => ChatConversation::STATUS_OPEN,
|
||||
]);
|
||||
}
|
||||
|
||||
private function conversationMessages(ChatConversation $conversation)
|
||||
{
|
||||
return $conversation->messages()
|
||||
->latest('id')
|
||||
->limit(100)
|
||||
->get()
|
||||
->reverse()
|
||||
->values();
|
||||
}
|
||||
|
||||
private function messagePayload(ChatMessage $message): array
|
||||
{
|
||||
return [
|
||||
'id' => $message->id,
|
||||
'sender' => $message->sender,
|
||||
'body' => $message->body,
|
||||
'created_at' => $message->created_at?->toIso8601String(),
|
||||
'time' => $message->created_at?->format('H:i'),
|
||||
];
|
||||
}
|
||||
|
||||
private function conversationPayload(ChatConversation $conversation): array
|
||||
{
|
||||
return [
|
||||
'id' => $conversation->id,
|
||||
'status' => $conversation->status,
|
||||
'is_closed' => $conversation->isClosed(),
|
||||
'notice' => $conversation->isClosed()
|
||||
? 'Чат закрыт администратором. Отправьте новое сообщение, чтобы начать новый диалог.'
|
||||
: null,
|
||||
];
|
||||
}
|
||||
|
||||
private function sanitizeMessage(string $value): string
|
||||
{
|
||||
$text = strip_tags($value);
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text) ?? $text;
|
||||
|
||||
return trim(Str::limit($text, 2000, ''));
|
||||
}
|
||||
|
||||
private function sessionToken(Request $request): string
|
||||
{
|
||||
$token = (string) $request->session()->get(self::SESSION_TOKEN_KEY, '');
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->rotateSessionToken($request);
|
||||
}
|
||||
|
||||
private function rotateSessionToken(Request $request): string
|
||||
{
|
||||
$token = $this->generateUniqueToken();
|
||||
$request->session()->put(self::SESSION_TOKEN_KEY, $token);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function generateUniqueToken(): string
|
||||
{
|
||||
do {
|
||||
$token = (string) Str::uuid();
|
||||
} while (ChatConversation::query()->where('visitor_token', $token)->exists());
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
174
app/Http/Controllers/Shop/CheckoutController.php
Normal file
174
app/Http/Controllers/Shop/CheckoutController.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CheckoutController extends Controller
|
||||
{
|
||||
private const CHECKOUT_CUSTOMER_KEY = 'checkout.customer';
|
||||
|
||||
public function show(Request $request)
|
||||
{
|
||||
$items = $this->cartItems($request);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
|
||||
}
|
||||
|
||||
return view('shop.checkout', [
|
||||
'items' => $items,
|
||||
'total' => $items->sum('subtotal'),
|
||||
'itemsCount' => $items->sum('quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function prepare(Request $request)
|
||||
{
|
||||
$items = $this->cartItems($request);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'customer_name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'address' => ['required', 'string', 'max:500'],
|
||||
'comment' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$validated['payment_method'] = 'card_transfer';
|
||||
$request->session()->put(self::CHECKOUT_CUSTOMER_KEY, $validated);
|
||||
|
||||
return redirect()->route('checkout.payment');
|
||||
}
|
||||
|
||||
public function payment(Request $request)
|
||||
{
|
||||
$items = $this->cartItems($request);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
|
||||
}
|
||||
|
||||
$customer = $request->session()->get(self::CHECKOUT_CUSTOMER_KEY);
|
||||
if (!is_array($customer)) {
|
||||
return redirect()->route('checkout.show')->with('status', 'Сначала заполните данные получателя.');
|
||||
}
|
||||
|
||||
return view('shop.checkout-payment', [
|
||||
'items' => $items,
|
||||
'total' => $items->sum('subtotal'),
|
||||
'itemsCount' => $items->sum('quantity'),
|
||||
'customer' => $customer,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$items = $this->cartItems($request);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
|
||||
}
|
||||
|
||||
$validated = $request->session()->get(self::CHECKOUT_CUSTOMER_KEY);
|
||||
if (!is_array($validated)) {
|
||||
return redirect()->route('checkout.show')->with('status', 'Сначала заполните данные получателя.');
|
||||
}
|
||||
|
||||
$validator = validator($validated, [
|
||||
'customer_name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => ['nullable', 'string', 'max:50'],
|
||||
'address' => ['required', 'string', 'max:500'],
|
||||
'comment' => ['nullable', 'string', 'max:1000'],
|
||||
'payment_method' => ['required', 'string', Rule::in(['card_transfer'])],
|
||||
]);
|
||||
$validated = $validator->validate();
|
||||
|
||||
$order = DB::transaction(function () use ($request, $items, $validated) {
|
||||
$order = Order::create([
|
||||
'user_id' => optional($request->user())->id,
|
||||
'status' => 'new',
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'total' => $items->sum('subtotal'),
|
||||
'items_count' => $items->sum('quantity'),
|
||||
'customer_name' => $validated['customer_name'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'] ?? null,
|
||||
'address' => $validated['address'],
|
||||
'comment' => $validated['comment'] ?? null,
|
||||
]);
|
||||
|
||||
$rows = $items->map(function (array $item) use ($order) {
|
||||
return [
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $item['product']->id,
|
||||
'name' => $item['product']->name,
|
||||
'price' => $item['product']->price,
|
||||
'quantity' => $item['quantity'],
|
||||
'subtotal' => $item['subtotal'],
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
})->all();
|
||||
|
||||
OrderItem::insert($rows);
|
||||
|
||||
return $order;
|
||||
});
|
||||
|
||||
$request->session()->forget('cart');
|
||||
$request->session()->forget(self::CHECKOUT_CUSTOMER_KEY);
|
||||
|
||||
return redirect()->route('checkout.success', $order);
|
||||
}
|
||||
|
||||
public function success(Order $order)
|
||||
{
|
||||
return view('shop.checkout-success', [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
private function cartItems(Request $request)
|
||||
{
|
||||
$cart = (array) $request->session()->get('cart', []);
|
||||
|
||||
$products = Product::query()
|
||||
->whereIn('id', array_keys($cart))
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return collect($cart)
|
||||
->map(function ($quantity, $productId) use ($products) {
|
||||
$product = $products->get((int) $productId);
|
||||
if (!$product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$qty = max(1, min((int) $quantity, (int) $product->stock));
|
||||
if ($qty < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'quantity' => $qty,
|
||||
'subtotal' => (float) $product->price * $qty,
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/Shop/CompareController.php
Normal file
119
app/Http/Controllers/Shop/CompareController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CompareController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$compareIds = $this->compareIds();
|
||||
$position = array_flip($compareIds);
|
||||
|
||||
$products = Product::query()
|
||||
->whereIn('id', $compareIds)
|
||||
->where('is_active', true)
|
||||
->with('category')
|
||||
->get()
|
||||
->sortBy(fn (Product $product) => $position[$product->id] ?? PHP_INT_MAX)
|
||||
->values();
|
||||
|
||||
$specKeys = $products
|
||||
->flatMap(fn (Product $product) => array_keys((array) $product->specs))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$specLabels = $specKeys->mapWithKeys(fn (string $key) => [$key => $this->specLabel($key)]);
|
||||
|
||||
return view('shop.compare', [
|
||||
'products' => $products,
|
||||
'specKeys' => $specKeys,
|
||||
'specLabels' => $specLabels,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggle(Product $product)
|
||||
{
|
||||
$compare = $this->compareIds();
|
||||
$exists = in_array($product->id, $compare, true);
|
||||
|
||||
if ($exists) {
|
||||
$compare = array_values(array_filter($compare, fn (int $id) => $id !== $product->id));
|
||||
session()->put('compare', $compare);
|
||||
|
||||
return back()->with('status', "Товар \"{$product->name}\" удален из сравнения.");
|
||||
}
|
||||
|
||||
if (count($compare) >= 4) {
|
||||
return back()->with('status', 'Можно сравнить не более 4 товаров одновременно.');
|
||||
}
|
||||
|
||||
$compare[] = $product->id;
|
||||
session()->put('compare', array_values(array_unique($compare)));
|
||||
|
||||
return back()->with('status', "Товар \"{$product->name}\" добавлен в сравнение.");
|
||||
}
|
||||
|
||||
public function clear()
|
||||
{
|
||||
session()->forget('compare');
|
||||
|
||||
return back()->with('status', 'Список сравнения очищен.');
|
||||
}
|
||||
|
||||
private function compareIds(): array
|
||||
{
|
||||
return array_values(array_map('intval', (array) session()->get('compare', [])));
|
||||
}
|
||||
|
||||
private function specLabel(string $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
'manufacturer' => 'Производитель',
|
||||
'socket_type' => 'Тип сокета',
|
||||
'cpu_type' => 'Тип процессора',
|
||||
'form_factor' => 'Форм-фактор',
|
||||
'cpu_socket' => 'Сокет для процессора',
|
||||
'condition' => 'Состояние',
|
||||
'chipset' => 'Чипсет',
|
||||
'memory_type' => 'Тип памяти',
|
||||
'pcie_version' => 'Версия PCI Express',
|
||||
'wifi_standard' => 'Стандарт Wi-Fi',
|
||||
'max_memory' => 'Максимальный объем памяти',
|
||||
'cache' => 'Объем кэша',
|
||||
'capacity' => 'Объем',
|
||||
'gpu' => 'GPU',
|
||||
'vram' => 'Объем видеопамяти',
|
||||
'vram_type' => 'Тип видеопамяти',
|
||||
'kit' => 'Количество модулей',
|
||||
'frequency' => 'Частота',
|
||||
'power' => 'Мощность',
|
||||
'efficiency' => 'Сертификат 80 Plus',
|
||||
'size' => 'Типоразмер',
|
||||
'gpu_length' => 'Макс. длина видеокарты',
|
||||
'intel_socket' => 'Сокет Intel',
|
||||
'amd_socket' => 'Сокет AMD',
|
||||
'fan_speed' => 'Скорость вращения',
|
||||
'fans' => 'Количество вентиляторов',
|
||||
'type' => 'Тип',
|
||||
'model' => 'Модель',
|
||||
'color' => 'Цвет',
|
||||
'screen_size' => 'Диагональ экрана',
|
||||
'cpu_brand' => 'Производитель процессора',
|
||||
'cpu_model' => 'Модель процессора',
|
||||
'ram' => 'Оперативная память',
|
||||
'storage' => 'Накопитель',
|
||||
'panel' => 'Тип матрицы',
|
||||
'resolution' => 'Разрешение экрана',
|
||||
'refresh_rate' => 'Частота обновления',
|
||||
'smart_tv' => 'Smart TV',
|
||||
'cores' => 'Количество ядер',
|
||||
'gpu_brand' => 'Производитель видеокарты',
|
||||
'gpu_model' => 'Модель видеокарты',
|
||||
default => Str::of($key)->replace('_', ' ')->title()->toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Shop/ContactController.php
Normal file
71
app/Http/Controllers/Shop/ContactController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function submit(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'email' => ['required', 'email:rfc,dns', 'max:190'],
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$botToken = trim((string) config('shop.telegram_bot_token'));
|
||||
$chatId = trim((string) config('shop.telegram_chat_id'));
|
||||
|
||||
if ($botToken === '' || $chatId === '') {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['contact' => 'Не настроена отправка в Telegram. Заполните SHOP_TELEGRAM_BOT_TOKEN и SHOP_TELEGRAM_CHAT_ID.']);
|
||||
}
|
||||
|
||||
$message = $this->buildTelegramMessage($validated, $request);
|
||||
$telegramUrl = sprintf('https://api.telegram.org/bot%s/sendMessage', $botToken);
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout(12)
|
||||
->post($telegramUrl, [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $message,
|
||||
'disable_web_page_preview' => true,
|
||||
]);
|
||||
} catch (Throwable) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['contact' => 'Не удалось отправить заявку в Telegram. Попробуйте еще раз.']);
|
||||
}
|
||||
|
||||
if (!$response->successful() || $response->json('ok') !== true) {
|
||||
return back()
|
||||
->withInput()
|
||||
->withErrors(['contact' => 'Telegram не принял заявку. Проверьте токен бота и chat id.']);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Заявка отправлена. Мы свяжемся с вами в ближайшее время.');
|
||||
}
|
||||
|
||||
private function buildTelegramMessage(array $data, Request $request): string
|
||||
{
|
||||
$siteName = (string) config('shop.company_name', config('app.name', 'PC Shop'));
|
||||
|
||||
return implode("\n", [
|
||||
'Новая заявка с формы контактов',
|
||||
'Сайт: ' . $siteName,
|
||||
'Имя: ' . trim((string) ($data['name'] ?? '')),
|
||||
'Email: ' . trim((string) ($data['email'] ?? '')),
|
||||
'Сообщение:',
|
||||
trim((string) ($data['message'] ?? '')),
|
||||
'IP: ' . $request->ip(),
|
||||
'Дата: ' . now()->format('d.m.Y H:i'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Shop/FavoriteController.php
Normal file
50
app/Http/Controllers/Shop/FavoriteController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$favoriteIds = $this->favoriteIds();
|
||||
$position = array_flip($favoriteIds);
|
||||
|
||||
$products = Product::query()
|
||||
->whereIn('id', $favoriteIds)
|
||||
->where('is_active', true)
|
||||
->with('category')
|
||||
->get()
|
||||
->sortBy(fn (Product $product) => $position[$product->id] ?? PHP_INT_MAX)
|
||||
->values();
|
||||
|
||||
return view('shop.favorites', [
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggle(Product $product)
|
||||
{
|
||||
$favorites = $this->favoriteIds();
|
||||
$exists = in_array($product->id, $favorites, true);
|
||||
|
||||
if ($exists) {
|
||||
$favorites = array_values(array_filter($favorites, fn (int $id) => $id !== $product->id));
|
||||
session()->put('favorites', $favorites);
|
||||
|
||||
return back()->with('status', "Товар \"{$product->name}\" удален из избранного.");
|
||||
}
|
||||
|
||||
$favorites[] = $product->id;
|
||||
session()->put('favorites', array_values(array_unique($favorites)));
|
||||
|
||||
return back()->with('status', "Товар \"{$product->name}\" добавлен в избранное.");
|
||||
}
|
||||
|
||||
private function favoriteIds(): array
|
||||
{
|
||||
return array_values(array_map('intval', (array) session()->get('favorites', [])));
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/Shop/OrderController.php
Normal file
23
app/Http/Controllers/Shop/OrderController.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function show(Request $request, Order $order)
|
||||
{
|
||||
if ((int) $order->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$order->load('items.product');
|
||||
|
||||
return view('shop.order', [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Shop/ProductController.php
Normal file
101
app/Http/Controllers/Shop/ProductController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function show(Product $product)
|
||||
{
|
||||
$product->load('category');
|
||||
$related = Product::query()
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->where('category_id', $product->category_id)
|
||||
->whereKeyNot($product->id)
|
||||
->with('category')
|
||||
->latest('id')
|
||||
->take(4)
|
||||
->get();
|
||||
|
||||
return view('shop.product', [
|
||||
'product' => $product,
|
||||
'specLabels' => $this->specLabelsForProduct($product),
|
||||
'related' => $related,
|
||||
]);
|
||||
}
|
||||
|
||||
private function specLabelsForCategory(?string $slug): array
|
||||
{
|
||||
if (!$slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(config('product_specs.categories.' . $slug, []))
|
||||
->mapWithKeys(fn (array $definition) => [$definition['key'] => $definition['label']])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function specLabelsForProduct(Product $product): array
|
||||
{
|
||||
$labels = $this->specLabelsForCategory($product->category?->slug);
|
||||
$fallback = $this->defaultSpecLabels();
|
||||
|
||||
foreach (array_keys((array) ($product->specs ?? [])) as $key) {
|
||||
if (!isset($labels[$key])) {
|
||||
$labels[$key] = $fallback[$key] ?? str_replace('_', ' ', $key);
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
private function defaultSpecLabels(): array
|
||||
{
|
||||
return [
|
||||
'manufacturer' => 'Производитель',
|
||||
'socket_type' => 'Тип сокета',
|
||||
'cpu_type' => 'Тип процессора',
|
||||
'form_factor' => 'Форм-фактор',
|
||||
'cpu_socket' => 'Сокет для процессора',
|
||||
'condition' => 'Состояние',
|
||||
'chipset' => 'Чипсет',
|
||||
'memory_type' => 'Тип памяти',
|
||||
'pcie_version' => 'Версия PCI Express',
|
||||
'wifi_standard' => 'Стандарт Wi-Fi',
|
||||
'max_memory' => 'Максимальный объем памяти',
|
||||
'cache' => 'Объем кэша',
|
||||
'capacity' => 'Объем',
|
||||
'gpu' => 'GPU',
|
||||
'vram' => 'Объем видеопамяти',
|
||||
'vram_type' => 'Тип видеопамяти',
|
||||
'kit' => 'Количество модулей',
|
||||
'frequency' => 'Частота',
|
||||
'power' => 'Мощность',
|
||||
'efficiency' => 'Сертификат 80 Plus',
|
||||
'size' => 'Типоразмер',
|
||||
'gpu_length' => 'Макс. длина видеокарты',
|
||||
'intel_socket' => 'Сокет Intel',
|
||||
'amd_socket' => 'Сокет AMD',
|
||||
'fan_speed' => 'Скорость вращения',
|
||||
'fans' => 'Количество вентиляторов',
|
||||
'type' => 'Тип',
|
||||
'model' => 'Модель',
|
||||
'color' => 'Цвет',
|
||||
'screen_size' => 'Диагональ экрана',
|
||||
'cpu_brand' => 'Производитель процессора',
|
||||
'cpu_model' => 'Модель процессора',
|
||||
'ram' => 'Оперативная память',
|
||||
'storage' => 'Накопитель',
|
||||
'panel' => 'Тип матрицы',
|
||||
'resolution' => 'Разрешение экрана',
|
||||
'refresh_rate' => 'Частота обновления',
|
||||
'smart_tv' => 'Smart TV',
|
||||
'cores' => 'Количество ядер',
|
||||
'gpu_brand' => 'Производитель видеокарты',
|
||||
'gpu_model' => 'Модель видеокарты',
|
||||
];
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Shop/ShopController.php
Normal file
58
app/Http/Controllers/Shop/ShopController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Shop;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\HomeSlide;
|
||||
use App\Models\Product;
|
||||
|
||||
class ShopController extends Controller
|
||||
{
|
||||
public function home()
|
||||
{
|
||||
$categories = Category::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->take(12)
|
||||
->get();
|
||||
|
||||
$featured = Product::query()
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->with('category')
|
||||
->orderByDesc('stock')
|
||||
->orderByDesc('id')
|
||||
->take(12)
|
||||
->get();
|
||||
|
||||
$newProducts = Product::query()
|
||||
->where('is_active', true)
|
||||
->where('stock', '>', 0)
|
||||
->with('category')
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
->take(12)
|
||||
->get();
|
||||
|
||||
$leftSlides = HomeSlide::query()
|
||||
->active()
|
||||
->forZone('left')
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
$rightSlides = HomeSlide::query()
|
||||
->active()
|
||||
->forZone('right')
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
return view('shop.home', [
|
||||
'categories' => $categories,
|
||||
'featured' => $featured,
|
||||
'newProducts' => $newProducts,
|
||||
'leftSlides' => $leftSlides,
|
||||
'rightSlides' => $rightSlides,
|
||||
]);
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/SitemapController.php
Normal file
91
app/Http/Controllers/SitemapController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SitemapController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$latestProductUpdate = Product::query()->where('is_active', true)->max('updated_at');
|
||||
$latestCategoryUpdate = Category::query()->where('is_active', true)->max('updated_at');
|
||||
$catalogLastmod = collect([$latestProductUpdate, $latestCategoryUpdate])
|
||||
->filter()
|
||||
->map(fn ($value) => $value instanceof \Carbon\CarbonInterface ? $value : \Carbon\Carbon::parse((string) $value))
|
||||
->max();
|
||||
$catalogLastmodIso = $catalogLastmod?->toAtomString() ?? now()->toAtomString();
|
||||
|
||||
$urls = collect([
|
||||
[
|
||||
'loc' => route('home'),
|
||||
'lastmod' => $catalogLastmodIso,
|
||||
'changefreq' => 'daily',
|
||||
'priority' => '1.0',
|
||||
],
|
||||
[
|
||||
'loc' => route('catalog.index'),
|
||||
'lastmod' => $catalogLastmodIso,
|
||||
'changefreq' => 'daily',
|
||||
'priority' => '0.9',
|
||||
],
|
||||
[
|
||||
'loc' => route('pages.about'),
|
||||
'lastmod' => now()->toAtomString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => '0.5',
|
||||
],
|
||||
[
|
||||
'loc' => route('pages.contacts'),
|
||||
'lastmod' => now()->toAtomString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => '0.5',
|
||||
],
|
||||
[
|
||||
'loc' => route('pages.shipping-payment'),
|
||||
'lastmod' => now()->toAtomString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => '0.5',
|
||||
],
|
||||
]);
|
||||
|
||||
$categoryUrls = Category::query()
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->map(function (Category $category) {
|
||||
return [
|
||||
'loc' => route('catalog.category', $category),
|
||||
'lastmod' => $category->updated_at?->toAtomString() ?? now()->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.8',
|
||||
];
|
||||
});
|
||||
|
||||
$productUrls = Product::query()
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->map(function (Product $product) {
|
||||
return [
|
||||
'loc' => route('products.show', $product),
|
||||
'lastmod' => $product->updated_at?->toAtomString() ?? now()->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.7',
|
||||
];
|
||||
});
|
||||
|
||||
$allUrls = $this->mergeUrls($urls, $categoryUrls, $productUrls);
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', ['urls' => $allUrls])
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8');
|
||||
}
|
||||
|
||||
private function mergeUrls(Collection ...$collections): Collection
|
||||
{
|
||||
return collect($collections)
|
||||
->flatten(1)
|
||||
->values();
|
||||
}
|
||||
}
|
||||
23
app/Http/Middleware/AdminMiddleware.php
Normal file
23
app/Http/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user()) {
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
|
||||
if (!$request->user()->is_admin) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
35
app/Models/Category.php
Normal file
35
app/Models/Category.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function products()
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
57
app/Models/ChatConversation.php
Normal file
57
app/Models/ChatConversation.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ChatConversation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'visitor_token',
|
||||
'status',
|
||||
'last_message_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_message_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function messages()
|
||||
{
|
||||
return $this->hasMany(ChatMessage::class);
|
||||
}
|
||||
|
||||
public function latestMessage()
|
||||
{
|
||||
return $this->hasOne(ChatMessage::class)->latestOfMany();
|
||||
}
|
||||
|
||||
public function getDisplayNameAttribute(): string
|
||||
{
|
||||
if ($this->user?->name) {
|
||||
return $this->user->name;
|
||||
}
|
||||
|
||||
return 'Гость #' . $this->id;
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED;
|
||||
}
|
||||
}
|
||||
30
app/Models/ChatMessage.php
Normal file
30
app/Models/ChatMessage.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ChatMessage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'chat_conversation_id',
|
||||
'sender',
|
||||
'body',
|
||||
'is_read',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_read' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function conversation()
|
||||
{
|
||||
return $this->belongsTo(ChatConversation::class, 'chat_conversation_id');
|
||||
}
|
||||
}
|
||||
69
app/Models/HomeSlide.php
Normal file
69
app/Models/HomeSlide.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class HomeSlide extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'zone',
|
||||
'title',
|
||||
'show_title',
|
||||
'subtitle',
|
||||
'show_subtitle',
|
||||
'button_text',
|
||||
'button_url',
|
||||
'show_button',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'show_title' => 'boolean',
|
||||
'show_subtitle' => 'boolean',
|
||||
'show_button' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForZone($query, string $zone)
|
||||
{
|
||||
return $query->where('zone', $zone);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
if (!$this->image_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($this->image_path, ['http://', 'https://', '/'])) {
|
||||
return $this->image_path;
|
||||
}
|
||||
|
||||
if (Str::startsWith($this->image_path, 'uploads/')) {
|
||||
return asset($this->image_path);
|
||||
}
|
||||
|
||||
return '/storage/' . ltrim($this->image_path, '/');
|
||||
}
|
||||
}
|
||||
50
app/Models/Order.php
Normal file
50
app/Models/Order.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'total',
|
||||
'items_count',
|
||||
'customer_name',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'comment',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total' => 'decimal:2',
|
||||
'items_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function getPaymentMethodLabelAttribute(): string
|
||||
{
|
||||
return match ($this->payment_method) {
|
||||
'card_transfer' => 'Перевод по реквизитам (на карту)',
|
||||
default => 'Не указан',
|
||||
};
|
||||
}
|
||||
}
|
||||
39
app/Models/OrderItem.php
Normal file
39
app/Models/OrderItem.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class OrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'product_id',
|
||||
'name',
|
||||
'price',
|
||||
'quantity',
|
||||
'subtotal',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
89
app/Models/Product.php
Normal file
89
app/Models/Product.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
'price',
|
||||
'old_price',
|
||||
'stock',
|
||||
'short_description',
|
||||
'description',
|
||||
'image_path',
|
||||
'gallery_paths',
|
||||
'specs',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'decimal:2',
|
||||
'old_price' => 'decimal:2',
|
||||
'stock' => 'integer',
|
||||
'gallery_paths' => 'array',
|
||||
'specs' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->resolveImagePath($this->image_path);
|
||||
}
|
||||
|
||||
public function getGalleryUrlsAttribute(): array
|
||||
{
|
||||
$paths = collect((array) ($this->gallery_paths ?? []))
|
||||
->prepend($this->image_path)
|
||||
->filter(fn ($path) => is_string($path) && trim($path) !== '')
|
||||
->map(fn (string $path) => trim($path))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return $paths
|
||||
->map(fn (string $path) => $this->resolveImagePath($path))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function resolveImagePath(?string $path): ?string
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($path, ['http://', 'https://', '/'])) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
if (Str::startsWith($path, 'uploads/')) {
|
||||
return asset($path);
|
||||
}
|
||||
|
||||
return '/storage/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
60
app/Models/User.php
Normal file
60
app/Models/User.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'is_admin',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function orders()
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function chatConversations()
|
||||
{
|
||||
return $this->hasMany(ChatConversation::class);
|
||||
}
|
||||
}
|
||||
68
app/Providers/AppServiceProvider.php
Normal file
68
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('auth', function (Request $request) {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return Limit::perMinute(10)->by($email . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('admin-login', function (Request $request) {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return Limit::perMinute(5)->by($email . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('chat-read', function (Request $request) {
|
||||
$identity = $request->user()?->id
|
||||
? 'user:' . $request->user()->id
|
||||
: 'session:' . $request->session()->getId();
|
||||
|
||||
return Limit::perMinute(180)->by($identity . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('chat-send', function (Request $request) {
|
||||
$identity = $request->user()?->id
|
||||
? 'user:' . $request->user()->id
|
||||
: 'session:' . $request->session()->getId();
|
||||
|
||||
return Limit::perMinute(20)->by($identity . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('contact-send', function (Request $request) {
|
||||
$email = Str::lower(trim((string) $request->input('email', '')));
|
||||
|
||||
return Limit::perMinute(6)->by(($email !== '' ? $email : 'guest') . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('admin-chat-read', function (Request $request) {
|
||||
return Limit::perMinute(240)->by('admin:' . ($request->user()?->id ?? 'guest') . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('admin-chat-send', function (Request $request) {
|
||||
return Limit::perMinute(60)->by('admin:' . ($request->user()?->id ?? 'guest') . '|' . $request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user