Initial commit
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
ssww23
2026-03-10 00:55:37 +03:00
parent fc0f28d830
commit 93a655235a
155 changed files with 24768 additions and 0 deletions

View 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');
}
}

View 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(),
]);
}
}

View 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;
}
}

View 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, ''));
}
}

View 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)',
];
}
}

View 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', 'Статус заказа обновлен.');
}
}

View 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;
}
}