1143 lines
46 KiB
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)
|
|
));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|