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(), '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', ], ], ], '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', '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' => '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'], ], '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(), '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(), '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(), 'productId' => $id, 'product' => $product, 'productImages' => $productImages, 'productVariants' => $productVariants, 'productImportWarning' => $importWarning, ], '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(), '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 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 */ 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 */ 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 $product * @return array */ 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> $items * @return array> */ 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'] ?? ''), '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( '%s', $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 '
' . $safeName . '
'; } return '
' . $safeName . '
'; } /** * @return array{ok:bool,errors:array} */ 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> $images * @return array> */ 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|null $input * @return array */ 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> */ 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 $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> $images * @param array $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 $image * @return array */ 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), ]; } }