Files
orderPRO/src/Modules/Products/ProductsController.php

1143 lines
46 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Products;
use App\Core\Http\Request;
use App\Core\Http\Response;
use App\Core\I18n\Translator;
use App\Core\Security\Csrf;
use App\Core\Support\Flash;
use App\Core\View\Template;
use App\Modules\Auth\AuthService;
use App\Modules\ProductLinks\ProductLinksService;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Products\ShopProExportService;
final class ProductsController
{
public function __construct(
private readonly Template $template,
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ProductRepository $products,
private readonly ProductService $service,
private readonly IntegrationRepository $integrations,
private readonly ProductLinksService $productLinks,
private readonly ShopProExportService $shopProExport,
private readonly \App\Modules\GS1\GS1Service $gs1Service
) {
}
public function index(Request $request): Response
{
$filtersValues = [
'search' => trim((string) $request->input('search', '')),
'status' => (string) $request->input('status', ''),
'type' => (string) $request->input('type', ''),
'sort' => (string) $request->input('sort', 'id'),
'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
'page' => max(1, (int) $request->input('page', 1)),
'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
];
$result = $this->products->paginate($filtersValues, 'pl');
$totalPages = max(1, (int) ceil(((int) $result['total']) / (int) $result['per_page']));
$rows = $this->tableRows((array) ($result['items'] ?? []));
$shopProIntegrations = array_values(array_filter(
$this->integrations->listByType('shoppro'),
static fn (array $row): bool => ($row['has_api_key'] ?? false) === true
));
$html = $this->template->render('products/index', [
'title' => $this->translator->get('products.title'),
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'shopProIntegrations' => $shopProIntegrations,
'tableList' => [
'list_key' => 'products',
'base_path' => '/products',
'query' => $filtersValues,
'create_url' => '/products/create',
'create_label' => $this->translator->get('products.actions.add'),
'header_actions' => [
[
'type' => 'button',
'label' => $this->translator->get('products.actions.import_shoppro'),
'class' => 'btn btn--secondary',
'attrs' => [
'data-open-modal' => 'product-import-modal',
],
],
[
'type' => 'button',
'label' => $this->translator->get('products.actions.export_shoppro'),
'class' => 'btn btn--secondary',
'attrs' => [
'data-open-modal' => 'product-export-modal',
],
],
],
'filters' => [
[
'key' => 'search',
'label' => $this->translator->get('products.filters.search'),
'type' => 'text',
'value' => $filtersValues['search'],
],
[
'key' => 'status',
'label' => $this->translator->get('products.filters.status'),
'type' => 'select',
'value' => $filtersValues['status'],
'options' => [
'' => $this->translator->get('products.filters.any'),
'1' => $this->translator->get('products.status.active'),
'0' => $this->translator->get('products.status.inactive'),
],
],
[
'key' => 'type',
'label' => $this->translator->get('products.filters.type'),
'type' => 'select',
'value' => $filtersValues['type'],
'options' => [
'' => $this->translator->get('products.filters.any'),
'simple' => $this->translator->get('products.type.simple'),
'variant_parent' => $this->translator->get('products.type.variant_parent'),
],
],
[
'key' => 'sort',
'label' => $this->translator->get('products.filters.sort'),
'type' => 'select',
'value' => $filtersValues['sort'],
'options' => [
'id' => 'ID',
'name' => $this->translator->get('products.fields.name'),
'sku' => 'SKU',
'ean' => 'EAN',
'price_brutto' => $this->translator->get('products.fields.price_brutto'),
'quantity' => $this->translator->get('products.fields.quantity'),
'status' => $this->translator->get('products.fields.status'),
'updated_at' => $this->translator->get('products.fields.updated_at'),
],
],
[
'key' => 'sort_dir',
'label' => $this->translator->get('products.filters.direction'),
'type' => 'select',
'value' => $filtersValues['sort_dir'],
'options' => [
'DESC' => 'DESC',
'ASC' => 'ASC',
],
],
[
'key' => 'per_page',
'label' => $this->translator->get('products.filters.per_page'),
'type' => 'select',
'value' => (string) $filtersValues['per_page'],
'options' => [
'20' => '20',
'50' => '50',
'100' => '100',
],
],
],
'columns' => [
['key' => 'id', 'label' => 'ID', 'sortable' => true, 'sort_key' => 'id'],
['key' => 'name', 'label' => $this->translator->get('products.fields.name'), 'raw' => true, 'sortable' => true, 'sort_key' => 'name'],
['key' => 'sku', 'label' => 'SKU', 'sortable' => true, 'sort_key' => 'sku'],
['key' => 'ean', 'label' => 'EAN', 'sortable' => true, 'sort_key' => 'ean'],
['key' => 'type_label', 'label' => $this->translator->get('products.fields.type')],
['key' => 'price_brutto', 'label' => $this->translator->get('products.fields.price_brutto'), 'sortable' => true, 'sort_key' => 'price_brutto'],
['key' => 'quantity', 'label' => $this->translator->get('products.fields.quantity'), 'sortable' => true, 'sort_key' => 'quantity'],
['key' => 'status_label', 'label' => $this->translator->get('products.fields.status'), 'raw' => true, 'sortable' => true, 'sort_key' => 'status'],
['key' => 'updated_at', 'label' => $this->translator->get('products.fields.updated_at'), 'sortable' => true, 'sort_key' => 'updated_at'],
],
'selectable' => true,
'select_name' => 'export_product_ids[]',
'select_value_key' => 'id',
'select_column_label' => $this->translator->get('products.export.select_column_label'),
'rows' => $rows,
'pagination' => [
'page' => (int) ($result['page'] ?? 1),
'total_pages' => $totalPages,
'total' => (int) ($result['total'] ?? 0),
'per_page' => (int) ($result['per_page'] ?? 20),
],
'per_page_options' => [20, 50, 100],
'empty_message' => $this->translator->get('products.empty'),
'show_actions' => true,
'actions_label' => $this->translator->get('products.fields.actions'),
],
'errorMessage' => (string) Flash::get('products_error', ''),
'successMessage' => (string) Flash::get('products_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function create(Request $request): Response
{
$html = $this->template->render('products/create', [
'title' => $this->translator->get('products.create.title'),
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'form' => $this->formDataFromFlash(),
'errors' => (array) Flash::get('products_form_errors', []),
], 'layouts/app');
return Response::html($html);
}
public function store(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/products');
}
$payload = $this->payloadFromRequest($request);
Flash::set('products_form_old', $payload);
$result = $this->service->create($payload, $this->auth->user());
if (($result['ok'] ?? false) !== true) {
Flash::set('products_form_errors', (array) ($result['errors'] ?? []));
return Response::redirect('/products/create');
}
Flash::set('products_form_old', []);
Flash::set('products_success', $this->translator->get('products.flash.created'));
return Response::redirect('/products');
}
public function edit(Request $request): Response
{
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$product = $this->products->findById($id, 'pl');
if ($product === null) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$form = $this->mergeOldWithProduct($product);
$productImages = $this->withPublicImageUrls($this->products->findImagesByProductId($id));
$html = $this->template->render('products/edit', [
'title' => $this->translator->get('products.edit.title', ['id' => (string) $id]),
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'productId' => $id,
'form' => $form,
'productImages' => $productImages,
'errors' => (array) Flash::get('products_form_errors', []),
], 'layouts/app');
return Response::html($html);
}
public function show(Request $request): Response
{
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$product = $this->products->findById($id, 'pl');
if ($product === null) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$productImages = $this->withPublicImageUrls($this->products->findImagesByProductId($id));
$productVariants = $this->products->findVariantsByProductId($id, 'pl');
$importWarning = $this->products->findLatestImportWarning($id);
$html = $this->template->render('products/show', [
'title' => $this->translator->get('products.show.title', ['id' => (string) $id]),
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'productId' => $id,
'product' => $product,
'productImages' => $productImages,
'productVariants' => $productVariants,
'productImportWarning' => $importWarning,
'errorMessage' => (string) Flash::get('products_error', ''),
'successMessage' => (string) Flash::get('products_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function links(Request $request): Response
{
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$product = $this->products->findById($id, 'pl');
if ($product === null) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$linksIntegrationId = max(0, (int) $request->input('links_integration_id', 0));
$linksQuery = trim((string) $request->input('links_query', ''));
$linksData = $this->productLinks->buildProductLinksViewData($id, $product, $linksIntegrationId, $linksQuery);
$html = $this->template->render('products/links', [
'title' => $this->translator->get('products.links.page_title', ['id' => (string) $id]),
'activeMenu' => 'products',
'user' => $this->auth->user(),
'csrfToken' => Csrf::token(),
'marketplaceIntegrations' => $this->marketplaceIntegrations(),
'productId' => $id,
'product' => $product,
'productLinks' => (array) ($linksData['links'] ?? []),
'productLinkEventsByMap' => (array) ($linksData['link_events_by_map'] ?? []),
'linkIntegrations' => (array) ($linksData['integrations'] ?? []),
'selectedLinksIntegrationId' => (int) ($linksData['selected_integration_id'] ?? 0),
'linksQuery' => (string) ($linksData['search_query'] ?? ''),
'linkOffers' => (array) ($linksData['offers'] ?? []),
'linksErrorMessage' => (string) Flash::get('product_links_error', ''),
'linksSuccessMessage' => (string) Flash::get('product_links_success', ''),
], 'layouts/app');
return Response::html($html);
}
public function linkSuggestions(Request $request): Response
{
$id = (int) $request->input('id', 0);
if ($id <= 0) {
return Response::json([
'ok' => false,
'message' => $this->translator->get('products.flash.not_found'),
], 404);
}
$product = $this->products->findById($id, 'pl');
if ($product === null) {
return Response::json([
'ok' => false,
'message' => $this->translator->get('products.flash.not_found'),
], 404);
}
$linksIntegrationId = max(0, (int) $request->input('links_integration_id', 0));
$linksQuery = trim((string) $request->input('links_query', ''));
$linksData = $this->productLinks->buildProductLinksViewData($id, $product, $linksIntegrationId, $linksQuery);
return Response::json([
'ok' => true,
'product_id' => $id,
'integration_id' => (int) ($linksData['selected_integration_id'] ?? 0),
'offers' => array_values(array_filter(
(array) ($linksData['offers'] ?? []),
static fn (array $offer): bool => (int) ($offer['match_confidence'] ?? 0) > 0
)),
]);
}
public function exportShopPro(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/products');
}
$integrationId = max(0, (int) $request->input('integration_id', 0));
$exportMode = (string) $request->input('export_mode', 'simple');
$selectedIds = $this->normalizeIntArray($request->input('export_product_ids', []));
if ($integrationId <= 0) {
Flash::set('products_error', $this->translator->get('products.export.flash.integration_required'));
return Response::redirect('/products');
}
if (!in_array($exportMode, ['simple', 'variant'], true)) {
Flash::set('products_error', $this->translator->get('products.export.flash.mode_invalid'));
return Response::redirect('/products');
}
if ($selectedIds === []) {
Flash::set('products_error', $this->translator->get('products.export.flash.no_products_selected'));
return Response::redirect('/products');
}
try {
$credentials = $this->integrations->findApiCredentials($integrationId);
} catch (\Throwable $exception) {
Flash::set('products_error', $this->translator->get('products.export.flash.failed') . ' ' . $exception->getMessage());
return Response::redirect('/products');
}
if ($credentials === null) {
Flash::set('products_error', $this->translator->get('products.export.flash.integration_not_found'));
return Response::redirect('/products');
}
$apiKey = (string) ($credentials['api_key'] ?? '');
if ($apiKey === '') {
Flash::set('products_error', $this->translator->get('products.export.flash.api_key_missing'));
return Response::redirect('/products');
}
$result = $this->shopProExport->exportProducts($selectedIds, $credentials, $exportMode, $this->auth->user());
$summary = $this->translator->get('products.export.flash.done', [
'exported' => (string) ($result['exported'] ?? 0),
'failed' => (string) ($result['failed'] ?? 0),
'mode' => $exportMode === 'variant'
? $this->translator->get('products.export.mode_variant')
: $this->translator->get('products.export.mode_simple'),
]);
$errors = is_array($result['errors'] ?? null) ? $result['errors'] : [];
if ($errors !== []) {
Flash::set('products_error', $summary . ' ' . implode(' | ', $errors));
} else {
Flash::set('products_success', $summary);
}
return Response::redirect('/products');
}
public function assignGs1Ean(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/products');
}
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
try {
$result = $this->gs1Service->assignEanToProduct($id);
Flash::set('products_success', $this->translator->get('products.gs1.ean_assigned', [
'ean' => $result['ean'],
]));
} catch (\Throwable $e) {
Flash::set('products_error', $this->translator->get('products.gs1.error') . ' ' . $e->getMessage());
}
return Response::redirect('/products/' . $id);
}
public function update(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/products');
}
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$payload = $this->payloadFromRequest($request);
Flash::set('products_form_old', $payload);
$result = $this->service->update($id, $payload, $this->auth->user());
if (($result['ok'] ?? false) !== true) {
Flash::set('products_form_errors', (array) ($result['errors'] ?? []));
return Response::redirect('/products/edit?id=' . $id);
}
$imageResult = $this->applyImageChanges($id, $request);
if (($imageResult['ok'] ?? false) !== true) {
$errors = (array) ($imageResult['errors'] ?? []);
if ($errors !== []) {
Flash::set('products_error', implode(' ', $errors));
}
}
Flash::set('products_form_old', []);
Flash::set('products_success', $this->translator->get('products.flash.updated'));
return Response::redirect('/products');
}
public function uploadImage(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
return Response::json([
'ok' => false,
'message' => $this->translator->get('auth.errors.csrf_expired'),
], 419);
}
$productId = (int) $request->input('id', 0);
if ($productId <= 0 || $this->products->findById($productId, 'pl') === null) {
return Response::json(['ok' => false, 'message' => $this->translator->get('products.flash.not_found')], 404);
}
$uploadedImages = $this->normalizeUploadedFiles($request->file('new_images'));
if ($uploadedImages === []) {
return Response::json(['ok' => false, 'message' => 'Nie wybrano pliku do uploadu.'], 422);
}
$currentImages = $this->products->findImagesByProductId($productId);
$nextSortOrder = $currentImages === []
? 0
: ((int) max(array_column($currentImages, 'sort_order')) + 1);
$setFirstAsMain = $currentImages === [];
$created = [];
$errors = [];
foreach ($uploadedImages as $imageFile) {
if ((int) ($imageFile['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
$saved = $this->saveUploadedImageFile($productId, $imageFile);
if (($saved['ok'] ?? false) !== true) {
$errors[] = (string) ($saved['error'] ?? 'Nie udalo sie zapisac pliku.');
continue;
}
$imageId = $this->products->createImage(
$productId,
(string) ($saved['storage_path'] ?? ''),
null,
$nextSortOrder,
$setFirstAsMain ? 1 : 0
);
$nextSortOrder++;
$setFirstAsMain = false;
$inserted = $this->products->findImagesByProductId($productId);
foreach ($inserted as $row) {
if ((int) ($row['id'] ?? 0) === $imageId) {
$created[] = $this->mapImageForApi($row);
break;
}
}
}
if ($created === []) {
return Response::json([
'ok' => false,
'message' => $errors !== [] ? implode(' ', $errors) : 'Nie udalo sie dodac zdjec.',
], 422);
}
return Response::json([
'ok' => true,
'images' => $created,
'message' => $errors === [] ? '' : implode(' ', $errors),
]);
}
public function setMainImage(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
return Response::json([
'ok' => false,
'message' => $this->translator->get('auth.errors.csrf_expired'),
], 419);
}
$productId = (int) $request->input('id', 0);
$imageId = (int) $request->input('image_id', 0);
if ($productId <= 0 || $imageId <= 0) {
return Response::json(['ok' => false, 'message' => 'Niepoprawne dane zdjecia.'], 422);
}
$images = $this->products->findImagesByProductId($productId);
$exists = false;
foreach ($images as $image) {
if ((int) ($image['id'] ?? 0) === $imageId) {
$exists = true;
break;
}
}
if (!$exists) {
return Response::json(['ok' => false, 'message' => 'Nie znaleziono wskazanego zdjecia.'], 404);
}
$this->products->setMainImage($productId, $imageId);
return Response::json([
'ok' => true,
'image_id' => $imageId,
]);
}
public function deleteImage(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
return Response::json([
'ok' => false,
'message' => $this->translator->get('auth.errors.csrf_expired'),
], 419);
}
$productId = (int) $request->input('id', 0);
$imageId = (int) $request->input('image_id', 0);
if ($productId <= 0 || $imageId <= 0) {
return Response::json(['ok' => false, 'message' => 'Niepoprawne dane zdjecia.'], 422);
}
$deletedPath = $this->products->deleteImageById($productId, $imageId);
if ($deletedPath === null) {
return Response::json(['ok' => false, 'message' => 'Nie znaleziono wskazanego zdjecia.'], 404);
}
$this->deleteLocalImageFile($deletedPath);
$remaining = $this->products->findImagesByProductId($productId);
$hasMain = false;
foreach ($remaining as $row) {
if ((int) ($row['is_main'] ?? 0) === 1) {
$hasMain = true;
break;
}
}
$newMainId = 0;
if (!$hasMain && $remaining !== []) {
$newMainId = (int) ($remaining[0]['id'] ?? 0);
if ($newMainId > 0) {
$this->products->setMainImage($productId, $newMainId);
}
}
return Response::json([
'ok' => true,
'deleted_id' => $imageId,
'main_image_id' => $newMainId,
]);
}
public function destroy(Request $request): Response
{
$csrfToken = (string) $request->input('_token', '');
if (!Csrf::validate($csrfToken)) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
return Response::redirect('/products');
}
$id = (int) $request->input('id', 0);
if ($id <= 0) {
Flash::set('products_error', $this->translator->get('products.flash.not_found'));
return Response::redirect('/products');
}
$result = $this->service->delete($id, $this->auth->user());
if (($result['ok'] ?? false) !== true) {
$errors = (array) ($result['errors'] ?? []);
Flash::set('products_error', $errors !== [] ? (string) $errors[0] : $this->translator->get('products.flash.delete_failed'));
return Response::redirect('/products');
}
Flash::set('products_success', $this->translator->get('products.flash.deleted'));
return Response::redirect('/products');
}
/**
* @return array<string, mixed>
*/
private function payloadFromRequest(Request $request): array
{
return [
'type' => (string) $request->input('type', 'simple'),
'name' => (string) $request->input('name', ''),
'sku' => (string) $request->input('sku', ''),
'ean' => (string) $request->input('ean', ''),
'status' => (string) $request->input('status', '1'),
'promoted' => (string) $request->input('promoted', '0'),
'vat' => (string) $request->input('vat', '23'),
'weight' => (string) $request->input('weight', ''),
'quantity' => (string) $request->input('quantity', '0'),
'price_input_mode' => (string) $request->input('price_input_mode', 'brutto'),
'price_brutto' => (string) $request->input('price_brutto', ''),
'price_netto' => (string) $request->input('price_netto', ''),
'price_brutto_promo' => (string) $request->input('price_brutto_promo', ''),
'price_netto_promo' => (string) $request->input('price_netto_promo', ''),
'short_description' => (string) $request->input('short_description', ''),
'description' => (string) $request->input('description', ''),
'meta_title' => (string) $request->input('meta_title', ''),
'meta_description' => (string) $request->input('meta_description', ''),
'meta_keywords' => (string) $request->input('meta_keywords', ''),
'seo_link' => (string) $request->input('seo_link', ''),
];
}
/**
* @return array<string, mixed>
*/
private function formDataFromFlash(): array
{
$old = (array) Flash::get('products_form_old', []);
return array_merge([
'type' => 'simple',
'name' => '',
'sku' => '',
'ean' => '',
'status' => '1',
'promoted' => '0',
'vat' => '23',
'weight' => '',
'quantity' => '0',
'price_input_mode' => 'brutto',
'price_brutto' => '',
'price_netto' => '',
'price_brutto_promo' => '',
'price_netto_promo' => '',
'short_description' => '',
'description' => '',
'meta_title' => '',
'meta_description' => '',
'meta_keywords' => '',
'seo_link' => '',
], $old);
}
/**
* @param array<string, mixed> $product
* @return array<string, mixed>
*/
private function mergeOldWithProduct(array $product): array
{
$old = (array) Flash::get('products_form_old', []);
$base = [
'type' => (string) ($product['type'] ?? 'simple'),
'name' => (string) ($product['name'] ?? ''),
'sku' => (string) ($product['sku'] ?? ''),
'ean' => (string) ($product['ean'] ?? ''),
'status' => (string) ($product['status'] ?? '1'),
'promoted' => (string) ($product['promoted'] ?? '0'),
'vat' => (string) ($product['vat'] ?? ''),
'weight' => (string) ($product['weight'] ?? ''),
'quantity' => (string) ($product['quantity'] ?? '0'),
'price_input_mode' => 'brutto',
'price_brutto' => (string) ($product['price_brutto'] ?? ''),
'price_netto' => (string) ($product['price_netto'] ?? ''),
'price_brutto_promo' => (string) ($product['price_brutto_promo'] ?? ''),
'price_netto_promo' => (string) ($product['price_netto_promo'] ?? ''),
'short_description' => (string) ($product['short_description'] ?? ''),
'description' => (string) ($product['description'] ?? ''),
'meta_title' => (string) ($product['meta_title'] ?? ''),
'meta_description' => (string) ($product['meta_description'] ?? ''),
'meta_keywords' => (string) ($product['meta_keywords'] ?? ''),
'seo_link' => (string) ($product['seo_link'] ?? ''),
];
return array_merge($base, $old);
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
private function tableRows(array $items): array
{
return array_map(function (array $row): array {
$id = (int) ($row['id'] ?? 0);
$isActive = (int) ($row['status'] ?? 0) === 1;
$type = (string) ($row['type'] ?? 'simple');
return [
'id' => $id,
'name' => $this->renderProductNameCell((string) ($row['name'] ?? ''), (string) ($row['main_image_path'] ?? '')),
'sku' => (string) ($row['sku'] ?? ''),
'ean' => (string) ($row['ean'] ?? ''),
'type_label' => $type === 'variant_parent'
? $this->translator->get('products.type.variant_parent')
: $this->translator->get('products.type.simple'),
'price_brutto' => number_format((float) ($row['price_brutto'] ?? 0), 2, '.', ''),
'quantity' => number_format((float) ($row['quantity'] ?? 0), 3, '.', ''),
'status_label' => sprintf(
'<span class="status-pill%s">%s</span>',
$isActive ? ' is-active' : '',
htmlspecialchars(
$isActive
? $this->translator->get('products.status.active')
: $this->translator->get('products.status.inactive'),
ENT_QUOTES,
'UTF-8'
)
),
'updated_at' => (string) ($row['updated_at'] ?? ''),
'_actions' => [
[
'label' => $this->translator->get('products.actions.preview'),
'url' => '/products/' . $id,
'class' => 'btn btn--secondary',
],
[
'label' => $this->translator->get('products.actions.links'),
'url' => '/products/' . $id . '/links',
'class' => 'btn btn--secondary',
],
[
'label' => $this->translator->get('products.actions.edit'),
'url' => '/products/edit?id=' . $id,
'class' => 'btn btn--secondary',
],
[
'label' => $this->translator->get('products.actions.delete'),
'url' => '/products/delete',
'class' => 'btn btn--danger',
'method' => 'post',
'confirm' => $this->translator->get('products.confirm.delete', ['id' => (string) $id]),
'params' => [
'id' => (string) $id,
],
],
],
];
}, $items);
}
private function renderProductNameCell(string $name, string $mainImagePath): string
{
$safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
$imageUrl = $this->publicImageUrl($mainImagePath);
$safeImageUrl = htmlspecialchars($imageUrl, ENT_QUOTES, 'UTF-8');
if ($safeImageUrl === '') {
return '<div class="product-name-cell"><span class="product-name-thumb product-name-thumb--empty" aria-hidden="true"></span><span class="product-name-text">' . $safeName . '</span></div>';
}
return '<div class="product-name-cell"><button type="button" class="product-name-thumb-btn" data-product-image-preview="' . $safeImageUrl . '" aria-label="Podglad zdjecia produktu: ' . $safeName . '"><img src="' . $safeImageUrl . '" alt="" class="product-name-thumb" loading="lazy" decoding="async"></button><span class="product-name-text">' . $safeName . '</span></div>';
}
/**
* @return array{ok:bool,errors:array<int, string>}
*/
private function applyImageChanges(int $productId, Request $request): array
{
$errors = [];
$removedPaths = [];
$newMainChoice = trim((string) $request->input('main_image_choice', ''));
$newlyInsertedIds = [];
$removeImageIds = $this->normalizeIntArray($request->input('remove_image_ids', []));
foreach ($removeImageIds as $imageId) {
$storagePath = $this->products->deleteImageById($productId, $imageId);
if ($storagePath !== null) {
$removedPaths[] = $storagePath;
}
}
$uploadedImages = $this->normalizeUploadedFiles($request->file('new_images'));
if ($uploadedImages !== []) {
$currentImages = $this->products->findImagesByProductId($productId);
$nextSortOrder = $currentImages === []
? 0
: ((int) max(array_column($currentImages, 'sort_order')) + 1);
foreach ($uploadedImages as $uploadIndex => $imageFile) {
if ((int) ($imageFile['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
$saved = $this->saveUploadedImageFile($productId, $imageFile);
if (($saved['ok'] ?? false) !== true) {
$message = (string) ($saved['error'] ?? 'Nie udalo sie zapisac pliku.');
$errors[] = $message;
continue;
}
$imageId = $this->products->createImage(
$productId,
(string) ($saved['storage_path'] ?? ''),
null,
$nextSortOrder,
0
);
$newlyInsertedIds[$uploadIndex] = $imageId;
$nextSortOrder++;
}
}
$allImages = $this->products->findImagesByProductId($productId);
if ($allImages !== []) {
$selectedMainId = $this->resolveMainImageId($newMainChoice, $allImages, $newlyInsertedIds);
if ($selectedMainId === null) {
$currentMain = array_values(array_filter(
$allImages,
static fn (array $row): bool => ((int) ($row['is_main'] ?? 0)) === 1
));
$selectedMainId = $currentMain !== [] ? (int) ($currentMain[0]['id'] ?? 0) : null;
}
if ($selectedMainId === null || $selectedMainId <= 0) {
$selectedMainId = (int) ($allImages[0]['id'] ?? 0);
}
if ($selectedMainId > 0) {
$this->products->setMainImage($productId, $selectedMainId);
}
}
foreach ($removedPaths as $path) {
$this->deleteLocalImageFile($path);
}
return [
'ok' => $errors === [],
'errors' => $errors,
];
}
/**
* @param array<int, array<string, mixed>> $images
* @return array<int, array<string, mixed>>
*/
private function withPublicImageUrls(array $images): array
{
return array_map(function (array $image): array {
$storagePath = trim((string) ($image['storage_path'] ?? ''));
$image['public_url'] = $this->publicImageUrl($storagePath);
return $image;
}, $images);
}
private function publicImageUrl(string $storagePath): string
{
$path = trim($storagePath);
if ($path === '') {
return '';
}
if (preg_match('#^https?://#i', $path) === 1 || str_starts_with($path, '//')) {
return $path;
}
return '/' . ltrim(str_replace('\\', '/', $path), '/');
}
/**
* @param array<string, mixed>|null $input
* @return array<int, int>
*/
private function normalizeIntArray(mixed $input): array
{
if (!is_array($input)) {
return [];
}
$values = [];
foreach ($input as $value) {
if (is_scalar($value) && is_numeric((string) $value)) {
$parsed = (int) $value;
if ($parsed > 0) {
$values[] = $parsed;
}
}
}
return array_values(array_unique($values));
}
/**
* @param mixed $rawFiles
* @return array<int, array<string, mixed>>
*/
private function normalizeUploadedFiles(mixed $rawFiles): array
{
if (!is_array($rawFiles) || !isset($rawFiles['name'])) {
return [];
}
$normalized = [];
if (is_array($rawFiles['name'])) {
foreach ($rawFiles['name'] as $index => $name) {
$normalized[] = [
'name' => (string) $name,
'type' => (string) ($rawFiles['type'][$index] ?? ''),
'tmp_name' => (string) ($rawFiles['tmp_name'][$index] ?? ''),
'error' => (int) ($rawFiles['error'][$index] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($rawFiles['size'][$index] ?? 0),
];
}
return $normalized;
}
return [[
'name' => (string) ($rawFiles['name'] ?? ''),
'type' => (string) ($rawFiles['type'] ?? ''),
'tmp_name' => (string) ($rawFiles['tmp_name'] ?? ''),
'error' => (int) ($rawFiles['error'] ?? UPLOAD_ERR_NO_FILE),
'size' => (int) ($rawFiles['size'] ?? 0),
]];
}
/**
* @param array<string, mixed> $file
* @return array{ok:bool,storage_path?:string,error?:string}
*/
private function saveUploadedImageFile(int $productId, array $file): array
{
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE) {
return ['ok' => false, 'error' => 'Nie wybrano pliku do uploadu.'];
}
if ($error !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => 'Upload obrazu zakonczyl sie bledem.'];
}
$originalName = trim((string) ($file['name'] ?? ''));
$tmpFile = (string) ($file['tmp_name'] ?? '');
if ($tmpFile === '' || !is_uploaded_file($tmpFile)) {
return ['ok' => false, 'error' => 'Niepoprawny plik tymczasowy obrazu.'];
}
$extension = strtolower((string) pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif'], true)) {
return ['ok' => false, 'error' => 'Dozwolone formaty obrazow: JPG, PNG, WEBP, GIF.'];
}
$imageInfo = @getimagesize($tmpFile);
if (!is_array($imageInfo)) {
return ['ok' => false, 'error' => 'Plik nie jest poprawnym obrazem.'];
}
$projectRoot = dirname(__DIR__, 3);
$relativeDir = 'uploads/products/' . $productId;
$targetDir = $projectRoot . '/public/' . $relativeDir;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
return ['ok' => false, 'error' => 'Nie mozna utworzyc katalogu na obrazy.'];
}
$safeBaseName = preg_replace('/[^a-zA-Z0-9_-]+/', '-', (string) pathinfo($originalName, PATHINFO_FILENAME));
$safeBaseName = trim((string) $safeBaseName, '-');
if ($safeBaseName === '') {
$safeBaseName = 'image';
}
$fileName = sprintf('%s-%s.%s', $safeBaseName, bin2hex(random_bytes(6)), $extension);
$targetPath = $targetDir . '/' . $fileName;
if (!move_uploaded_file($tmpFile, $targetPath)) {
return ['ok' => false, 'error' => 'Nie mozna zapisac obrazu na serwerze.'];
}
return [
'ok' => true,
'storage_path' => $relativeDir . '/' . $fileName,
];
}
/**
* @param array<int, array<string, mixed>> $images
* @param array<int, int> $newlyInsertedIds
*/
private function resolveMainImageId(string $choice, array $images, array $newlyInsertedIds): ?int
{
$imageIds = array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $images);
if ($choice === '') {
return null;
}
if (preg_match('/^existing:(\d+)$/', $choice, $match) === 1) {
$id = (int) $match[1];
return in_array($id, $imageIds, true) ? $id : null;
}
if (preg_match('/^new:(\d+)$/', $choice, $match) === 1) {
$newIndex = (int) $match[1];
return $newlyInsertedIds[$newIndex] ?? null;
}
return null;
}
private function deleteLocalImageFile(string $storagePath): void
{
$path = trim($storagePath);
if ($path === '' || preg_match('#^https?://#i', $path) === 1 || str_starts_with($path, '//')) {
return;
}
$projectRoot = dirname(__DIR__, 3);
$filePath = $projectRoot . '/public/' . ltrim(str_replace('\\', '/', $path), '/');
$realFilePath = realpath($filePath);
$realPublicPath = realpath($projectRoot . '/public');
if ($realFilePath === false || $realPublicPath === false) {
return;
}
if (!str_starts_with($realFilePath, $realPublicPath . DIRECTORY_SEPARATOR)) {
return;
}
if (is_file($realFilePath)) {
@unlink($realFilePath);
}
}
/**
* @param array<string, mixed> $image
* @return array<string, mixed>
*/
private function mapImageForApi(array $image): array
{
$storagePath = (string) ($image['storage_path'] ?? '');
return [
'id' => (int) ($image['id'] ?? 0),
'storage_path' => $storagePath,
'alt' => (string) ($image['alt'] ?? ''),
'sort_order' => (int) ($image['sort_order'] ?? 0),
'is_main' => (int) ($image['is_main'] ?? 0),
'public_url' => $this->publicImageUrl($storagePath),
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function marketplaceIntegrations(): array
{
return array_values(array_filter(
$this->integrations->listByType('shoppro'),
static fn (array $row): bool => (bool) ($row['is_active'] ?? false)
));
}
}