feat: Add IntegrationRepository and ShopProClient for managing integrations and fetching products from shopPRO API

This commit is contained in:
2026-02-23 23:28:55 +01:00
parent b312dc56e3
commit 18d0019c28
54 changed files with 10397 additions and 393 deletions

2
.env
View File

@@ -12,3 +12,5 @@ DB_DATABASE=host700513_orderpro
DB_USERNAME=host700513_orderpro DB_USERNAME=host700513_orderpro
DB_PASSWORD=hrDNtUBg9grwZ7syN77S DB_PASSWORD=hrDNtUBg9grwZ7syN77S
DB_CHARSET=utf8mb4 DB_CHARSET=utf8mb4
INTEGRATIONS_SECRET=nB3sTkXAbBLqA2Ent74R9Mi1118bAbWa

View File

@@ -3,6 +3,7 @@ APP_ENV=local
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost:8000 APP_URL=http://localhost:8000
SESSION_NAME=orderpro_session SESSION_NAME=orderpro_session
INTEGRATIONS_SECRET=change-me-long-random-secret
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1

4
.vscode/ftp-kr.json vendored
View File

@@ -12,6 +12,8 @@
"ignoreRemoteModification": true, "ignoreRemoteModification": true,
"ignore": [ "ignore": [
".git", ".git",
"/.vscode" "/.vscode",
"/.claude",
".gitignore"
] ]
} }

File diff suppressed because it is too large Load Diff

18
AGENTS.md Normal file
View File

@@ -0,0 +1,18 @@
# Projektowe zasady dla Codex
## Baza danych i migracje
- `DB_HOST_REMOTE` jest techniczne tylko dla agenta (Codex) do recznych operacji DB/migracji.
- Nie podpinaj `DB_HOST_REMOTE` do runtime aplikacji.
- Runtime aplikacji ma korzystac standardowo z `DB_HOST`.
## Utrwalanie stalych wymagan
- Trwale wymagania techniczne zapisuj w tym pliku (`AGENTS.md`) w root projektu.
- Dla zmiennych srodowiskowych utrzymuj tez wpisy w `.env.example`.
## Alerty i potwierdzenia UI
- W aplikacji uzywaj modulu `resources/modules/jquery-alerts` (build do `public/assets/js/modules/jquery-alerts.js` i `public/assets/css/modules/jquery-alerts.css`).
- Nie dodawaj nowych natywnych `alert()` / `confirm()` w widokach; dla potwierdzen akcji (np. usuwanie) korzystaj z `window.OrderProAlerts.confirm(...)`.
## Style frontendu
- Nie trzymaj styli CSS w plikach widokow (`resources/views/...`).
- Wszystkie style umieszczaj w plikach SCSS (`resources/scss/...`) i buduj do `public/assets/css/...`.

View File

@@ -92,3 +92,22 @@ Legenda statusow:
- klasa polaczenia PDO, - klasa polaczenia PDO,
- konfiguracja z `.env`, - konfiguracja z `.env`,
- prosty endpoint kontrolny DB. - prosty endpoint kontrolny DB.
## Sprint 1.5 - Modul produktow (bez importu/eksportu)
62. `DONE` Utworzyc migracje tabel: `products`, `product_translations`, `product_images`, `product_categories`.
63. `DONE` Utworzyc migracje tabel: `product_variants`, `product_variant_attributes`.
64. `DONE` Utworzyc migracje tabel slownikowych: `attributes`, `attribute_translations`, `attribute_values`, `attribute_value_translations`.
65. `DONE` Utworzyc migracje tabel pomocniczych: `product_change_log`, `sales_channels`, `product_channel_map`.
66. `DONE` Dodac repozytoria i serwis domenowy dla produktow.
67. `DONE` Dodac walidator produktow i wariantow (nazwa, cena, SKU produktu i wariantu, EAN, kombinacje atrybutow).
68. `DONE` Dodac ekran listy produktow (`/products`) z filtrami i paginacja.
69. `DONE` Dodac ekran tworzenia produktu (`/products/create`) i zapis (`POST /products`).
70. `DONE` Dodac ekran szczegolow i edycji produktu (`/products/edit?id={id}`).
71. `TODO` Dodac obsluge wariantow (dodaj/edytuj) na szczegolach produktu + konwersja simple <-> variant_parent.
72. `TODO` Dodac sekcje atrybutow i wartosci (lista + dodawanie).
73. `DONE` Dodac wpisy do `product_change_log` przy zmianach krytycznych.
74. `DONE` Dodac pozycje "Produkty" do nawigacji + tlumaczenia `resources/lang/pl.php`.
75. `DONE` Dodac dwukierunkowe przeliczanie cen brutto/netto (UI + backend) na podstawie VAT.
76. `TODO` Dodac upload i zapis zdjec produktu na serwerze orderPRO.
77. `TODO` Przeprowadzic test manualny flow: create/edit/list/filter/variant.

View File

@@ -0,0 +1,235 @@
# orderPRO - Plan wdrozenia modulu powiazan produktow (shopPRO + marketplace)
Data: 2026-02-23
Status: draft do implementacji
## Status realizacji (2026-02-23)
- Etap A: wykonany
- Etap B: wykonany
- Etap C: wykonany
- Etap D: wykonany (LinkMatcherService, priorytety EAN/SKU, endpoint sugestii)
- Etap E: w toku (UI historii zdarzen + soft-unlink i audyt gotowe; do domkniecia testy manualne E2E)
## Ustalenie implementacyjne (2026-02-23)
- Import produktu z integracji tworzy od razu powiazanie w `product_channel_map` z ustawionym `integration_id` dla tej konkretnej instancji konta.
## 1. Cel etapu
Zbudowac osobny modul "Powiazania ofert" pozwalajacy mapowac produkty orderPRO do ofert zewnetrznych z instancji:
- shopPRO,
- marketplace.
Modul ma dzialac jako osobna karta (analogicznie do podejscia Apilo/BaseLinker), ale osadzona w obecnej architekturze orderPRO.
## 2. Stan obecny i luka
### 2.1 Co juz jest
- tabele `sales_channels` oraz `product_channel_map`,
- seeding kanalow (`shoppro`, `allegro`, `erli`) w `IntegrationRepository::ensureSalesChannelsSeeded()`,
- podstawowe mapowanie `product_id + channel_code + external ids` przez `upsertProductChannelMap(...)`.
### 2.2 Czego brakuje
- brak rozroznienia instancji kont (np. kilka kont marketplace),
- brak lokalnego cache ofert zewnetrznych do wyszukiwania i laczenia,
- brak statusow powiazania i konfliktow,
- brak dedykowanego UI "Powiazania",
- brak audytu operacji recznych (powiaz/przepnij/odlacz).
## 3. Zakres MVP modulu powiazan
W etapie MVP wdrazamy:
1. osobna karta "Powiazania" na szczegolach produktu,
2. reczne powiazanie produktu orderPRO z oferta zewnetrzna,
3. auto-podpowiedzi po `EAN` i `SKU` (bez automatycznego zapisu),
4. odlaczanie i przepinanie powiazan,
5. podstawowy audit trail w osobnej tabeli logow,
6. import/listowanie ofert z aktywnych integracji do lokalnej tabeli cache.
W MVP NIE wdrazamy:
- pelnej, automatycznej synchronizacji cen/stanow,
- fuzzy matching po nazwie jako auto-link,
- masowych operacji na setkach rekordow naraz.
## 4. Model danych (docelowy dla MVP + rozszerzalny)
### 4.1 Rozszerzenie istniejacej tabeli mapowania
Tabela: `product_channel_map` (istniejaca)
Dodac kolumny:
- `integration_id` INT UNSIGNED NULL (FK -> `integrations.id`) - wskazuje konkretna instancje konta,
- `link_type` VARCHAR(32) NOT NULL DEFAULT 'manual' (`manual`, `auto_sku`, `auto_ean`),
- `link_status` VARCHAR(32) NOT NULL DEFAULT 'active' (`active`, `conflict`, `inactive`, `unverified`),
- `confidence` TINYINT UNSIGNED NULL,
- `linked_at` DATETIME NULL,
- `linked_by_user_id` INT UNSIGNED NULL (FK -> `users.id`),
- `unlinked_at` DATETIME NULL,
- `unlinked_by_user_id` INT UNSIGNED NULL (FK -> `users.id`),
- `sync_meta_json` JSON NULL (pole techniczne pod dane z API).
Indeksy:
- `(integration_id, external_product_id, external_variant_id)`,
- `(product_id, link_status)`,
- `(channel_id, link_status)`.
Uwagi:
- utrzymac kompatybilnosc ze starym kodem (`upsertProductChannelMap`),
- `integration_id` moze byc NULL tylko dla rekordow historycznych.
### 4.2 Nowa tabela cache ofert zewnetrznych
Tabela: `channel_offers`
- `id` INT UNSIGNED PK
- `integration_id` INT UNSIGNED NOT NULL FK -> `integrations.id`
- `channel_id` INT UNSIGNED NOT NULL FK -> `sales_channels.id`
- `external_product_id` VARCHAR(128) NOT NULL
- `external_variant_id` VARCHAR(128) NULL
- `external_offer_id` VARCHAR(128) NULL
- `name` VARCHAR(255) NOT NULL
- `sku` VARCHAR(128) NULL
- `ean` VARCHAR(32) NULL
- `price_brutto` DECIMAL(12,2) NULL
- `quantity` DECIMAL(12,3) NULL
- `currency` VARCHAR(8) NULL
- `offer_status` VARCHAR(32) NOT NULL DEFAULT 'active'
- `source_updated_at` DATETIME NULL
- `last_seen_at` DATETIME NOT NULL
- `payload_json` JSON NULL
- `created_at`, `updated_at`
Unikalnosc:
- `UNIQUE (integration_id, external_product_id, external_variant_id)`.
### 4.3 Nowa tabela logow powiazan
Tabela: `product_link_events`
- `id` INT UNSIGNED PK
- `product_channel_map_id` INT UNSIGNED NOT NULL FK -> `product_channel_map.id`
- `event_type` VARCHAR(32) NOT NULL (`linked`, `relinked`, `unlinked`, `status_changed`, `conflict_detected`)
- `before_json` JSON NULL
- `after_json` JSON NULL
- `created_by_user_id` INT UNSIGNED NULL FK -> `users.id`
- `created_at` DATETIME NOT NULL
## 5. Architektura aplikacyjna (orderPRO)
Nowy modul: `src/Modules/ProductLinks`
Klasy:
- `ProductLinksController` - karta "Powiazania", akcje reczne,
- `ProductLinksService` - logika powiaz/przepnij/odlacz + reguly konfliktow,
- `ProductLinksRepository` - odczyt/zapis `product_channel_map` i `product_link_events`,
- `ChannelOffersRepository` - odczyt cache ofert,
- `LinkMatcherService` - podpowiedzi po EAN/SKU,
- `OfferImportService` - import ofert z API integracji do `channel_offers`.
Wspolpraca z istniejacymi modulami:
- `Settings/IntegrationRepository` - lista aktywnych instancji,
- `Products/ProductRepository` - dane produktu lokalnego,
- `Core/Security/Csrf` + `Auth` - autoryzacja i CSRF dla akcji POST.
## 6. UI/UX (osobna karta)
### 6.1 Widok produktu
W `resources/views/products/show.php` dodac zakladke:
- `Powiazania`.
Nowy widok czesciowy:
- `resources/views/products/partials/links.php`.
### 6.2 Sekcje karty
1. Aktualne powiazania:
- instancja (`integrations.name`),
- kanal (`sales_channels.name`),
- oferta (`name`, `external_product_id`, `external_variant_id`),
- dopasowanie (`link_type`, `link_status`, `confidence`),
- akcje: `Przepnij`, `Odlacz`.
2. Wyszukiwarka ofert:
- filtr po instancji,
- filtr po SKU/EAN/nazwie,
- lista wynikow z `channel_offers`,
- akcja `Powiaz`.
3. Podpowiedzi automatyczne:
- sekcja "Proponowane dopasowania" (EAN/SKU),
- tylko sugestia, finalny zapis reczny przez operatora.
### 6.3 Potwierdzenia i alerty
Krytyczne akcje (`Przepnij`, `Odlacz`) realizowac przez:
- `window.OrderProAlerts.confirm(...)`.
Nie dodawac natywnych `alert()` i `confirm()`.
## 7. API / routing wewnetrzny (SSR + POST)
Proponowane endpointy:
- `GET /products/{id}/links` - zwrot zawartosci karty (lub render w `show`),
- `POST /products/{id}/links` - utworzenie recznego powiazania,
- `POST /products/{id}/links/{mapId}/relink` - przepiecie na inna oferte,
- `POST /products/{id}/links/{mapId}/unlink` - odlaczenie,
- `GET /products/{id}/links/suggestions` - sugestie EAN/SKU.
Backoffice import ofert:
- `POST /settings/integrations/{id}/offers/import` - reczny import cache,
- docelowo cron/job: `php bin/cron_import_offers.php` (po MVP).
## 8. Reguly biznesowe
1. Jeden rekord oferty zewnetrznej (`integration_id + external_product_id + external_variant_id`) moze byc aktywnie powiazany tylko z jednym produktem orderPRO.
2. Jeden produkt orderPRO moze miec wiele powiazan (multi-channel, multi-instance).
3. Powiazanie reczne ma priorytet nad sugestiami auto-match.
4. `EAN exact` ma wyzszy priorytet sugestii niz `SKU exact`.
5. `SKU normalized` (bez spacji, myslnikow, podkreslen) jest nizszy niz `SKU exact`.
6. Konflikty nie modyfikuja automatycznie aktywnego linku - nadaja status `conflict` i wymagaja decyzji operatora.
7. Odlaczenie nie kasuje historii: rekord mapy moze przejsc w `link_status = inactive`.
## 9. Etapy wdrozenia
## Etap A - migracje i repozytoria
- migracja rozszerzajaca `product_channel_map`,
- nowe tabele `channel_offers`, `product_link_events`,
- repozytoria i podstawowe testy manualne SQL.
Kryterium akceptacji:
- migracje przechodza lokalnie,
- mozna zapisac i odczytac link wraz z `integration_id` i `link_status`.
## Etap B - import i cache ofert
- adapter importu ofert z aktywnych integracji,
- upsert do `channel_offers`,
- reczny trigger importu z panelu Integracje.
Kryterium akceptacji:
- po imporcie widoczne sa oferty w cache dla wskazanej integracji,
- kolejne importy aktualizuja rekordy bez duplikatow.
## Etap C - UI karty Powiazania
- dodanie zakladki w szczegolach produktu,
- lista aktywnych powiazan,
- formularz recznego powiazania,
- akcje odlacz/przepnij z `OrderProAlerts.confirm`.
Kryterium akceptacji:
- operator bez SQL moze powiazac, przepiac i odlaczyc oferte.
## Etap D - sugestie i konflikty
- `LinkMatcherService` (EAN/SKU),
- widok sugestii,
- logika konfliktu i statusy.
Kryterium akceptacji:
- sugestie sa widoczne i oznaczone confidence,
- konflikt nie psuje istniejacego aktywnego mapowania.
## Etap E - hardening i audyt
- logowanie zdarzen do `product_link_events`,
- dopracowanie komunikatow i walidacji,
- testy manualne end-to-end.
Kryterium akceptacji:
- kazda operacja reczna ma wpis w historii,
- UI poprawnie pokazuje status i ostatnia zmiane.
## 10. Definicja "Done" dla modulu
- istnieje osobna karta "Powiazania" na produkcie,
- mozliwe jest reczne mapowanie produkt <-> oferta (shopPRO/marketplace),
- dziala odlaczanie i przepinanie z potwierdzeniem UI,
- cache ofert z integracji dziala i jest przeszukiwalny,
- konflikty sa oznaczane i nie niszcza danych,
- operacje sa audytowane.
## 11. Kolejny krok po MVP
Po MVP uruchomic:
1. job/cron cyklicznego importu ofert,
2. automatyczne auto-linkowanie tylko dla przypadkow `confidence >= prog`,
3. masowe operacje mapowania (bulk),
4. wykorzystanie powiazan w sync cen/stanow i publikacji ofert.

View File

@@ -0,0 +1,289 @@
# orderPRO - Plan wdrozenia modulu produktow (bez importu/eksportu)
Data: 2026-02-23
Status: draft do implementacji
## 1. Cel etapu
Zbudowac lokalny modul produktow w orderPRO, gotowy pod obsluge wielu kanalow sprzedazy (co najmniej 2 sklepy shopPRO), ale bez uruchamiania synchronizacji import/export.
W tym etapie tworzymy:
- model danych produktu w orderPRO,
- panel do listowania, filtrowania, podgladu i edycji produktow,
- obsluge wariantow i atrybutow,
- podstawy pod przyszle mapowanie kanalowe (shopPRO, Allegro, Erli).
W tym etapie NIE tworzymy:
- importu z shopPRO,
- eksportu do shopPRO,
- eksportu do marketplace,
- schedulerow/cronow synchronizacji produktow.
## 2. Zalozenia domenowe
1. orderPRO jest source of truth dla danych produktowych po wdrozeniu tego etapu.
2. Produkt ma stabilny identyfikator lokalny oraz techniczny UUID do laczenia z integracjami.
3. Jeden produkt moze miec wiele wariantow.
4. Ceny sa przechowywane lokalnie i przeliczane dwukierunkowo (brutto <-> netto) na podstawie VAT.
5. Stan magazynowy jest przechowywany wylacznie na produkcie glownym.
6. Modul ma byc przygotowany pod wiele kanalow, ale bez aktywnych procesow synchronizacji.
7. W MVP obslugiwany jest jezyk `pl`, z zachowaniem struktury pod kolejne jezyki.
## 3. Zakres funkcjonalny MVP modulu produktow
### 3.1 Lista produktow
- paginacja,
- wyszukiwanie po nazwie, SKU, EAN,
- filtry: status, typ (prosty/wariantowy), producent, data modyfikacji,
- sortowanie: id, nazwa, SKU, cena, stan, status, data modyfikacji,
- widoczne flagi: aktywny/nieaktywny, promowany.
### 3.2 Szczegoly produktu
- dane glowne: nazwa, SKU, EAN, status, promoted,
- ceny: brutto/netto + promo,
- stan magazynowy i waga,
- VAT i jednostka,
- kategorie lokalne,
- opis i meta (PL jako minimum),
- galeria zdjec (metadane i kolejnosc).
### 3.3 Tworzenie i edycja produktu
- formularz z walidacja,
- partial update,
- historia zmian (minimum: kto, kiedy, co zmienil).
### 3.4 Warianty
- dodawanie wariantu po kombinacji atrybutow,
- walidacja unikalnosci kombinacji,
- osobne pola SKU/EAN/cena/waga/status dla wariantu (bez osobnego stanu),
- aktywacja/dezaktywacja wariantu.
### 3.5 Atrybuty i wartosci
- slownik atrybutow i wartosci lokalnych,
- oznaczenie typu atrybutu,
- mozliwosc przypisania wielu atrybutow do produktu.
## 4. Model danych (propozycja)
### 4.1 Tabele glówne
1. `products`
- `id` BIGINT PK
- `uuid` CHAR(36) UNIQUE
- `type` ENUM('simple','variant_parent')
- `sku` VARCHAR(128) NULL UNIQUE
- `ean` VARCHAR(32) NULL
- `status` TINYINT(1)
- `promoted` TINYINT(1)
- `vat` DECIMAL(5,2) NULL
- `weight` DECIMAL(10,3) NULL
- `price_brutto` DECIMAL(12,2)
- `price_brutto_promo` DECIMAL(12,2) NULL
- `price_netto` DECIMAL(12,2) NULL
- `price_netto_promo` DECIMAL(12,2) NULL
- `quantity` DECIMAL(12,3) DEFAULT 0
- `producer_id` BIGINT NULL
- `product_unit_id` BIGINT NULL
- `created_at`, `updated_at`, `deleted_at`
2. `product_translations`
- `id` BIGINT PK
- `product_id` BIGINT FK -> products.id
- `lang` VARCHAR(8)
- `name` VARCHAR(255)
- `short_description` TEXT NULL
- `description` LONGTEXT NULL
- `meta_title` VARCHAR(255) NULL
- `meta_description` VARCHAR(255) NULL
- `meta_keywords` VARCHAR(255) NULL
- `seo_link` VARCHAR(255) NULL
- UNIQUE (`product_id`, `lang`)
3. `product_images`
- `id` BIGINT PK
- `product_id` BIGINT FK
- `storage_path` VARCHAR(255)
- `alt` VARCHAR(255) NULL
- `sort_order` INT DEFAULT 0
- `is_main` TINYINT(1) DEFAULT 0
4. `product_categories`
- `product_id` BIGINT FK
- `category_id` BIGINT FK
- PK (`product_id`, `category_id`)
5. `product_variants`
- `id` BIGINT PK
- `product_id` BIGINT FK -> products.id
- `permutation_hash` VARCHAR(191)
- `sku` VARCHAR(128) NULL UNIQUE
- `ean` VARCHAR(32) NULL
- `status` TINYINT(1)
- `price_brutto` DECIMAL(12,2) NULL
- `price_brutto_promo` DECIMAL(12,2) NULL
- `price_netto` DECIMAL(12,2) NULL
- `price_netto_promo` DECIMAL(12,2) NULL
- `weight` DECIMAL(10,3) NULL
- `created_at`, `updated_at`
- UNIQUE (`product_id`, `permutation_hash`)
6. `product_variant_attributes`
- `variant_id` BIGINT FK -> product_variants.id
- `attribute_id` BIGINT FK
- `value_id` BIGINT FK
- PK (`variant_id`, `attribute_id`)
7. `attributes`
- `id` BIGINT PK
- `type` TINYINT
- `status` TINYINT(1)
8. `attribute_translations`
- `attribute_id` BIGINT FK
- `lang` VARCHAR(8)
- `name` VARCHAR(255)
- PK (`attribute_id`, `lang`)
9. `attribute_values`
- `id` BIGINT PK
- `attribute_id` BIGINT FK
- `status` TINYINT(1)
- `is_default` TINYINT(1)
- `impact_on_price` DECIMAL(12,2) NULL
10. `attribute_value_translations`
- `value_id` BIGINT FK
- `lang` VARCHAR(8)
- `name` VARCHAR(255)
- PK (`value_id`, `lang`)
11. `product_change_log`
- `id` BIGINT PK
- `product_id` BIGINT FK
- `user_id` BIGINT FK -> users.id
- `change_type` VARCHAR(64)
- `before_json` JSON NULL
- `after_json` JSON NULL
- `created_at`
### 4.2 Tabele "future-ready" pod integracje (bez aktywnej synchronizacji)
12. `sales_channels`
- `id` BIGINT PK
- `code` VARCHAR(64) UNIQUE (np. shoppro_1, shoppro_2, allegro, erli)
- `name` VARCHAR(128)
- `type` VARCHAR(64) (shoppro/marketplace)
- `status` TINYINT(1)
13. `product_channel_map`
- `id` BIGINT PK
- `product_id` BIGINT FK
- `channel_id` BIGINT FK
- `external_product_id` VARCHAR(128) NULL
- `external_variant_id` VARCHAR(128) NULL
- `sync_state` VARCHAR(32) DEFAULT 'not_linked'
- `last_sync_at` DATETIME NULL
- UNIQUE (`product_id`, `channel_id`, `external_product_id`, `external_variant_id`)
Uwagi:
- na tym etapie `product_channel_map` jest tylko przygotowaniem danych,
- nie tworzymy procesow, ktore aktualizuja `sync_state` automatycznie.
## 5. Architektura aplikacyjna (orderPRO)
Nowy modul: `src/Modules/Products`
Sugerowane klasy:
- `ProductsController` (SSR: lista, szczegoly, create, update),
- `ProductVariantsController`,
- `AttributesController`,
- `ProductRepository`,
- `VariantRepository`,
- `AttributeRepository`,
- `ProductValidator`,
- `ProductService` (transakcje i reguly domenowe),
- DTO/normalizery do mapowania formularz <-> domena.
Widoki:
- `resources/views/products/index.php`
- `resources/views/products/form.php`
- `resources/views/products/show.php`
- `resources/views/products/partials/variants.php`
- `resources/views/products/partials/attributes.php`
Routing (propozycja):
- `GET /products`
- `GET /products/create`
- `POST /products`
- `GET /products/{id}`
- `POST /products/{id}` (na teraz bez PUT, zgodnie z obecnym stylem formularzy)
- `POST /products/{id}/variants`
- `POST /variants/{id}`
- `GET /attributes`
- `POST /attributes`
## 6. Walidacja i reguly biznesowe
1. Wymagane: minimum jedna nazwa tlumaczenia (`pl.name`) i `price_brutto` dla produktu prostego.
2. `sku` jest unikalne na poziomie produktu glownego (`products.sku`).
3. `sku` wariantu jest unikalne na poziomie wariantu (`product_variants.sku`) gdy jest uzupelnione.
4. Ceny brutto/netto sa liczone dwukierunkowo (na podstawie wpisanego pola i stawki VAT).
5. Wariant musi miec niepusta i unikalna kombinacje atrybutow (`permutation_hash`).
6. Stan magazynowy jest przechowywany tylko na produkcie glownym.
7. `ean` opcjonalny, ale jesli podany to walidowany formatowo.
8. Zmiany krytyczne (cena, status, stan, sku, ean) wpisywane do `product_change_log`.
## 7. Etapy wdrozenia
## Etap A - fundament danych
- migracje tabel produktowych i atrybutowych,
- indeksy pod filtry listy,
- seed bazowych danych slownikowych (opcjonalnie).
Kryterium akceptacji:
- migracje przechodza lokalnie i tabela status w `Settings > Aktualizacja bazy` pokazuje brak pending.
## Etap B - backend CRUD produktu
- repozytoria + serwis + walidacja,
- endpointy/form actions dla create/edit/show/list,
- log zmian.
Kryterium akceptacji:
- mozna utworzyc i edytowac produkt prosty,
- walidacje dzialaja,
- zmiany zapisuja sie w `product_change_log`.
## Etap C - warianty i atrybuty
- CRUD atrybutow i wartosci,
- dodawanie/edycja wariantow,
- unikalnosc kombinacji atrybutow,
- obsluga konwersji simple <-> variant_parent.
Kryterium akceptacji:
- mozna dodac wiele wariantow jednego produktu,
- system blokuje duplikat kombinacji,
- lista i szczegoly poprawnie prezentuja warianty.
## Etap D - UI/UX panelu i stabilizacja
- finalne widoki i filtry,
- komunikaty flash i bledy,
- porzadkowanie tlumaczen `resources/lang/pl.php`,
- testy manualne flow end-to-end.
Kryterium akceptacji:
- caly flow produktowy dziala bez SQL manualnego,
- menu zawiera sekcje "Produkty",
- formularze sa zgodne z istniejacym stylem aplikacji.
## 8. Ustalenia biznesowe (2026-02-23)
1. `sku` jest unikalne na poziomie produktu glownego.
2. `sku` jest unikalne rowniez na poziomie wariantu (jesli wariant ma wypelnione SKU).
3. Ceny netto/brutto sa wyliczane automatycznie w obie strony.
4. Stan magazynowy jest tylko na produkcie glownym.
5. MVP dziala na jezyku `pl`, ale struktura danych zostaje wielojezyczna.
6. Kategorie w tym etapie tylko jako relacja (`product_categories`), bez CRUD kategorii.
7. Zdjecia sa przechowywane na serwerze orderPRO.
8. Dozwolona jest konwersja produktu prostego na wariantowy i odwrotnie.
9. Uprawnienia szczegolowe do modulu produktow sa odlozone (dostep dla zalogowanych).
## 9. Definicja "Done" dla tego etapu
- modul produktow dostepny z panelu,
- kompletne CRUD dla produktu prostego,
- obsluga wariantow i atrybutow,
- dzialajace filtrowanie i paginacja,
- log zmian,
- przygotowane mapowanie kanalowe (tabele), ale bez aktywnej synchronizacji.
## 10. Co dalej po tym etapie
Kolejny etap to osobny plan: import produktow z 2x shopPRO + export z orderPRO do shopPRO/Allegro/Erli z kolejka, retry i monitorowaniem sync.

View File

@@ -1,2 +1,4 @@
1. [x] Wszystkie teksty trzymac w pliku z tlumaczeniami. 1. Na podglądzie produktu zmień wyświetlanie zdjęć na siatkę (grid)
2. [x] Jako styli uzywac SCSS, ktore sa kompilowane i minifikowane do CSS. 2. W tabelach w filtrach filtr Na strone nie jest potrzebny bo ta opcja jest na dole w stronicowaniu.
3. W tabelach w sortowanie nie jest potrzebny bo ta opcja jest dostępna w nagłowkach tabel.
4. https://orderpro.projectpro.pl/products/8 rozszerzyć kolumnę z nazwami parametrów

View File

@@ -13,6 +13,9 @@ return [
'name' => Env::get('SESSION_NAME', 'orderpro_session'), 'name' => Env::get('SESSION_NAME', 'orderpro_session'),
'path' => dirname(__DIR__) . '/storage/sessions', 'path' => dirname(__DIR__) . '/storage/sessions',
], ],
'integrations' => [
'secret' => Env::get('INTEGRATIONS_SECRET', ''),
],
'view_path' => dirname(__DIR__) . '/resources/views', 'view_path' => dirname(__DIR__) . '/resources/views',
'lang_path' => dirname(__DIR__) . '/resources/lang', 'lang_path' => dirname(__DIR__) . '/resources/lang',
'log_path' => dirname(__DIR__) . '/storage/logs/app.log', 'log_path' => dirname(__DIR__) . '/storage/logs/app.log',

View File

@@ -0,0 +1,74 @@
CREATE TABLE IF NOT EXISTS products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL,
type ENUM('simple','variant_parent') NOT NULL DEFAULT 'simple',
sku VARCHAR(128) NULL,
ean VARCHAR(32) NULL,
status TINYINT(1) NOT NULL DEFAULT 1,
promoted TINYINT(1) NOT NULL DEFAULT 0,
vat DECIMAL(5,2) NULL,
weight DECIMAL(10,3) NULL,
price_brutto DECIMAL(12,2) NOT NULL DEFAULT 0.00,
price_brutto_promo DECIMAL(12,2) NULL,
price_netto DECIMAL(12,2) NULL,
price_netto_promo DECIMAL(12,2) NULL,
quantity DECIMAL(12,3) NOT NULL DEFAULT 0.000,
producer_id INT UNSIGNED NULL,
product_unit_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE KEY products_uuid_unique (uuid),
UNIQUE KEY products_sku_unique (sku),
KEY products_status_idx (status),
KEY products_type_idx (type),
KEY products_updated_at_idx (updated_at),
KEY products_ean_idx (ean)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS product_translations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
lang VARCHAR(8) NOT NULL,
name VARCHAR(255) NOT NULL,
short_description TEXT NULL,
description LONGTEXT NULL,
meta_title VARCHAR(255) NULL,
meta_description VARCHAR(255) NULL,
meta_keywords VARCHAR(255) NULL,
seo_link VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY product_translations_product_lang_unique (product_id, lang),
KEY product_translations_lang_name_idx (lang, name),
CONSTRAINT product_translations_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS product_images (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
storage_path VARCHAR(255) NOT NULL,
alt VARCHAR(255) NULL,
sort_order INT NOT NULL DEFAULT 0,
is_main TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY product_images_product_sort_idx (product_id, sort_order),
KEY product_images_main_idx (product_id, is_main),
CONSTRAINT product_images_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS product_categories (
product_id INT UNSIGNED NOT NULL,
category_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (product_id, category_id),
KEY product_categories_category_idx (category_id),
CONSTRAINT product_categories_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,50 @@
CREATE TABLE IF NOT EXISTS attributes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
type TINYINT UNSIGNED NOT NULL DEFAULT 1,
status TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY attributes_status_idx (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS attribute_translations (
attribute_id INT UNSIGNED NOT NULL,
lang VARCHAR(8) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (attribute_id, lang),
KEY attribute_translations_lang_name_idx (lang, name),
CONSTRAINT attribute_translations_attribute_fk
FOREIGN KEY (attribute_id) REFERENCES attributes(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS attribute_values (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
attribute_id INT UNSIGNED NOT NULL,
status TINYINT(1) NOT NULL DEFAULT 1,
is_default TINYINT(1) NOT NULL DEFAULT 0,
impact_on_price DECIMAL(12,2) NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY attribute_values_attribute_idx (attribute_id),
KEY attribute_values_default_idx (attribute_id, is_default),
CONSTRAINT attribute_values_attribute_fk
FOREIGN KEY (attribute_id) REFERENCES attributes(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS attribute_value_translations (
value_id INT UNSIGNED NOT NULL,
lang VARCHAR(8) NOT NULL,
name VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (value_id, lang),
KEY attribute_value_translations_lang_name_idx (lang, name),
CONSTRAINT attribute_value_translations_value_fk
FOREIGN KEY (value_id) REFERENCES attribute_values(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS product_variants (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
permutation_hash VARCHAR(191) NOT NULL,
sku VARCHAR(128) NULL,
ean VARCHAR(32) NULL,
status TINYINT(1) NOT NULL DEFAULT 1,
price_brutto DECIMAL(12,2) NULL,
price_brutto_promo DECIMAL(12,2) NULL,
price_netto DECIMAL(12,2) NULL,
price_netto_promo DECIMAL(12,2) NULL,
weight DECIMAL(10,3) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY product_variants_sku_unique (sku),
UNIQUE KEY product_variants_product_permutation_unique (product_id, permutation_hash),
KEY product_variants_product_idx (product_id),
KEY product_variants_status_idx (status),
CONSTRAINT product_variants_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS product_variant_attributes (
variant_id INT UNSIGNED NOT NULL,
attribute_id INT UNSIGNED NOT NULL,
value_id INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (variant_id, attribute_id),
KEY product_variant_attributes_value_idx (value_id),
CONSTRAINT product_variant_attributes_variant_fk
FOREIGN KEY (variant_id) REFERENCES product_variants(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT product_variant_attributes_attribute_fk
FOREIGN KEY (attribute_id) REFERENCES attributes(id)
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT product_variant_attributes_value_fk
FOREIGN KEY (value_id) REFERENCES attribute_values(id)
ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS product_change_log (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NULL,
change_type VARCHAR(64) NOT NULL,
before_json JSON NULL,
after_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY product_change_log_product_idx (product_id),
KEY product_change_log_user_idx (user_id),
KEY product_change_log_created_idx (created_at),
CONSTRAINT product_change_log_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT product_change_log_user_fk
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS sales_channels (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
type VARCHAR(64) NOT NULL,
status TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY sales_channels_code_unique (code),
KEY sales_channels_type_status_idx (type, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS product_channel_map (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
external_product_id VARCHAR(128) NULL,
external_variant_id VARCHAR(128) NULL,
sync_state VARCHAR(32) NOT NULL DEFAULT 'not_linked',
last_sync_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY product_channel_map_product_idx (product_id),
KEY product_channel_map_channel_idx (channel_id),
KEY product_channel_map_sync_state_idx (sync_state),
UNIQUE KEY product_channel_map_unique (product_id, channel_id, external_product_id, external_variant_id),
CONSTRAINT product_channel_map_product_fk
FOREIGN KEY (product_id) REFERENCES products(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT product_channel_map_channel_fk
FOREIGN KEY (channel_id) REFERENCES sales_channels(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS integrations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
type VARCHAR(32) NOT NULL,
name VARCHAR(128) NOT NULL,
base_url VARCHAR(255) NOT NULL,
api_key_encrypted TEXT NULL,
timeout_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 10,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_test_status VARCHAR(16) NULL,
last_test_http_code SMALLINT UNSIGNED NULL,
last_test_message VARCHAR(255) NULL,
last_test_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY integrations_type_name_unique (type, name),
KEY integrations_type_active_idx (type, is_active),
KEY integrations_last_test_at_idx (last_test_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS integration_test_logs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
integration_id INT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
http_code SMALLINT UNSIGNED NULL,
message VARCHAR(255) NOT NULL,
endpoint_url VARCHAR(255) NULL,
tested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY integration_test_logs_integration_idx (integration_id),
KEY integration_test_logs_tested_at_idx (tested_at),
CONSTRAINT integration_test_logs_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,77 @@
ALTER TABLE product_channel_map
ADD COLUMN integration_id INT UNSIGNED NULL AFTER channel_id,
ADD COLUMN link_type VARCHAR(32) NOT NULL DEFAULT 'manual' AFTER sync_state,
ADD COLUMN link_status VARCHAR(32) NOT NULL DEFAULT 'active' AFTER link_type,
ADD COLUMN confidence TINYINT UNSIGNED NULL AFTER link_status,
ADD COLUMN linked_at DATETIME NULL AFTER confidence,
ADD COLUMN linked_by_user_id INT UNSIGNED NULL AFTER linked_at,
ADD COLUMN unlinked_at DATETIME NULL AFTER linked_by_user_id,
ADD COLUMN unlinked_by_user_id INT UNSIGNED NULL AFTER unlinked_at,
ADD COLUMN sync_meta_json JSON NULL AFTER unlinked_by_user_id,
ADD KEY product_channel_map_integration_external_idx (integration_id, external_product_id, external_variant_id),
ADD KEY product_channel_map_product_link_status_idx (product_id, link_status),
ADD KEY product_channel_map_channel_link_status_idx (channel_id, link_status),
ADD KEY product_channel_map_linked_by_user_idx (linked_by_user_id),
ADD KEY product_channel_map_unlinked_by_user_idx (unlinked_by_user_id),
ADD CONSTRAINT product_channel_map_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT product_channel_map_linked_by_user_fk
FOREIGN KEY (linked_by_user_id) REFERENCES users(id)
ON DELETE SET NULL ON UPDATE CASCADE,
ADD CONSTRAINT product_channel_map_unlinked_by_user_fk
FOREIGN KEY (unlinked_by_user_id) REFERENCES users(id)
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE TABLE IF NOT EXISTS channel_offers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
integration_id INT UNSIGNED NOT NULL,
channel_id INT UNSIGNED NOT NULL,
external_product_id VARCHAR(128) NOT NULL,
external_variant_id VARCHAR(128) NULL,
external_offer_id VARCHAR(128) NULL,
name VARCHAR(255) NOT NULL,
sku VARCHAR(128) NULL,
ean VARCHAR(32) NULL,
price_brutto DECIMAL(12,2) NULL,
quantity DECIMAL(12,3) NULL,
currency VARCHAR(8) NULL,
offer_status VARCHAR(32) NOT NULL DEFAULT 'active',
source_updated_at DATETIME NULL,
last_seen_at DATETIME NOT NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY channel_offers_unique_external (integration_id, external_product_id, external_variant_id),
KEY channel_offers_integration_channel_idx (integration_id, channel_id),
KEY channel_offers_sku_idx (sku),
KEY channel_offers_ean_idx (ean),
KEY channel_offers_offer_status_idx (offer_status),
KEY channel_offers_last_seen_idx (last_seen_at),
CONSTRAINT channel_offers_integration_fk
FOREIGN KEY (integration_id) REFERENCES integrations(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT channel_offers_channel_fk
FOREIGN KEY (channel_id) REFERENCES sales_channels(id)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS product_link_events (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
product_channel_map_id INT UNSIGNED NOT NULL,
event_type VARCHAR(32) NOT NULL,
before_json JSON NULL,
after_json JSON NULL,
created_by_user_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY product_link_events_map_idx (product_channel_map_id),
KEY product_link_events_event_type_idx (event_type),
KEY product_link_events_created_by_user_idx (created_by_user_id),
KEY product_link_events_created_at_idx (created_at),
CONSTRAINT product_link_events_map_fk
FOREIGN KEY (product_channel_map_id) REFERENCES product_channel_map(id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT product_link_events_created_by_user_fk
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
:root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:8px 16px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.alert{padding:12px 14px;border-radius:8px;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}} :root{--c-primary: #6690f4;--c-primary-dark: #3164db;--c-bg: #f4f6f9;--c-surface: #ffffff;--c-text: #4e5e6a;--c-text-strong: #2d3748;--c-muted: #718096;--c-border: #e2e8f0;--c-danger: #cc0000;--focus-ring: 0 0 0 3px rgba(102, 144, 244, 0.15);--shadow-card: 0 1px 4px rgba(0, 0, 0, 0.06)}.btn{display:inline-flex;align-items:center;justify-content:center;min-height:38px;padding:8px 16px;border:1px solid rgba(0,0,0,0);border-radius:8px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .2s ease,border-color .2s ease,color .2s ease,transform .1s ease}.btn--primary{color:#fff;background:var(--c-primary)}.btn--primary:hover{background:var(--c-primary-dark)}.btn--secondary{color:var(--c-text-strong);border-color:var(--c-border);background:var(--c-surface)}.btn--secondary:hover{border-color:#cbd5e0;background:#f8fafc}.btn--danger{color:#fff;border-color:#b91c1c;background:#dc2626}.btn--danger:hover{border-color:#991b1b;background:#b91c1c}.btn--block{width:100%}.btn:active{transform:translateY(1px)}.btn:focus-visible{outline:none;box-shadow:var(--focus-ring);border-color:var(--c-primary)}.form-control{width:100%;min-height:38px;border:1px solid var(--c-border);border-radius:8px;padding:7px 12px;font:inherit;color:var(--c-text-strong);background:#fff;transition:border-color .2s ease,box-shadow .2s ease}.form-control:focus{outline:none;border-color:var(--c-primary);box-shadow:var(--focus-ring)}.alert{padding:12px 14px;border-radius:8px;border:1px solid rgba(0,0,0,0);font-size:13px;min-height:44px}.alert--danger{border-color:#fed7d7;background:#fff5f5;color:var(--c-danger)}.alert--success{border-color:#b7ebcf;background:#f0fff6;color:#0f6b39}.alert--warning{border-color:#f7dd8b;background:#fff8e8;color:#815500}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.table-wrap{width:100%;overflow-x:auto}.table{width:100%;border-collapse:collapse;background:var(--c-surface)}.table th,.table td{padding:10px 12px;border-bottom:1px solid var(--c-border);text-align:left}.table th{color:var(--c-text-strong);font-weight:700;background:#f8fafc}.pagination{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.pagination__item{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 10px;border-radius:8px;border:1px solid var(--c-border);color:var(--c-text-strong);background:var(--c-surface);text-decoration:none;font-weight:600}.pagination__item:hover{border-color:#cbd5e0;background:#f8fafc}.pagination__item.is-active{border-color:var(--c-primary);color:var(--c-primary);background:#edf2ff}:root{--shadow-card: 0 20px 50px rgba(22, 34, 58, 0.14)}*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;font-family:"Roboto","Segoe UI",sans-serif;color:var(--c-text);background:var(--c-bg);overflow-x:hidden}.bg-orb{position:fixed;width:460px;height:460px;border-radius:999px;filter:blur(28px);z-index:0;opacity:.45;pointer-events:none}.bg-orb-left{top:-200px;left:-180px;background:radial-gradient(circle, rgba(102, 144, 244, 0.48) 0%, rgba(102, 144, 244, 0) 70%)}.bg-orb-right{right:-200px;bottom:-220px;background:radial-gradient(circle, rgba(30, 42, 58, 0.36) 0%, rgba(30, 42, 58, 0) 70%)}.login-page{min-height:100vh;display:grid;place-items:center;padding:32px 20px;position:relative;z-index:1}.login-card{width:100%;max-width:430px;background:var(--c-surface);border:1px solid var(--c-border);border-radius:12px;box-shadow:var(--shadow-card);padding:34px 30px 28px;animation:card-enter 420ms ease-out}.login-header{margin-bottom:24px}.login-badge{display:inline-block;margin:0 0 14px;padding:5px 12px;border-radius:999px;border:1px solid #d9e2ff;background:#eef2ff;color:#3f5faf;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em}h1{margin:0;color:var(--c-text-strong);font-size:clamp(1.6rem,2.5vw,1.9rem);line-height:1.15;font-weight:700}.login-subtitle{margin:10px 0 0;font-size:15px;line-height:1.55;color:var(--c-muted)}.login-alert{margin-bottom:18px}.login-alert-placeholder{opacity:.56}.login-form{display:grid;gap:16px}.form-field{display:grid;gap:7px}.field-label{color:var(--c-text-strong);font-size:13px;font-weight:600}.login-form .form-control{min-height:46px;padding:0 14px;border-width:2px}.login-form .form-control::placeholder{color:#cbd5e0}.login-submit{margin-top:2px;font-size:15px;min-height:48px}@keyframes card-enter{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@media(max-width: 640px){.login-page{padding:18px 14px}.login-card{padding:24px 20px 20px}h1{font-size:1.55rem}}

View File

@@ -1 +1 @@
.jq-alert{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:12px 14px;border:1px solid rgba(0,0,0,0);border-radius:8px;font-size:14px;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease}.jq-alert.is-visible{opacity:1;transform:translateY(0)}.jq-alert--info{color:#1e3a8a;background:#eff6ff;border-color:#bfdbfe}.jq-alert--success{color:#065f46;background:#ecfdf5;border-color:#a7f3d0}.jq-alert--warning{color:#92400e;background:#fffbeb;border-color:#fde68a}.jq-alert--error{color:#991b1b;background:#fef2f2;border-color:#fecaca}.jq-alert__content{flex:1}.jq-alert__close{appearance:none;border:0;padding:0;line-height:1;font-size:18px;cursor:pointer;color:inherit;background:rgba(0,0,0,0)} .jq-alert-host{position:fixed;top:16px;right:16px;z-index:300;width:min(420px,100vw - 32px)}.jq-alert{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:12px 14px;border:1px solid rgba(0,0,0,0);border-radius:8px;font-size:14px;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease}.jq-alert.is-visible{opacity:1;transform:translateY(0)}.jq-alert--info{color:#1e3a8a;background:#eff6ff;border-color:#bfdbfe}.jq-alert--success{color:#065f46;background:#ecfdf5;border-color:#a7f3d0}.jq-alert--warning{color:#92400e;background:#fffbeb;border-color:#fde68a}.jq-alert--error{color:#991b1b;background:#fef2f2;border-color:#fecaca}.jq-alert__content{flex:1}.jq-alert__close{appearance:none;border:0;padding:0;line-height:1;font-size:18px;cursor:pointer;color:inherit;background:rgba(0,0,0,0)}.jq-alert-modal-backdrop{position:fixed;inset:0;z-index:310;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(15,23,42,.5);opacity:0;transition:opacity .18s ease}.jq-alert-modal-backdrop.is-visible{opacity:1}.jq-alert-modal{width:min(520px,100%);border-radius:10px;border:1px solid #dbe3ee;background:#fff;box-shadow:0 18px 42px rgba(15,23,42,.3)}.jq-alert-modal__header{padding:14px 16px;border-bottom:1px solid #e2e8f0}.jq-alert-modal__header h3{margin:0;color:#2d3748;font-size:18px}.jq-alert-modal__body{padding:14px 16px;color:#4e5e6a;line-height:1.45}.jq-alert-modal__footer{display:flex;justify-content:flex-end;gap:8px;padding:0 16px 14px}

View File

@@ -1,17 +1,17 @@
"use strict"; "use strict";
(function (factory) { (function (factory) {
if (typeof module === "object" && module.exports) { if (typeof module === "object" && module.exports && typeof window === "undefined") {
module.exports = factory; module.exports = factory(null, null);
return; return;
} }
if (typeof window.jQuery !== "undefined") { var win = typeof window !== "undefined" ? window : null;
factory(window.jQuery); var $ = win && typeof win.jQuery !== "undefined" ? win.jQuery : null;
} factory($, win);
})(function ($) { })(function ($, win) {
if (!$ || !$.fn) { if (!win || !win.document) {
return; return {};
} }
const DEFAULTS = { const DEFAULTS = {
@@ -21,52 +21,190 @@
classPrefix: "jq-alert", classPrefix: "jq-alert",
}; };
function removeAlert($el) { function removeAlert(el) {
$el.removeClass("is-visible"); if (!el) {
window.setTimeout(function () { return;
$el.remove(); }
el.classList.remove("is-visible");
win.setTimeout(function () {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
}, 180); }, 180);
} }
$.fn.orderProAlert = function (options) { function createElement(tag, className) {
const settings = $.extend({}, DEFAULTS, options); var el = win.document.createElement(tag);
if (className) {
el.className = className;
}
return el;
}
return this.each(function () { function getGlobalHost() {
const $host = $(this); var id = "jq-alert-host";
const $alert = $("<div>", { var existing = win.document.getElementById(id);
class: settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible", if (existing) {
role: "alert", return existing;
}
var host = createElement("div", "jq-alert-host");
host.id = id;
win.document.body.appendChild(host);
return host;
}
function renderAlert(host, options) {
var settings = Object.assign({}, DEFAULTS, options || {});
var alertEl = createElement(
"div",
settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible"
);
alertEl.setAttribute("role", "alert");
var content = createElement("div", settings.classPrefix + "__content");
content.textContent = String(settings.message || "");
alertEl.appendChild(content);
if (settings.dismissible) {
var close = createElement("button", settings.classPrefix + "__close");
close.type = "button";
close.setAttribute("aria-label", "Close alert");
close.textContent = "x";
close.addEventListener("click", function () {
removeAlert(alertEl);
}); });
alertEl.appendChild(close);
}
const $content = $("<div>", { host.appendChild(alertEl);
class: settings.classPrefix + "__content",
text: String(settings.message || ""), if (settings.timeout > 0) {
win.setTimeout(function () {
removeAlert(alertEl);
}, settings.timeout);
}
return alertEl;
}
function show(options) {
return renderAlert(getGlobalHost(), options);
}
function closeConfirm(backdrop, resolve, value) {
if (!backdrop || backdrop.getAttribute("data-closed") === "1") {
return;
}
backdrop.setAttribute("data-closed", "1");
backdrop.classList.remove("is-visible");
win.setTimeout(function () {
if (backdrop.parentNode) {
backdrop.parentNode.removeChild(backdrop);
}
resolve(value);
}, 180);
}
function confirm(options) {
var settings = Object.assign(
{
title: "Potwierdzenie",
message: "",
confirmLabel: "Potwierdz",
cancelLabel: "Anuluj",
danger: false,
},
options || {}
);
return new Promise(function (resolve) {
var backdrop = createElement("div", "jq-alert-modal-backdrop is-visible");
var modal = createElement("div", "jq-alert-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
var titleId = "jq-alert-modal-title-" + Date.now();
modal.setAttribute("aria-labelledby", titleId);
var header = createElement("div", "jq-alert-modal__header");
var title = createElement("h3");
title.id = titleId;
title.textContent = String(settings.title || "");
header.appendChild(title);
var body = createElement("div", "jq-alert-modal__body");
body.textContent = String(settings.message || "");
var footer = createElement("div", "jq-alert-modal__footer");
var cancelButton = createElement("button", "btn btn--secondary");
cancelButton.type = "button";
cancelButton.textContent = String(settings.cancelLabel || "Anuluj");
var confirmButton = createElement(
"button",
settings.danger ? "btn btn--danger" : "btn btn--primary"
);
confirmButton.type = "button";
confirmButton.textContent = String(settings.confirmLabel || "Potwierdz");
footer.appendChild(cancelButton);
footer.appendChild(confirmButton);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
backdrop.appendChild(modal);
win.document.body.appendChild(backdrop);
var onKeyDown = function (event) {
if (event.key === "Escape") {
onCancel();
}
};
var cleanup = function () {
win.document.removeEventListener("keydown", onKeyDown);
};
var onConfirm = function () {
cleanup();
if (typeof settings.onConfirm === "function") {
settings.onConfirm();
}
closeConfirm(backdrop, resolve, true);
};
var onCancel = function () {
cleanup();
if (typeof settings.onCancel === "function") {
settings.onCancel();
}
closeConfirm(backdrop, resolve, false);
};
confirmButton.addEventListener("click", onConfirm);
cancelButton.addEventListener("click", onCancel);
backdrop.addEventListener("click", function (event) {
if (event.target === backdrop) {
onCancel();
}
}); });
win.document.addEventListener("keydown", onKeyDown);
$alert.append($content); confirmButton.focus();
if (settings.dismissible) {
const $close = $("<button>", {
class: settings.classPrefix + "__close",
type: "button",
"aria-label": "Close alert",
text: "x",
});
$close.on("click", function () {
removeAlert($alert);
});
$alert.append($close);
}
$host.append($alert);
if (settings.timeout > 0) {
window.setTimeout(function () {
removeAlert($alert);
}, settings.timeout);
}
}); });
}
win.OrderProAlerts = {
show: show,
confirm: confirm,
}; };
if ($ && $.fn) {
$.fn.orderProAlert = function (options) {
var settings = $.extend({}, DEFAULTS, options);
return this.each(function () {
renderAlert(this, settings);
});
};
}
return win.OrderProAlerts;
}); });

View File

@@ -19,6 +19,7 @@ return [
'navigation' => [ 'navigation' => [
'main_menu' => 'Menu glowne', 'main_menu' => 'Menu glowne',
'users' => 'Uzytkownicy', 'users' => 'Uzytkownicy',
'products' => 'Produkty',
'dashboard' => 'Dashboard', 'dashboard' => 'Dashboard',
'settings' => 'Ustawienia', 'settings' => 'Ustawienia',
], ],
@@ -68,6 +69,203 @@ return [
'email_taken' => 'Ten adres email jest juz zajety.', 'email_taken' => 'Ten adres email jest juz zajety.',
'password_min' => 'Haslo musi miec co najmniej 8 znakow.', 'password_min' => 'Haslo musi miec co najmniej 8 znakow.',
], ],
'filters' => [
'search' => 'Szukaj (imie, email)',
'sort' => 'Sortowanie',
'direction' => 'Kierunek',
'per_page' => 'Na strone',
],
],
'products' => [
'title' => 'Produkty',
'description' => 'Lokalna baza produktow orderPRO przygotowana pod przyszly sync kanalowy.',
'empty' => 'Brak produktow. Dodaj pierwszy produkt.',
'flash' => [
'created' => 'Produkt zostal dodany.',
'updated' => 'Produkt zostal zaktualizowany.',
'deleted' => 'Produkt zostal usuniety.',
'delete_failed' => 'Nie udalo sie usunac produktu.',
'not_found' => 'Nie znaleziono wskazanego produktu.',
],
'actions' => [
'add' => 'Dodaj produkt',
'import_shoppro' => 'Import z shopPRO',
'preview' => 'Podglad',
'links' => 'Powiazania',
'edit' => 'Edytuj',
'delete' => 'Usun',
'save' => 'Zapisz',
'back' => 'Wroc do listy',
'filter' => 'Filtruj',
'reset' => 'Resetuj',
],
'tabs' => [
'details' => 'Szczegoly',
'links' => 'Powiazania',
],
'confirm' => [
'delete' => 'Czy na pewno usunac produkt #:id?',
],
'fields' => [
'name' => 'Nazwa',
'type' => 'Typ',
'status' => 'Status',
'promoted' => 'Promowany',
'vat' => 'VAT (%)',
'weight' => 'Waga',
'quantity' => 'Stan',
'price_input_mode' => 'Tryb ceny',
'price_brutto' => 'Cena brutto',
'price_netto' => 'Cena netto',
'price_brutto_promo' => 'Cena brutto promo',
'price_netto_promo' => 'Cena netto promo',
'short_description' => 'Krotki opis',
'description' => 'Opis',
'meta_title' => 'Meta title',
'meta_description' => 'Meta description',
'meta_keywords' => 'Meta keywords',
'seo_link' => 'SEO link',
'updated_at' => 'Data modyfikacji',
'actions' => 'Akcje',
],
'status' => [
'active' => 'Aktywny',
'inactive' => 'Nieaktywny',
],
'promoted' => [
'yes' => 'Tak',
'no' => 'Nie',
],
'type' => [
'simple' => 'Prosty',
'variant_parent' => 'Wariantowy',
],
'price_mode' => [
'brutto' => 'Wpisuje brutto',
'netto' => 'Wpisuje netto',
],
'filters' => [
'title' => 'Filtry i sortowanie',
'search' => 'Szukaj (nazwa, SKU, EAN)',
'status' => 'Status',
'type' => 'Typ',
'sort' => 'Sortowanie',
'direction' => 'Kierunek',
'per_page' => 'Na strone',
'any' => 'Wszystkie',
],
'pagination' => [
'summary' => 'Lacznie rekordow: :total',
],
'create' => [
'title' => 'Dodaj produkt',
'description' => 'Utworz lokalny produkt w orderPRO (bez eksportu).',
],
'edit' => [
'title' => 'Edycja produktu #:id',
'description' => 'Aktualizuj dane produktu i zapisuj zmiany w logu.',
],
'show' => [
'title' => 'Podglad produktu #:id',
'description' => 'Widok tylko do odczytu wszystkich danych produktu.',
'details' => 'Szczegoly produktu',
],
'links' => [
'title' => 'Powiazania',
'page_title' => 'Powiazania produktu #:id',
'description' => 'Mapowanie produktu orderPRO do ofert zewnetrznych (shopPRO / marketplace).',
'current_links' => 'Aktualne powiazania',
'search_title' => 'Wyszukiwarka ofert',
'empty_links' => 'Brak powiazan dla tego produktu.',
'empty_offers' => 'Brak ofert do wyswietlenia. Wybierz integracje, wpisz filtr lub wykonaj import ofert.',
'integration_placeholder' => '-- wybierz integracje --',
'search_placeholder' => 'Szukaj po nazwie, SKU, EAN lub external_product_id',
'fields' => [
'integration' => 'Instancja',
'channel' => 'Kanal',
'search' => 'Fraza',
'offer_name' => 'Oferta',
'external_product_id' => 'External product ID',
'external_variant_id' => 'External variant ID',
'external_variant_id_optional' => 'Opcjonalny variant ID',
'link_type' => 'Typ',
'confidence' => 'Confidence',
'link_status' => 'Status',
'updated_at' => 'Ostatnia zmiana',
'history' => 'Historia',
'match_hint' => 'Dopasowanie',
'actions' => 'Akcje',
],
'actions' => [
'search' => 'Szukaj ofert',
'link' => 'Powiaz',
'relink' => 'Przepnij',
'unlink' => 'Odlacz',
],
'confirm' => [
'title' => 'Potwierdzenie',
'unlink_message' => 'Czy na pewno odlaczyc to powiazanie?',
'relink_message' => 'Czy na pewno przepiac powiazanie na nowe ID zewnetrzne?',
'yes' => 'Potwierdz',
'no' => 'Anuluj',
],
'flash' => [
'linked' => 'Powiazanie zostalo zapisane.',
'relinked' => 'Powiazanie zostalo przepiete.',
'unlinked' => 'Powiazanie zostalo odlaczone.',
'link_failed' => 'Nie udalo sie zapisac powiazania.',
'relink_failed' => 'Nie udalo sie przepiac powiazania.',
'unlink_failed' => 'Nie udalo sie odlaczyc powiazania.',
],
],
'images' => [
'title' => 'Zdjecia',
'description' => 'Aktualne zdjecia produktu. Mozesz usunac wybrane, dodac nowe i wskazac glowne.',
'empty' => 'Brak zapisanych zdjec.',
'remove' => 'Usun to zdjecie',
'add_new' => 'Dodaj nowe zdjecia',
'set_main' => 'Ustaw jako glowne',
'main' => 'Glowne',
'main_hint' => 'Jesli nie wybierzesz recznie zdjecia glownego, system ustawi pierwsze dostepne.',
'uploading' => 'Wysylanie zdjec...',
'uploaded_ok' => 'Zdjecia zostaly dodane.',
'confirm_title' => 'Potwierdzenie',
'confirm_delete' => 'Czy na pewno usunac to zdjecie?',
'confirm_yes' => 'Usun',
'confirm_no' => 'Anuluj',
],
'variants' => [
'title' => 'Warianty produktu',
'empty' => 'Brak wariantow dla tego produktu.',
'attributes' => 'Atrybuty',
'import_warning_title' => 'Ostrzezenie po imporcie wariantow',
'import_warning_date' => 'Data ostrzezenia',
],
'import' => [
'title' => 'Import produktow z shopPRO',
'close' => 'Zamknij',
'integration' => 'Integracja',
'integration_placeholder' => '-- wybierz integracje --',
'mode' => 'Zakres importu',
'mode_all' => 'Wszystkie produkty',
'mode_single' => 'Pojedynczy produkt',
'external_id' => 'ID produktu w shopPRO',
'with_variants' => 'Importuj warianty produktu',
'with_variants_hint' => 'Opcja nadpisze lokalna liste wariantow dla importowanego produktu danymi z shopPRO.',
'no_integrations' => 'Brak aktywnych integracji shopPRO z kluczem API. Skonfiguruj je w Ustawienia -> Integracje shopPRO.',
'submit' => 'Uruchom import',
'flash' => [
'failed' => 'Import produktow zakonczyl sie bledem.',
'integration_required' => 'Wybierz integracje do importu.',
'integration_not_found' => 'Nie znaleziono wskazanej integracji.',
'api_key_missing' => 'Wybrana integracja nie ma zapisanego klucza API.',
'single_id_required' => 'Dla importu pojedynczego podaj ID produktu.',
'mode_invalid' => 'Niepoprawny tryb importu.',
'no_products' => 'Brak produktow do importu po stronie shopPRO.',
'single_ok' => 'Import zakonczony. shopPRO #:external_id -> lokalny produkt #:local_id.',
'all_done' => 'Import zakonczony. Zaimportowano: :imported, bledy: :failed.',
],
],
], ],
'settings' => [ 'settings' => [
'title' => 'Ustawienia', 'title' => 'Ustawienia',
@@ -98,5 +296,77 @@ return [
'failed' => 'Nie udalo sie wykonac migracji. Sprawdz log i polaczenie bazy.', 'failed' => 'Nie udalo sie wykonac migracji. Sprawdz log i polaczenie bazy.',
], ],
], ],
'integrations' => [
'title' => 'Integracje shopPRO',
'list_title' => 'Integracje shopPRO',
'create_title' => 'Dodaj integracje',
'edit_title' => 'Edytuj integracje',
'empty' => 'Brak skonfigurowanych integracji.',
'fields' => [
'name' => 'Nazwa',
'base_url' => 'Base URL',
'api_key' => 'API Key',
'timeout_seconds' => 'Timeout (sekundy)',
'active' => 'Aktywna',
'active_checkbox' => 'Integracja aktywna',
'last_test' => 'Ostatni test',
'actions' => 'Akcje',
],
'actions' => [
'save' => 'Zapisz integracje',
'edit' => 'Edytuj',
'test' => 'Test polaczenia',
'test_now' => 'Sprawdz teraz',
'import_offers_cache' => 'Importuj oferty',
'new' => 'Nowa integracja',
],
'active' => [
'yes' => 'Tak',
'no' => 'Nie',
],
'test_status' => [
'never' => 'Nie testowano',
'ok' => 'OK',
'error' => 'Blad',
],
'logs_title' => 'Ostatnie testy polaczenia',
'logs' => [
'fields' => [
'tested_at' => 'Data testu',
'status' => 'Status',
'http_code' => 'HTTP',
'message' => 'Komunikat',
],
],
'api_key_placeholder_edit' => 'Zostaw puste, aby zachowac obecny klucz',
'api_key_saved' => 'Klucz API jest zapisany.',
'api_key_missing' => 'Brak zapisanego klucza API.',
'validation' => [
'name_min' => 'Nazwa integracji musi miec co najmniej 2 znaki.',
'base_url_invalid' => 'Podaj poprawny adres URL (http lub https).',
'api_key_required' => 'Podaj klucz API dla integracji.',
'name_taken' => 'Integracja o tej nazwie juz istnieje.',
],
'flash' => [
'created' => 'Integracja zostala dodana.',
'updated' => 'Integracja zostala zapisana.',
'not_found' => 'Nie znaleziono wskazanej integracji.',
'failed' => 'Nie udalo sie zapisac integracji.',
'test_ok' => 'Test polaczenia zakonczony powodzeniem.',
'test_failed' => 'Test polaczenia zakonczyl sie bledem.',
'import_offers_ok' => 'Import cache ofert zakonczony. Przetworzone rekordy: :imported, bledy: :failed, strony API: :pages.',
'import_offers_failed' => 'Import cache ofert zakonczyl sie bledem.',
'import_ok' => 'Import produktu zakonczony. shopPRO #:external_id -> lokalny produkt #:local_id. Kanaly: shopPRO=wystawiony, Allegro=nieustalone, Erli=niedostepny.',
'import_failed' => 'Import produktu zakonczyl sie bledem.',
'import_no_products' => 'Brak aktywnych produktow do importu po stronie shopPRO.',
],
'import' => [
'title' => 'Import testowy produktu',
'description' => 'Importuje jeden produkt z shopPRO. Mozesz podac ID produktu albo zostawic puste, aby pobrac pierwszy aktywny.',
'external_product_id' => 'ID produktu shopPRO (opcjonalnie)',
'external_product_id_placeholder' => 'np. 123',
'action' => 'Importuj 1 produkt',
],
],
], ],
]; ];

View File

@@ -1,17 +1,17 @@
"use strict"; "use strict";
(function (factory) { (function (factory) {
if (typeof module === "object" && module.exports) { if (typeof module === "object" && module.exports && typeof window === "undefined") {
module.exports = factory; module.exports = factory(null, null);
return; return;
} }
if (typeof window.jQuery !== "undefined") { var win = typeof window !== "undefined" ? window : null;
factory(window.jQuery); var $ = win && typeof win.jQuery !== "undefined" ? win.jQuery : null;
} factory($, win);
})(function ($) { })(function ($, win) {
if (!$ || !$.fn) { if (!win || !win.document) {
return; return {};
} }
const DEFAULTS = { const DEFAULTS = {
@@ -21,52 +21,190 @@
classPrefix: "jq-alert", classPrefix: "jq-alert",
}; };
function removeAlert($el) { function removeAlert(el) {
$el.removeClass("is-visible"); if (!el) {
window.setTimeout(function () { return;
$el.remove(); }
el.classList.remove("is-visible");
win.setTimeout(function () {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
}, 180); }, 180);
} }
$.fn.orderProAlert = function (options) { function createElement(tag, className) {
const settings = $.extend({}, DEFAULTS, options); var el = win.document.createElement(tag);
if (className) {
el.className = className;
}
return el;
}
return this.each(function () { function getGlobalHost() {
const $host = $(this); var id = "jq-alert-host";
const $alert = $("<div>", { var existing = win.document.getElementById(id);
class: settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible", if (existing) {
role: "alert", return existing;
}
var host = createElement("div", "jq-alert-host");
host.id = id;
win.document.body.appendChild(host);
return host;
}
function renderAlert(host, options) {
var settings = Object.assign({}, DEFAULTS, options || {});
var alertEl = createElement(
"div",
settings.classPrefix + " " + settings.classPrefix + "--" + settings.type + " is-visible"
);
alertEl.setAttribute("role", "alert");
var content = createElement("div", settings.classPrefix + "__content");
content.textContent = String(settings.message || "");
alertEl.appendChild(content);
if (settings.dismissible) {
var close = createElement("button", settings.classPrefix + "__close");
close.type = "button";
close.setAttribute("aria-label", "Close alert");
close.textContent = "x";
close.addEventListener("click", function () {
removeAlert(alertEl);
}); });
alertEl.appendChild(close);
}
const $content = $("<div>", { host.appendChild(alertEl);
class: settings.classPrefix + "__content",
text: String(settings.message || ""), if (settings.timeout > 0) {
win.setTimeout(function () {
removeAlert(alertEl);
}, settings.timeout);
}
return alertEl;
}
function show(options) {
return renderAlert(getGlobalHost(), options);
}
function closeConfirm(backdrop, resolve, value) {
if (!backdrop || backdrop.getAttribute("data-closed") === "1") {
return;
}
backdrop.setAttribute("data-closed", "1");
backdrop.classList.remove("is-visible");
win.setTimeout(function () {
if (backdrop.parentNode) {
backdrop.parentNode.removeChild(backdrop);
}
resolve(value);
}, 180);
}
function confirm(options) {
var settings = Object.assign(
{
title: "Potwierdzenie",
message: "",
confirmLabel: "Potwierdz",
cancelLabel: "Anuluj",
danger: false,
},
options || {}
);
return new Promise(function (resolve) {
var backdrop = createElement("div", "jq-alert-modal-backdrop is-visible");
var modal = createElement("div", "jq-alert-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
var titleId = "jq-alert-modal-title-" + Date.now();
modal.setAttribute("aria-labelledby", titleId);
var header = createElement("div", "jq-alert-modal__header");
var title = createElement("h3");
title.id = titleId;
title.textContent = String(settings.title || "");
header.appendChild(title);
var body = createElement("div", "jq-alert-modal__body");
body.textContent = String(settings.message || "");
var footer = createElement("div", "jq-alert-modal__footer");
var cancelButton = createElement("button", "btn btn--secondary");
cancelButton.type = "button";
cancelButton.textContent = String(settings.cancelLabel || "Anuluj");
var confirmButton = createElement(
"button",
settings.danger ? "btn btn--danger" : "btn btn--primary"
);
confirmButton.type = "button";
confirmButton.textContent = String(settings.confirmLabel || "Potwierdz");
footer.appendChild(cancelButton);
footer.appendChild(confirmButton);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
backdrop.appendChild(modal);
win.document.body.appendChild(backdrop);
var onKeyDown = function (event) {
if (event.key === "Escape") {
onCancel();
}
};
var cleanup = function () {
win.document.removeEventListener("keydown", onKeyDown);
};
var onConfirm = function () {
cleanup();
if (typeof settings.onConfirm === "function") {
settings.onConfirm();
}
closeConfirm(backdrop, resolve, true);
};
var onCancel = function () {
cleanup();
if (typeof settings.onCancel === "function") {
settings.onCancel();
}
closeConfirm(backdrop, resolve, false);
};
confirmButton.addEventListener("click", onConfirm);
cancelButton.addEventListener("click", onCancel);
backdrop.addEventListener("click", function (event) {
if (event.target === backdrop) {
onCancel();
}
}); });
win.document.addEventListener("keydown", onKeyDown);
$alert.append($content); confirmButton.focus();
if (settings.dismissible) {
const $close = $("<button>", {
class: settings.classPrefix + "__close",
type: "button",
"aria-label": "Close alert",
text: "x",
});
$close.on("click", function () {
removeAlert($alert);
});
$alert.append($close);
}
$host.append($alert);
if (settings.timeout > 0) {
window.setTimeout(function () {
removeAlert($alert);
}, settings.timeout);
}
}); });
}
win.OrderProAlerts = {
show: show,
confirm: confirm,
}; };
if ($ && $.fn) {
$.fn.orderProAlert = function (options) {
var settings = $.extend({}, DEFAULTS, options);
return this.each(function () {
renderAlert(this, settings);
});
};
}
return win.OrderProAlerts;
}); });

View File

@@ -1,3 +1,11 @@
.jq-alert-host {
position: fixed;
top: 16px;
right: 16px;
z-index: 300;
width: min(420px, calc(100vw - 32px));
}
.jq-alert { .jq-alert {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -56,3 +64,52 @@
color: inherit; color: inherit;
background: transparent; background: transparent;
} }
.jq-alert-modal-backdrop {
position: fixed;
inset: 0;
z-index: 310;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.5);
opacity: 0;
transition: opacity 0.18s ease;
}
.jq-alert-modal-backdrop.is-visible {
opacity: 1;
}
.jq-alert-modal {
width: min(520px, 100%);
border-radius: 10px;
border: 1px solid #dbe3ee;
background: #ffffff;
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.3);
}
.jq-alert-modal__header {
padding: 14px 16px;
border-bottom: 1px solid #e2e8f0;
}
.jq-alert-modal__header h3 {
margin: 0;
color: #2d3748;
font-size: 18px;
}
.jq-alert-modal__body {
padding: 14px 16px;
color: #4e5e6a;
line-height: 1.45;
}
.jq-alert-modal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 0 16px 14px;
}

View File

@@ -98,9 +98,10 @@ a {
} }
.container { .container {
max-width: 1120px; max-width: none;
margin: 24px auto; width: calc(100% - 28px);
padding: 0 18px 24px; margin: 18px 14px;
padding: 0 6px 24px;
} }
.card { .card {
@@ -209,6 +210,521 @@ a {
overflow: auto; overflow: auto;
} }
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.filters-actions {
display: flex;
align-items: end;
gap: 8px;
}
.product-form .form-control {
width: 100%;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.field-inline {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 200;
}
.modal-backdrop[hidden] {
display: none;
}
.modal {
width: min(560px, 100%);
background: #fff;
border-radius: 10px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.35);
overflow: hidden;
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 16px 18px;
border-bottom: 1px solid var(--c-border);
}
.modal__header h3 {
margin: 0;
font-size: 18px;
color: var(--c-text-strong);
}
.modal__body {
padding: 16px 18px 18px;
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #fed7d7;
background: #fff5f5;
color: #9b2c2c;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.status-pill.is-active {
border-color: #b7ebcf;
background: #f0fff6;
color: #0f6b39;
}
.table-list {
display: grid;
gap: 14px;
}
.table-list__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.table-list__left {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.table-list-header-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.js-filter-toggle-btn.is-active {
border-color: #cbd5e0;
background: #edf2ff;
color: var(--c-primary-dark);
}
.table-filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 11px;
font-weight: 700;
color: #fff;
background: var(--c-primary);
border-radius: 999px;
}
.table-filters-wrapper {
display: none;
}
.table-filters-wrapper.is-open {
display: block;
}
.table-list-filters {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.table-col-toggle-wrapper {
position: relative;
}
.table-col-toggle-dropdown {
display: none;
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 30;
width: 260px;
max-height: 360px;
overflow: auto;
border: 1px solid var(--c-border);
border-radius: 10px;
background: #fff;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.12);
}
.table-col-toggle-dropdown.is-open {
display: block;
}
.table-col-toggle-header {
padding: 10px 12px;
border-bottom: 1px solid var(--c-border);
font-size: 12px;
font-weight: 700;
color: var(--c-muted);
}
.table-col-toggle-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
font-size: 13px;
color: var(--c-text-strong);
}
.table-col-toggle-item:hover {
background: #f8fafc;
}
.table-col-toggle-footer {
border-top: 1px solid var(--c-border);
padding: 8px 12px;
}
.table-col-hidden {
display: none;
}
.table-col-switch {
position: relative;
display: inline-block;
width: 34px;
min-width: 34px;
height: 18px;
}
.table-col-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.table-col-switch-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #cbd5e1;
border-radius: 999px;
transition: background-color 0.2s ease;
}
.table-col-switch-slider::before {
content: "";
position: absolute;
height: 14px;
width: 14px;
left: 2px;
bottom: 2px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s ease;
}
.table-col-switch input:checked + .table-col-switch-slider {
background: #16a34a;
}
.table-col-switch input:checked + .table-col-switch-slider::before {
transform: translateX(16px);
}
.table-sort-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--c-text-strong);
text-decoration: none;
}
.table-sort-link:hover {
color: var(--c-primary-dark);
}
.table-sort-icon.is-muted {
color: #a0aec0;
}
.table-list__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.table-list-per-page-form {
display: inline-flex;
align-items: center;
gap: 8px;
}
.table-list-per-page-form .form-control {
min-width: 90px;
}
.table-inline-action {
display: inline-block;
margin-right: 6px;
}
.product-name-cell {
display: inline-flex;
align-items: center;
gap: 10px;
}
.product-name-thumb {
width: 60px;
height: 60px;
border-radius: 6px;
object-fit: cover;
border: 1px solid var(--c-border);
background: #f8fafc;
}
.product-name-thumb--empty {
display: inline-block;
width: 60px;
height: 60px;
border-radius: 6px;
border: 1px dashed #cbd5e0;
background: #f8fafc;
}
.product-name-thumb-btn {
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.product-name-thumb-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
border-radius: 8px;
}
.modal--image-preview {
width: min(760px, 100%);
}
.product-image-preview__img {
display: block;
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
background: #f8fafc;
}
.product-images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.product-image-card {
border: 1px solid #dfe3ea;
border-radius: 10px;
padding: 10px;
background: #fff;
}
.product-image-card__thumb-wrap {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #f2f5f8;
}
.product-image-card__thumb {
width: 100%;
height: 160px;
object-fit: cover;
display: block;
}
.product-image-card__thumb.is-empty {
height: 160px;
display: grid;
place-items: center;
color: #6b7785;
font-size: 12px;
}
.product-image-card__badge {
display: none;
position: absolute;
top: 8px;
left: 8px;
background: #1f7a43;
color: #fff;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
}
.product-image-card.is-main .product-image-card__badge {
display: inline-block;
}
.product-image-card__meta {
margin-top: 8px;
font-size: 11px;
line-height: 1.25;
color: #5f6b79;
overflow-wrap: anywhere;
}
.product-image-card__actions {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.product-image-card__actions .btn {
min-height: 34px;
font-size: 12px;
line-height: 1.2;
padding: 6px 10px;
}
.product-links-search-form {
display: grid;
gap: 12px;
grid-template-columns: minmax(220px, 320px) minmax(220px, 1fr) auto;
align-items: end;
}
.product-links-head {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.product-tabs-nav {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.product-links-inline-form {
display: grid;
gap: 8px;
grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) auto;
align-items: center;
}
.product-links-actions-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.product-links-actions-row .product-links-relink-form {
flex: 1 1 auto;
}
.product-links-unlink-form {
margin: 0;
flex: 0 0 auto;
}
.product-link-events-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
}
.product-link-events-list li {
display: grid;
gap: 2px;
}
.product-link-events-type {
font-weight: 600;
color: var(--c-text-strong);
}
.product-link-events-date {
color: var(--c-muted);
font-size: 12px;
}
.product-show-images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.product-show-image-card {
border: 1px solid var(--c-border);
border-radius: 10px;
background: #fff;
padding: 10px;
}
.product-show-image {
width: 100%;
max-height: 260px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #d9e0ea;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -241,14 +757,47 @@ a {
.container { .container {
margin-top: 16px; margin-top: 16px;
padding: 0 14px 18px; width: calc(100% - 16px);
margin-left: 8px;
margin-right: 8px;
padding: 0 4px 18px;
} }
.settings-grid { .settings-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.page-head {
flex-direction: column;
align-items: flex-start;
}
.filters-grid,
.form-grid,
.table-list-filters,
.product-links-search-form,
.product-links-inline-form {
grid-template-columns: 1fr;
}
.filters-actions {
align-items: center;
}
.table-list__header,
.table-list__footer {
align-items: flex-start;
}
.product-links-head {
grid-template-columns: 1fr;
}
.card { .card {
padding: 18px; padding: 18px;
} }
.modal--image-preview {
width: min(92vw, 100%);
}
} }

View File

@@ -47,6 +47,17 @@
background: #f8fafc; background: #f8fafc;
} }
.btn--danger {
color: #ffffff;
border-color: #b91c1c;
background: #dc2626;
}
.btn--danger:hover {
border-color: #991b1b;
background: #b91c1c;
}
.btn--block { .btn--block {
width: 100%; width: 100%;
} }

View File

@@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
$config = is_array($tableList ?? null) ? $tableList : [];
$basePath = (string) ($config['base_path'] ?? '');
$query = is_array($config['query'] ?? null) ? $config['query'] : [];
$filters = is_array($config['filters'] ?? null) ? $config['filters'] : [];
$columns = is_array($config['columns'] ?? null) ? $config['columns'] : [];
$rows = is_array($config['rows'] ?? null) ? $config['rows'] : [];
$pagination = is_array($config['pagination'] ?? null) ? $config['pagination'] : [];
$perPageOptions = is_array($config['per_page_options'] ?? null) ? $config['per_page_options'] : [20, 50, 100];
$createUrl = (string) ($config['create_url'] ?? '');
$createLabel = (string) ($config['create_label'] ?? '');
$headerActions = is_array($config['header_actions'] ?? null) ? $config['header_actions'] : [];
$emptyMessage = (string) ($config['empty_message'] ?? 'Brak rekordow.');
$showActions = (bool) ($config['show_actions'] ?? true);
$actionsLabel = (string) ($config['actions_label'] ?? 'Akcje');
$listKey = (string) ($config['list_key'] ?? md5($basePath !== '' ? $basePath : 'table-list'));
$currentSort = (string) ($query['sort'] ?? 'id');
$currentDir = strtoupper((string) ($query['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($pagination['page'] ?? 1));
$totalPages = max(1, (int) ($pagination['total_pages'] ?? 1));
$total = max(0, (int) ($pagination['total'] ?? 0));
$perPage = max(1, (int) ($pagination['per_page'] ?? 20));
$activeFiltersCount = 0;
foreach ($filters as $filter) {
if ((string) ($filter['value'] ?? '') !== '') {
$activeFiltersCount++;
}
}
$hasActiveFilters = $activeFiltersCount > 0;
$buildUrl = static function (array $params = []) use ($basePath, $query): string {
$merged = array_merge($query, $params);
foreach ($merged as $key => $value) {
if ($value === '' || $value === null) {
unset($merged[$key]);
}
}
$qs = http_build_query($merged);
if ($qs === '') {
return $basePath;
}
return $basePath . '?' . $qs;
};
?>
<section class="card table-list" data-table-list-id="<?= $e($listKey) ?>" data-table-list-base="<?= $e($basePath) ?>">
<div class="table-list__header">
<div class="table-list__left">
<?php if ($createUrl !== '' && $createLabel !== ''): ?>
<a href="<?= $e($createUrl) ?>" class="btn btn--primary">
<?= $e($createLabel) ?>
</a>
<?php endif; ?>
<?php foreach ($headerActions as $action): ?>
<?php
$actionType = (string) ($action['type'] ?? 'link');
$actionLabel = (string) ($action['label'] ?? '');
$actionClass = (string) ($action['class'] ?? 'btn btn--secondary');
$actionAttrs = is_array($action['attrs'] ?? null) ? $action['attrs'] : [];
if ($actionLabel === '') {
continue;
}
?>
<?php if ($actionType === 'button'): ?>
<button type="button" class="<?= $e($actionClass) ?>"
<?php foreach ($actionAttrs as $attrKey => $attrValue): ?>
<?= $e((string) $attrKey) ?>="<?= $e((string) $attrValue) ?>"
<?php endforeach; ?>
><?= $e($actionLabel) ?></button>
<?php else: ?>
<a href="<?= $e((string) ($action['url'] ?? '#')) ?>" class="<?= $e($actionClass) ?>"
<?php foreach ($actionAttrs as $attrKey => $attrValue): ?>
<?= $e((string) $attrKey) ?>="<?= $e((string) $attrValue) ?>"
<?php endforeach; ?>
><?= $e($actionLabel) ?></a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<div class="table-list-header-actions">
<span class="muted">Wynikow: <?= $e((string) $total) ?></span>
<?php if (!empty($filters)): ?>
<button type="button" class="btn btn--secondary js-filter-toggle-btn<?= $hasActiveFilters ? ' is-active' : '' ?>" title="Filtry">
Filtry
<?php if ($hasActiveFilters): ?>
<span class="table-filter-badge"><?= $e((string) $activeFiltersCount) ?></span>
<?php endif; ?>
</button>
<?php endif; ?>
<div class="table-col-toggle-wrapper">
<button type="button" class="btn btn--secondary js-col-toggle-btn" title="Widocznosc kolumn">Kolumny</button>
<div class="table-col-toggle-dropdown js-col-toggle-dropdown">
<div class="table-col-toggle-header">Widocznosc kolumn</div>
<?php foreach ($columns as $index => $column): ?>
<?php $colKey = (string) ($column['key'] ?? ('col_' . $index)); ?>
<label class="table-col-toggle-item">
<span class="table-col-switch">
<input type="checkbox" class="js-col-toggle-checkbox" data-col-key="<?= $e($colKey) ?>" checked>
<span class="table-col-switch-slider"></span>
</span>
<?= $e((string) ($column['label'] ?? $colKey)) ?>
</label>
<?php endforeach; ?>
<div class="table-col-toggle-footer">
<button type="button" class="btn btn--secondary js-col-toggle-reset">Pokaz wszystkie</button>
</div>
</div>
</div>
</div>
</div>
<?php if (!empty($filters)): ?>
<div class="table-filters-wrapper js-table-filters-wrapper<?= $hasActiveFilters ? ' is-open' : '' ?>">
<form method="get" action="<?= $e($basePath) ?>" class="table-list-filters js-table-filters-form">
<?php foreach ($filters as $filter): ?>
<?php
$filterKey = (string) ($filter['key'] ?? '');
$filterType = (string) ($filter['type'] ?? 'text');
$filterLabel = (string) ($filter['label'] ?? $filterKey);
$filterValue = (string) ($filter['value'] ?? '');
$inputId = 'filter_' . preg_replace('/[^a-zA-Z0-9_]+/', '_', $filterKey) . '_' . $listKey;
?>
<label class="form-field">
<span class="field-label"><?= $e($filterLabel) ?></span>
<?php if ($filterType === 'select'): ?>
<select class="form-control" id="<?= $e($inputId) ?>" name="<?= $e($filterKey) ?>">
<?php foreach ((array) ($filter['options'] ?? []) as $value => $label): ?>
<option value="<?= $e((string) $value) ?>"<?= (string) $value === $filterValue ? ' selected' : '' ?>>
<?= $e((string) $label) ?>
</option>
<?php endforeach; ?>
</select>
<?php elseif ($filterType === 'date'): ?>
<input class="form-control" id="<?= $e($inputId) ?>" type="date" name="<?= $e($filterKey) ?>" value="<?= $e($filterValue) ?>">
<?php else: ?>
<input class="form-control" id="<?= $e($inputId) ?>" type="text" name="<?= $e($filterKey) ?>" value="<?= $e($filterValue) ?>">
<?php endif; ?>
</label>
<?php endforeach; ?>
<?php foreach ($query as $key => $value): ?>
<?php if (!in_array((string) $key, array_map(static fn (array $filter): string => (string) ($filter['key'] ?? ''), $filters), true) && (string) $key !== 'page'): ?>
<input type="hidden" name="<?= $e((string) $key) ?>" value="<?= $e((string) $value) ?>">
<?php endif; ?>
<?php endforeach; ?>
<div class="filters-actions">
<button type="submit" class="btn btn--primary">Szukaj</button>
<a href="<?= $e($basePath) ?>" class="btn btn--secondary js-table-filters-clear">Wyczysc</a>
</div>
</form>
</div>
<?php endif; ?>
<div class="table-wrap">
<table class="table table-list-table">
<thead>
<tr>
<?php foreach ($columns as $index => $column): ?>
<?php
$colKey = (string) ($column['key'] ?? ('col_' . $index));
$label = (string) ($column['label'] ?? $colKey);
$sortable = (bool) ($column['sortable'] ?? false);
$sortKey = (string) ($column['sort_key'] ?? $colKey);
$isCurrent = $sortable && $currentSort === $sortKey;
$nextDir = $isCurrent && $currentDir === 'ASC' ? 'DESC' : 'ASC';
$headerClass = trim((string) ($column['class'] ?? ''));
?>
<th class="<?= $e($headerClass) ?>" data-col-key="<?= $e($colKey) ?>">
<?php if ($sortable): ?>
<a href="<?= $e($buildUrl(['sort' => $sortKey, 'sort_dir' => $nextDir, 'page' => 1])) ?>" class="table-sort-link">
<?= $e($label) ?>
<?php if ($isCurrent): ?>
<span class="table-sort-icon"><?= $e($currentDir === 'ASC' ? '↑' : '↓') ?></span>
<?php else: ?>
<span class="table-sort-icon is-muted">↕</span>
<?php endif; ?>
</a>
<?php else: ?>
<?= $e($label) ?>
<?php endif; ?>
</th>
<?php endforeach; ?>
<?php if ($showActions): ?>
<th><?= $e($actionsLabel) ?></th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr>
<td colspan="<?= $e((string) (count($columns) + ($showActions ? 1 : 0))) ?>" class="muted">
<?= $e($emptyMessage) ?>
</td>
</tr>
<?php else: ?>
<?php foreach ($rows as $row): ?>
<tr>
<?php foreach ($columns as $index => $column): ?>
<?php
$colKey = (string) ($column['key'] ?? ('col_' . $index));
$raw = (bool) ($column['raw'] ?? false);
$cellClass = trim((string) ($column['class'] ?? ''));
$value = $row[$colKey] ?? '';
?>
<td class="<?= $e($cellClass) ?>" data-col-key="<?= $e($colKey) ?>">
<?php if ($raw): ?>
<?= (string) $value ?>
<?php else: ?>
<?= $e((string) $value) ?>
<?php endif; ?>
</td>
<?php endforeach; ?>
<?php if ($showActions): ?>
<td>
<?php foreach ((array) ($row['_actions'] ?? []) as $action): ?>
<?php
$actionMethod = strtolower((string) ($action['method'] ?? 'get'));
$actionUrl = (string) ($action['url'] ?? '#');
$actionClass = (string) ($action['class'] ?? 'btn btn--secondary');
$actionLabel = (string) ($action['label'] ?? 'Akcja');
$actionConfirm = trim((string) ($action['confirm'] ?? ''));
$actionConfirmTitle = trim((string) ($action['confirm_title'] ?? 'Potwierdzenie'));
$actionConfirmYes = trim((string) ($action['confirm_yes'] ?? 'Potwierdz'));
$actionConfirmNo = trim((string) ($action['confirm_no'] ?? 'Anuluj'));
$actionParams = is_array($action['params'] ?? null) ? $action['params'] : [];
?>
<?php if ($actionMethod === 'post'): ?>
<form
action="<?= $e($actionUrl) ?>"
method="post"
class="table-inline-action"
<?php if ($actionConfirm !== ''): ?>
data-alert-confirm="<?= $e($actionConfirm) ?>"
data-alert-confirm-title="<?= $e($actionConfirmTitle) ?>"
data-alert-confirm-yes="<?= $e($actionConfirmYes) ?>"
data-alert-confirm-no="<?= $e($actionConfirmNo) ?>"
<?php endif; ?>
>
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
<?php foreach ($actionParams as $paramKey => $paramValue): ?>
<input type="hidden" name="<?= $e((string) $paramKey) ?>" value="<?= $e((string) $paramValue) ?>">
<?php endforeach; ?>
<button type="submit" class="<?= $e($actionClass) ?>"><?= $e($actionLabel) ?></button>
</form>
<?php else: ?>
<a href="<?= $e($actionUrl) ?>" class="<?= $e($actionClass) ?>">
<?= $e($actionLabel) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="table-list__footer">
<div class="pagination">
<?php $startPage = max(1, $page - 2); ?>
<?php $endPage = min($totalPages, $page + 2); ?>
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => 1])) ?>">«</a>
<a class="pagination__item<?= $page <= 1 ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => max(1, $page - 1)])) ?>"></a>
<?php for ($i = $startPage; $i <= $endPage; $i++): ?>
<a class="pagination__item<?= $i === $page ? ' is-active' : '' ?>" href="<?= $e($buildUrl(['page' => $i])) ?>">
<?= $e((string) $i) ?>
</a>
<?php endfor; ?>
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => min($totalPages, $page + 1)])) ?>"></a>
<a class="pagination__item<?= $page >= $totalPages ? ' is-disabled' : '' ?>" href="<?= $e($buildUrl(['page' => $totalPages])) ?>">»</a>
</div>
<form method="get" action="<?= $e($basePath) ?>" class="table-list-per-page-form js-per-page-form">
<?php foreach ($query as $key => $value): ?>
<?php if ((string) $key !== 'per_page' && (string) $key !== 'page'): ?>
<input type="hidden" name="<?= $e((string) $key) ?>" value="<?= $e((string) $value) ?>">
<?php endif; ?>
<?php endforeach; ?>
<input type="hidden" name="page" value="1">
<span>Wyswietlaj</span>
<select class="form-control js-per-page-select" name="per_page">
<?php foreach ($perPageOptions as $opt): ?>
<option value="<?= $e((string) $opt) ?>"<?= (int) $opt === $perPage ? ' selected' : '' ?>><?= $e((string) $opt) ?></option>
<?php endforeach; ?>
</select>
<span>rekordow</span>
</form>
</div>
</section>
<script>
(function() {
var root = document.querySelector('[data-table-list-id="<?= $e($listKey) ?>"]');
if (!root) return;
var basePath = root.getAttribute('data-table-list-base') || '';
var storagePrefix = 'tableList_' + (basePath || 'default') + '_<?= $e($listKey) ?>';
var filterKey = storagePrefix + '_filters_open';
var colsKey = storagePrefix + '_hidden_cols';
var queryKey = storagePrefix + '_query';
var clearKey = storagePrefix + '_cleared';
function readJson(key, fallback) {
try {
var val = localStorage.getItem(key);
if (!val) return fallback;
return JSON.parse(val);
} catch (e) {
return fallback;
}
}
function writeJson(key, val) {
try {
localStorage.setItem(key, JSON.stringify(val));
} catch (e) {}
}
var filterWrapper = root.querySelector('.js-table-filters-wrapper');
var filterBtn = root.querySelector('.js-filter-toggle-btn');
if (filterWrapper && filterBtn) {
var savedOpen = localStorage.getItem(filterKey) === '1';
if (savedOpen && !filterWrapper.classList.contains('is-open')) {
filterWrapper.classList.add('is-open');
filterBtn.classList.add('is-active');
}
filterBtn.addEventListener('click', function() {
var isOpen = filterWrapper.classList.toggle('is-open');
filterBtn.classList.toggle('is-active', isOpen);
try { localStorage.setItem(filterKey, isOpen ? '1' : '0'); } catch (e) {}
});
}
var dropdownBtn = root.querySelector('.js-col-toggle-btn');
var dropdown = root.querySelector('.js-col-toggle-dropdown');
if (dropdownBtn && dropdown) {
dropdownBtn.addEventListener('click', function(ev) {
ev.stopPropagation();
dropdown.classList.toggle('is-open');
});
dropdown.addEventListener('click', function(ev) { ev.stopPropagation(); });
document.addEventListener('click', function() { dropdown.classList.remove('is-open'); });
}
function applyHiddenCols(hiddenCols) {
var allCells = root.querySelectorAll('th[data-col-key], td[data-col-key]');
allCells.forEach(function(cell) {
var key = cell.getAttribute('data-col-key');
cell.classList.toggle('table-col-hidden', hiddenCols.indexOf(key) !== -1);
});
var checkboxes = root.querySelectorAll('.js-col-toggle-checkbox');
checkboxes.forEach(function(cb) {
var key = cb.getAttribute('data-col-key');
cb.checked = hiddenCols.indexOf(key) === -1;
});
}
var hiddenCols = readJson(colsKey, []);
if (!Array.isArray(hiddenCols)) hiddenCols = [];
applyHiddenCols(hiddenCols);
root.querySelectorAll('.js-col-toggle-checkbox').forEach(function(cb) {
cb.addEventListener('change', function() {
var key = cb.getAttribute('data-col-key');
hiddenCols = readJson(colsKey, []);
if (!Array.isArray(hiddenCols)) hiddenCols = [];
if (cb.checked) {
hiddenCols = hiddenCols.filter(function(v) { return v !== key; });
} else if (hiddenCols.indexOf(key) === -1) {
hiddenCols.push(key);
}
writeJson(colsKey, hiddenCols);
applyHiddenCols(hiddenCols);
});
});
var resetBtn = root.querySelector('.js-col-toggle-reset');
if (resetBtn) {
resetBtn.addEventListener('click', function() {
hiddenCols = [];
writeJson(colsKey, hiddenCols);
applyHiddenCols(hiddenCols);
});
}
root.querySelectorAll('.js-per-page-select').forEach(function(select) {
select.addEventListener('change', function() {
var form = select.closest('form');
if (form) form.submit();
});
});
var clearBtn = root.querySelector('.js-table-filters-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function() {
try {
localStorage.removeItem(queryKey);
sessionStorage.setItem(clearKey, '1');
} catch (e) {}
});
}
root.addEventListener('submit', function(event) {
var form = event.target;
if (!form || !form.matches || !form.matches('form[data-alert-confirm]')) return;
if (form.getAttribute('data-confirmed') === '1') {
form.removeAttribute('data-confirmed');
return;
}
var message = form.getAttribute('data-alert-confirm') || '';
if (message === '') return;
event.preventDefault();
var title = form.getAttribute('data-alert-confirm-title') || 'Potwierdzenie';
var confirmYes = form.getAttribute('data-alert-confirm-yes') || 'Potwierdz';
var confirmNo = form.getAttribute('data-alert-confirm-no') || 'Anuluj';
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
window.OrderProAlerts.confirm({
title: title,
message: message,
confirmLabel: confirmYes,
cancelLabel: confirmNo,
danger: true
}).then(function(accepted) {
if (!accepted) return;
form.setAttribute('data-confirmed', '1');
form.submit();
});
return;
}
if (window.confirm(message)) {
form.setAttribute('data-confirmed', '1');
form.submit();
}
});
try {
var query = window.location.search ? window.location.search.substring(1) : '';
var justCleared = sessionStorage.getItem(clearKey) === '1';
sessionStorage.removeItem(clearKey);
if (query) {
localStorage.setItem(queryKey, query);
} else if (!justCleared) {
var saved = localStorage.getItem(queryKey);
if (saved) window.location.replace(basePath + '?' + saved);
}
} catch (e) {}
})();
</script>

View File

@@ -8,6 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/app.css"> <link rel="stylesheet" href="/assets/css/app.css">
<link rel="stylesheet" href="/assets/css/modules/jquery-alerts.css">
</head> </head>
<body> <body>
<?php $currentMenu = (string) ($activeMenu ?? ''); ?> <?php $currentMenu = (string) ($activeMenu ?? ''); ?>
@@ -22,7 +23,10 @@
<a class="sidebar__link<?= $currentMenu === 'users' ? ' is-active' : '' ?>" href="/users"> <a class="sidebar__link<?= $currentMenu === 'users' ? ' is-active' : '' ?>" href="/users">
<?= $e($t('navigation.users')) ?> <?= $e($t('navigation.users')) ?>
</a> </a>
<a class="sidebar__link<?= $currentMenu === 'settings' ? ' is-active' : '' ?>" href="/settings/database"> <a class="sidebar__link<?= $currentMenu === 'products' ? ' is-active' : '' ?>" href="/products">
<?= $e($t('navigation.products')) ?>
</a>
<a class="sidebar__link<?= $currentMenu === 'settings' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro">
<?= $e($t('navigation.settings')) ?> <?= $e($t('navigation.settings')) ?>
</a> </a>
</nav> </nav>
@@ -44,5 +48,6 @@
</main> </main>
</div> </div>
</div> </div>
<script src="/assets/js/modules/jquery-alerts.js"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,139 @@
<section class="card">
<h1><?= $e($t('products.create.title')) ?></h1>
<p class="muted"><?= $e($t('products.create.description')) ?></p>
</section>
<section class="card mt-16">
<?php if (!empty($errors)): ?>
<div class="alert alert--danger" role="alert">
<?php foreach ((array) $errors as $error): ?>
<div><?= $e((string) $error) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form class="product-form mt-16" method="post" action="/products">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<div class="form-grid">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">SKU</span>
<input class="form-control" type="text" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">EAN</span>
<input class="form-control" type="text" name="ean" value="<?= $e((string) ($form['ean'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.type')) ?></span>
<select class="form-control" name="type">
<option value="simple"<?= (string) ($form['type'] ?? '') === 'simple' ? ' selected' : '' ?>><?= $e($t('products.type.simple')) ?></option>
<option value="variant_parent"<?= (string) ($form['type'] ?? '') === 'variant_parent' ? ' selected' : '' ?>><?= $e($t('products.type.variant_parent')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.status')) ?></span>
<select class="form-control" name="status">
<option value="1"<?= (string) ($form['status'] ?? '1') === '1' ? ' selected' : '' ?>><?= $e($t('products.status.active')) ?></option>
<option value="0"<?= (string) ($form['status'] ?? '1') === '0' ? ' selected' : '' ?>><?= $e($t('products.status.inactive')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.promoted')) ?></span>
<select class="form-control" name="promoted">
<option value="0"<?= (string) ($form['promoted'] ?? '0') === '0' ? ' selected' : '' ?>><?= $e($t('products.promoted.no')) ?></option>
<option value="1"<?= (string) ($form['promoted'] ?? '0') === '1' ? ' selected' : '' ?>><?= $e($t('products.promoted.yes')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.vat')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" max="100" name="vat" value="<?= $e((string) ($form['vat'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.quantity')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="quantity" value="<?= $e((string) ($form['quantity'] ?? '0')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.weight')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="weight" value="<?= $e((string) ($form['weight'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_input_mode')) ?></span>
<select class="form-control" name="price_input_mode">
<option value="brutto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'brutto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.brutto')) ?></option>
<option value="netto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'netto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.netto')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto" value="<?= $e((string) ($form['price_brutto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto" value="<?= $e((string) ($form['price_netto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto_promo" value="<?= $e((string) ($form['price_brutto_promo'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto_promo" value="<?= $e((string) ($form['price_netto_promo'] ?? '')) ?>">
</label>
</div>
<label class="form-field mt-16">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<textarea class="form-control" name="short_description" rows="3"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<textarea class="form-control" name="description" rows="6"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
</label>
<div class="form-grid mt-16">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_title')) ?></span>
<input class="form-control" type="text" name="meta_title" value="<?= $e((string) ($form['meta_title'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_description')) ?></span>
<input class="form-control" type="text" name="meta_description" value="<?= $e((string) ($form['meta_description'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_keywords')) ?></span>
<input class="form-control" type="text" name="meta_keywords" value="<?= $e((string) ($form['meta_keywords'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.seo_link')) ?></span>
<input class="form-control" type="text" name="seo_link" value="<?= $e((string) ($form['seo_link'] ?? '')) ?>">
</label>
</div>
<div class="form-actions mt-16">
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
</div>
</form>
</section>

View File

@@ -0,0 +1,383 @@
<section class="card">
<h1><?= $e($t('products.edit.title', ['id' => (string) ($productId ?? 0)])) ?></h1>
<p class="muted"><?= $e($t('products.edit.description')) ?></p>
</section>
<section class="card mt-16">
<?php if (!empty($errors)): ?>
<div class="alert alert--danger" role="alert">
<?php foreach ((array) $errors as $error): ?>
<div><?= $e((string) $error) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
<form class="product-form mt-16" method="post" action="/products/update" enctype="multipart/form-data">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
<div class="form-grid">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.name')) ?></span>
<input class="form-control" type="text" name="name" required value="<?= $e((string) ($form['name'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">SKU</span>
<input class="form-control" type="text" name="sku" value="<?= $e((string) ($form['sku'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label">EAN</span>
<input class="form-control" type="text" name="ean" value="<?= $e((string) ($form['ean'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.type')) ?></span>
<select class="form-control" name="type">
<option value="simple"<?= (string) ($form['type'] ?? '') === 'simple' ? ' selected' : '' ?>><?= $e($t('products.type.simple')) ?></option>
<option value="variant_parent"<?= (string) ($form['type'] ?? '') === 'variant_parent' ? ' selected' : '' ?>><?= $e($t('products.type.variant_parent')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.status')) ?></span>
<select class="form-control" name="status">
<option value="1"<?= (string) ($form['status'] ?? '1') === '1' ? ' selected' : '' ?>><?= $e($t('products.status.active')) ?></option>
<option value="0"<?= (string) ($form['status'] ?? '1') === '0' ? ' selected' : '' ?>><?= $e($t('products.status.inactive')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.promoted')) ?></span>
<select class="form-control" name="promoted">
<option value="0"<?= (string) ($form['promoted'] ?? '0') === '0' ? ' selected' : '' ?>><?= $e($t('products.promoted.no')) ?></option>
<option value="1"<?= (string) ($form['promoted'] ?? '0') === '1' ? ' selected' : '' ?>><?= $e($t('products.promoted.yes')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.vat')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" max="100" name="vat" value="<?= $e((string) ($form['vat'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.quantity')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="quantity" value="<?= $e((string) ($form['quantity'] ?? '0')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.weight')) ?></span>
<input class="form-control" type="number" step="0.001" min="0" name="weight" value="<?= $e((string) ($form['weight'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_input_mode')) ?></span>
<select class="form-control" name="price_input_mode">
<option value="brutto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'brutto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.brutto')) ?></option>
<option value="netto"<?= (string) ($form['price_input_mode'] ?? 'brutto') === 'netto' ? ' selected' : '' ?>><?= $e($t('products.price_mode.netto')) ?></option>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto" value="<?= $e((string) ($form['price_brutto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto" value="<?= $e((string) ($form['price_netto'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_brutto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_brutto_promo" value="<?= $e((string) ($form['price_brutto_promo'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.price_netto_promo')) ?></span>
<input class="form-control" type="number" step="0.01" min="0" name="price_netto_promo" value="<?= $e((string) ($form['price_netto_promo'] ?? '')) ?>">
</label>
</div>
<label class="form-field mt-16">
<span class="field-label"><?= $e($t('products.fields.short_description')) ?></span>
<textarea class="form-control" name="short_description" rows="3"><?= $e((string) ($form['short_description'] ?? '')) ?></textarea>
</label>
<label class="form-field mt-12">
<span class="field-label"><?= $e($t('products.fields.description')) ?></span>
<textarea class="form-control" name="description" rows="6"><?= $e((string) ($form['description'] ?? '')) ?></textarea>
</label>
<div class="form-grid mt-16">
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_title')) ?></span>
<input class="form-control" type="text" name="meta_title" value="<?= $e((string) ($form['meta_title'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_description')) ?></span>
<input class="form-control" type="text" name="meta_description" value="<?= $e((string) ($form['meta_description'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.meta_keywords')) ?></span>
<input class="form-control" type="text" name="meta_keywords" value="<?= $e((string) ($form['meta_keywords'] ?? '')) ?>">
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.fields.seo_link')) ?></span>
<input class="form-control" type="text" name="seo_link" value="<?= $e((string) ($form['seo_link'] ?? '')) ?>">
</label>
</div>
<section class="card mt-16">
<h3><?= $e($t('products.images.title')) ?></h3>
<p class="muted"><?= $e($t('products.images.description')) ?></p>
<input type="hidden" id="product-image-csrf" value="<?= $e($csrfToken ?? '') ?>">
<div class="product-images-grid mt-12" id="product-images-grid" data-product-id="<?= $e((string) ($productId ?? 0)) ?>">
<?php foreach ($images as $image): ?>
<?php
$imageId = (int) ($image['id'] ?? 0);
$isMain = (int) ($image['is_main'] ?? 0) === 1;
$publicUrl = (string) ($image['public_url'] ?? '');
?>
<article
class="product-image-card<?= $isMain ? ' is-main' : '' ?>"
data-image-id="<?= $e((string) $imageId) ?>"
data-storage-path="<?= $e((string) ($image['storage_path'] ?? '')) ?>"
>
<div class="product-image-card__thumb-wrap">
<?php if ($publicUrl !== ''): ?>
<img class="product-image-card__thumb" src="<?= $e($publicUrl) ?>" alt="<?= $e((string) ($image['alt'] ?? '')) ?>">
<?php else: ?>
<div class="product-image-card__thumb is-empty">NO IMAGE</div>
<?php endif; ?>
<span class="product-image-card__badge"><?= $e($t('products.images.main')) ?></span>
</div>
<div class="product-image-card__meta"><?= $e((string) ($image['storage_path'] ?? '')) ?></div>
<div class="product-image-card__actions">
<button type="button" class="btn btn--secondary btn-set-main"<?= $isMain ? ' disabled' : '' ?>>
<?= $e($t('products.images.set_main')) ?>
</button>
<button type="button" class="btn btn--danger btn-delete-image">
<?= $e($t('products.images.remove')) ?>
</button>
</div>
</article>
<?php endforeach; ?>
</div>
<p class="muted mt-12" id="product-images-empty"<?= $images === [] ? '' : ' style="display:none;"' ?>>
<?= $e($t('products.images.empty')) ?>
</p>
<label class="form-field mt-16">
<span class="field-label"><?= $e($t('products.images.add_new')) ?></span>
<input class="form-control" type="file" id="product-image-upload" name="new_images[]" accept=".jpg,.jpeg,.png,.webp,.gif,image/*" multiple>
</label>
<p class="muted" id="product-image-upload-status"></p>
<p class="muted"><?= $e($t('products.images.main_hint')) ?></p>
</section>
<div class="form-actions mt-16">
<button class="btn btn--primary" type="submit"><?= $e($t('products.actions.save')) ?></button>
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
</div>
</form>
</section>
<script>
(function() {
var grid = document.getElementById('product-images-grid');
var emptyState = document.getElementById('product-images-empty');
var uploadInput = document.getElementById('product-image-upload');
var uploadStatus = document.getElementById('product-image-upload-status');
var tokenInput = document.getElementById('product-image-csrf');
if (!grid || !uploadInput || !tokenInput) return;
var productId = grid.getAttribute('data-product-id');
var csrfToken = tokenInput.value || '';
var txtSetMain = <?= json_encode((string) $t('products.images.set_main'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtRemove = <?= json_encode((string) $t('products.images.remove'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtMain = <?= json_encode((string) $t('products.images.main'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtUploadPending = <?= json_encode((string) $t('products.images.uploading'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtUploadOk = <?= json_encode((string) $t('products.images.uploaded_ok'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtDeleteConfirm = <?= json_encode((string) $t('products.images.confirm_delete'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtConfirmTitle = <?= json_encode((string) $t('products.images.confirm_title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtConfirmYes = <?= json_encode((string) $t('products.images.confirm_yes'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var txtConfirmNo = <?= json_encode((string) $t('products.images.confirm_no'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
function refreshMainState(mainId) {
var cards = grid.querySelectorAll('.product-image-card');
cards.forEach(function(card) {
var cardId = Number(card.getAttribute('data-image-id') || 0);
var isMain = cardId === mainId;
card.classList.toggle('is-main', isMain);
var setMainBtn = card.querySelector('.btn-set-main');
if (setMainBtn) setMainBtn.disabled = isMain;
});
}
function updateEmptyState() {
if (!emptyState) return;
emptyState.style.display = grid.querySelector('.product-image-card') ? 'none' : '';
}
function buildCard(image) {
var article = document.createElement('article');
article.className = 'product-image-card' + (Number(image.is_main) === 1 ? ' is-main' : '');
article.setAttribute('data-image-id', String(image.id));
article.setAttribute('data-storage-path', String(image.storage_path || ''));
var thumbWrap = document.createElement('div');
thumbWrap.className = 'product-image-card__thumb-wrap';
if (image.public_url) {
var img = document.createElement('img');
img.className = 'product-image-card__thumb';
img.src = image.public_url;
img.alt = image.alt || '';
thumbWrap.appendChild(img);
} else {
var noimg = document.createElement('div');
noimg.className = 'product-image-card__thumb is-empty';
noimg.textContent = 'NO IMAGE';
thumbWrap.appendChild(noimg);
}
var badge = document.createElement('span');
badge.className = 'product-image-card__badge';
badge.textContent = txtMain;
thumbWrap.appendChild(badge);
var meta = document.createElement('div');
meta.className = 'product-image-card__meta';
meta.textContent = image.storage_path || '';
var actions = document.createElement('div');
actions.className = 'product-image-card__actions';
var setMainBtn = document.createElement('button');
setMainBtn.type = 'button';
setMainBtn.className = 'btn btn--secondary btn-set-main';
setMainBtn.textContent = txtSetMain;
if (Number(image.is_main) === 1) setMainBtn.disabled = true;
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn--danger btn-delete-image';
removeBtn.textContent = txtRemove;
actions.appendChild(setMainBtn);
actions.appendChild(removeBtn);
article.appendChild(thumbWrap);
article.appendChild(meta);
article.appendChild(actions);
return article;
}
async function postForm(url, data) {
var response = await fetch(url, { method: 'POST', body: data, credentials: 'same-origin' });
var payload = await response.json();
if (!response.ok || payload.ok !== true) {
throw new Error(payload.message || 'Blad operacji.');
}
return payload;
}
uploadInput.addEventListener('change', async function() {
if (!uploadInput.files || uploadInput.files.length === 0) return;
var formData = new FormData();
formData.append('_token', csrfToken);
formData.append('id', String(productId));
Array.prototype.forEach.call(uploadInput.files, function(file) {
formData.append('new_images[]', file);
});
uploadStatus.textContent = txtUploadPending;
uploadInput.disabled = true;
try {
var result = await postForm('/products/images/upload', formData);
(result.images || []).forEach(function(image) {
grid.appendChild(buildCard(image));
if (Number(image.is_main) === 1) refreshMainState(Number(image.id));
});
uploadStatus.textContent = txtUploadOk;
if (result.message) uploadStatus.textContent += ' ' + result.message;
updateEmptyState();
} catch (error) {
uploadStatus.textContent = error.message || 'Blad uploadu.';
} finally {
uploadInput.value = '';
uploadInput.disabled = false;
}
});
grid.addEventListener('click', async function(event) {
var target = event.target;
if (!target || !(target instanceof HTMLElement)) return;
var card = target.closest('.product-image-card');
if (!card) return;
var imageId = Number(card.getAttribute('data-image-id') || 0);
if (imageId <= 0) return;
if (target.classList.contains('btn-set-main')) {
if (target.disabled) return;
var dataMain = new FormData();
dataMain.append('_token', csrfToken);
dataMain.append('id', String(productId));
dataMain.append('image_id', String(imageId));
target.disabled = true;
try {
await postForm('/products/images/set-main', dataMain);
refreshMainState(imageId);
} catch (error) {
target.disabled = false;
if (window.OrderProAlerts && typeof window.OrderProAlerts.alert === 'function') {
window.OrderProAlerts.alert({ title: 'Blad', message: error.message || 'Blad operacji.', danger: true });
} else if (uploadStatus) {
uploadStatus.textContent = error.message || 'Blad operacji.';
}
}
}
if (target.classList.contains('btn-delete-image')) {
var confirmDelete = async function() {
var dataDelete = new FormData();
dataDelete.append('_token', csrfToken);
dataDelete.append('id', String(productId));
dataDelete.append('image_id', String(imageId));
var result = await postForm('/products/images/delete', dataDelete);
card.remove();
if (Number(result.main_image_id || 0) > 0) {
refreshMainState(Number(result.main_image_id));
}
updateEmptyState();
};
if (window.OrderProAlerts && typeof window.OrderProAlerts.confirm === 'function') {
var accepted = await window.OrderProAlerts.confirm({
title: txtConfirmTitle,
message: txtDeleteConfirm,
confirmLabel: txtConfirmYes,
cancelLabel: txtConfirmNo,
danger: true
});
if (!accepted) return;
try { await confirmDelete(); } catch (error) {
window.OrderProAlerts.alert({ title: 'Blad', message: error.message || 'Blad operacji.', danger: true });
}
} else {
try { await confirmDelete(); } catch (error) {
if (uploadStatus) uploadStatus.textContent = error.message || 'Blad operacji.';
}
}
}
});
})();
</script>

View File

@@ -0,0 +1,177 @@
<section class="card">
<div class="page-head">
<div>
<h1><?= $e($t('products.title')) ?></h1>
<p class="muted"><?= $e($t('products.description')) ?></p>
</div>
</div>
</section>
<?php if (!empty($errorMessage)): ?>
<section class="card mt-16">
<div class="alert alert--danger" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
</section>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<section class="card mt-16">
<div class="alert alert--success" role="status">
<?= $e((string) $successMessage) ?>
</div>
</section>
<?php endif; ?>
<?php require __DIR__ . '/../components/table-list.php'; ?>
<?php
$integrations = is_array($shopProIntegrations ?? null) ? $shopProIntegrations : [];
?>
<div class="modal-backdrop" data-modal-backdrop="product-image-preview-modal" hidden>
<div class="modal modal--image-preview" role="dialog" aria-modal="true" aria-labelledby="product-image-preview-title">
<div class="modal__header">
<h3 id="product-image-preview-title">Podglad zdjecia</h3>
<button type="button" class="btn btn--secondary" data-close-modal="product-image-preview-modal">Zamknij</button>
</div>
<div class="modal__body">
<img src="" alt="" class="product-image-preview__img" data-product-image-preview-target>
</div>
</div>
</div>
<div class="modal-backdrop" data-modal-backdrop="product-import-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="product-import-modal-title">
<div class="modal__header">
<h3 id="product-import-modal-title"><?= $e($t('products.import.title')) ?></h3>
<button type="button" class="btn btn--secondary" data-close-modal="product-import-modal"><?= $e($t('products.import.close')) ?></button>
</div>
<form action="/products/import/shoppro" method="post" class="modal__body">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('products.import.integration')) ?></span>
<select class="form-control" name="integration_id" required>
<option value=""><?= $e($t('products.import.integration_placeholder')) ?></option>
<?php foreach ($integrations as $integration): ?>
<option value="<?= $e((string) ($integration['id'] ?? 0)) ?>">
<?= $e((string) ($integration['name'] ?? '')) ?> (ID: <?= $e((string) ($integration['id'] ?? 0)) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<?php if (empty($integrations)): ?>
<p class="muted"><?= $e($t('products.import.no_integrations')) ?></p>
<?php endif; ?>
<div class="form-field">
<span class="field-label"><?= $e($t('products.import.mode')) ?></span>
<label class="field-inline">
<input type="radio" name="import_mode" value="all" checked>
<?= $e($t('products.import.mode_all')) ?>
</label>
<label class="field-inline">
<input type="radio" name="import_mode" value="single">
<?= $e($t('products.import.mode_single')) ?>
</label>
</div>
<label class="form-field" data-single-id-wrap hidden>
<span class="field-label"><?= $e($t('products.import.external_id')) ?></span>
<input class="form-control" type="number" min="1" name="external_product_id" data-single-id-input>
</label>
<label class="form-field">
<span class="field-label">
<input type="checkbox" name="import_variants" value="1">
<?= $e($t('products.import.with_variants')) ?>
</span>
<small class="muted"><?= $e($t('products.import.with_variants_hint')) ?></small>
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"<?= empty($integrations) ? ' disabled' : '' ?>><?= $e($t('products.import.submit')) ?></button>
</div>
</form>
</div>
</div>
<script>
(function() {
var openBtn = document.querySelector('[data-open-modal="product-import-modal"]');
var backdrop = document.querySelector('[data-modal-backdrop="product-import-modal"]');
var closeBtn = document.querySelector('[data-close-modal="product-import-modal"]');
if (!openBtn || !backdrop || !closeBtn) return;
var modeInputs = backdrop.querySelectorAll('input[name="import_mode"]');
var singleIdWrap = backdrop.querySelector('[data-single-id-wrap]');
var singleIdInput = backdrop.querySelector('[data-single-id-input]');
function syncMode() {
var mode = 'all';
modeInputs.forEach(function(input) {
if (input.checked) mode = input.value;
});
var isSingle = mode === 'single';
if (singleIdWrap) singleIdWrap.hidden = !isSingle;
if (singleIdInput) {
singleIdInput.required = isSingle;
if (!isSingle) singleIdInput.value = '';
}
}
openBtn.addEventListener('click', function() {
backdrop.hidden = false;
syncMode();
});
closeBtn.addEventListener('click', function() {
backdrop.hidden = true;
});
backdrop.addEventListener('click', function(event) {
if (event.target === backdrop) {
backdrop.hidden = true;
}
});
modeInputs.forEach(function(input) {
input.addEventListener('change', syncMode);
});
})();
(function() {
var previewBackdrop = document.querySelector('[data-modal-backdrop="product-image-preview-modal"]');
if (!previewBackdrop) return;
var previewImage = previewBackdrop.querySelector('[data-product-image-preview-target]');
var closeBtn = previewBackdrop.querySelector('[data-close-modal="product-image-preview-modal"]');
if (!previewImage || !closeBtn) return;
function closePreview() {
previewBackdrop.hidden = true;
previewImage.setAttribute('src', '');
}
document.addEventListener('click', function(event) {
var trigger = event.target.closest('[data-product-image-preview]');
if (!trigger) return;
var imageUrl = trigger.getAttribute('data-product-image-preview') || '';
if (imageUrl === '') return;
previewImage.setAttribute('src', imageUrl);
previewBackdrop.hidden = false;
});
closeBtn.addEventListener('click', closePreview);
previewBackdrop.addEventListener('click', function(event) {
if (event.target === previewBackdrop) {
closePreview();
}
});
})();
</script>

View File

@@ -0,0 +1,251 @@
<?php $item = is_array($product ?? null) ? $product : []; ?>
<?php $links = is_array($productLinks ?? null) ? $productLinks : []; ?>
<?php $integrations = is_array($linkIntegrations ?? null) ? $linkIntegrations : []; ?>
<?php $offers = is_array($linkOffers ?? null) ? $linkOffers : []; ?>
<?php $eventsByMap = is_array($productLinkEventsByMap ?? null) ? $productLinkEventsByMap : []; ?>
<?php $selectedIntegrationId = (int) ($selectedLinksIntegrationId ?? 0); ?>
<?php $linksQueryValue = (string) ($linksQuery ?? ''); ?>
<?php $productIdValue = (int) ($productId ?? 0); ?>
<section class="card">
<h1><?= $e($t('products.links.page_title', ['id' => (string) ($productId ?? 0)])) ?></h1>
<p class="muted"><?= $e($t('products.links.description')) ?></p>
</section>
<section class="card mt-16">
<div class="product-tabs-nav">
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>"><?= $e($t('products.tabs.details')) ?></a>
<span class="btn btn--primary"><?= $e($t('products.tabs.links')) ?></span>
</div>
</section>
<section class="card mt-16">
<div class="product-links-head">
<div>
<strong><?= $e($t('products.fields.name')) ?>:</strong>
<?= $e((string) ($item['name'] ?? '')) ?>
</div>
<div>
<strong>SKU:</strong>
<?= $e((string) ($item['sku'] ?? '')) ?>
</div>
<div>
<strong>EAN:</strong>
<?= $e((string) ($item['ean'] ?? '')) ?>
</div>
</div>
</section>
<section class="card mt-16">
<h3><?= $e($t('products.links.title')) ?></h3>
<?php if (!empty($linksErrorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $linksErrorMessage) ?></div>
<?php endif; ?>
<?php if (!empty($linksSuccessMessage)): ?>
<div class="alert alert--success mt-12" role="status"><?= $e((string) $linksSuccessMessage) ?></div>
<?php endif; ?>
<h4 class="section-title mt-16"><?= $e($t('products.links.current_links')) ?></h4>
<?php if ($links === []): ?>
<p class="muted mt-12"><?= $e($t('products.links.empty_links')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('products.links.fields.integration')) ?></th>
<th><?= $e($t('products.links.fields.channel')) ?></th>
<th><?= $e($t('products.links.fields.external_product_id')) ?></th>
<th><?= $e($t('products.links.fields.external_variant_id')) ?></th>
<th><?= $e($t('products.links.fields.link_type')) ?></th>
<th><?= $e($t('products.links.fields.confidence')) ?></th>
<th><?= $e($t('products.links.fields.link_status')) ?></th>
<th><?= $e($t('products.links.fields.updated_at')) ?></th>
<th><?= $e($t('products.links.fields.history')) ?></th>
<th><?= $e($t('products.links.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($links as $link): ?>
<?php
$mapId = (int) ($link['id'] ?? 0);
$linkStatus = (string) ($link['link_status'] ?? '');
$isActive = $linkStatus === 'active';
$confidence = $link['confidence'] ?? null;
$lastChangeAt = trim((string) ($link['updated_at'] ?? ''));
if ($lastChangeAt === '') {
$lastChangeAt = trim((string) ($link['linked_at'] ?? ''));
}
?>
<tr>
<td><?= $e((string) (($link['integration_name'] ?? '') !== '' ? $link['integration_name'] : ('#' . (string) ($link['integration_id'] ?? 0)))) ?></td>
<td><?= $e((string) ($link['channel_name'] ?? '')) ?></td>
<td><?= $e((string) ($link['external_product_id'] ?? '')) ?></td>
<td><?= $e((string) ($link['external_variant_id'] ?? '')) ?></td>
<td><?= $e((string) ($link['link_type'] ?? '')) ?></td>
<td><?= $e($confidence === null ? '-' : ((string) $confidence . '%')) ?></td>
<td>
<span class="status-pill<?= $isActive ? ' is-active' : '' ?>">
<?= $e($linkStatus) ?>
</span>
</td>
<td><?= $e($lastChangeAt === '' ? '-' : $lastChangeAt) ?></td>
<td>
<?php $events = is_array($eventsByMap[$mapId] ?? null) ? $eventsByMap[$mapId] : []; ?>
<?php if ($events === []): ?>
<span class="muted">-</span>
<?php else: ?>
<ul class="product-link-events-list">
<?php foreach ($events as $event): ?>
<li>
<span class="product-link-events-type"><?= $e((string) ($event['event_type'] ?? '')) ?></span>
<span class="product-link-events-date"><?= $e((string) ($event['created_at'] ?? '')) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</td>
<td>
<div class="product-links-actions-row">
<form action="/products/<?= $e((string) $productIdValue) ?>/links/<?= $e((string) $mapId) ?>/relink" method="post" class="product-links-inline-form product-links-relink-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
<input type="hidden" name="map_id" value="<?= $e((string) $mapId) ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ((int) ($link['integration_id'] ?? 0))) ?>">
<input class="form-control" type="text" name="external_product_id" required value="<?= $e((string) ($link['external_product_id'] ?? '')) ?>">
<input class="form-control" type="text" name="external_variant_id" value="<?= $e((string) ($link['external_variant_id'] ?? '')) ?>" placeholder="<?= $e($t('products.links.fields.external_variant_id_optional')) ?>">
<button type="submit" class="btn btn--secondary" data-links-action="relink"><?= $e($t('products.links.actions.relink')) ?></button>
</form>
<form action="/products/<?= $e((string) $productIdValue) ?>/links/<?= $e((string) $mapId) ?>/unlink" method="post" class="product-links-unlink-form">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
<input type="hidden" name="map_id" value="<?= $e((string) $mapId) ?>">
<button type="submit" class="btn btn--danger" data-links-action="unlink"><?= $e($t('products.links.actions.unlink')) ?></button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<h4 class="section-title mt-16"><?= $e($t('products.links.search_title')) ?></h4>
<form class="product-links-search-form mt-12" action="/products/<?= $e((string) $productIdValue) ?>/links" method="get">
<input type="hidden" name="id" value="<?= $e((string) ($productId ?? 0)) ?>">
<label class="form-field">
<span class="field-label"><?= $e($t('products.links.fields.integration')) ?></span>
<select class="form-control" name="links_integration_id" required>
<option value="0"><?= $e($t('products.links.integration_placeholder')) ?></option>
<?php foreach ($integrations as $integration): ?>
<?php $integrationId = (int) ($integration['id'] ?? 0); ?>
<option value="<?= $e((string) $integrationId) ?>"<?= $integrationId === $selectedIntegrationId ? ' selected' : '' ?>>
<?= $e((string) ($integration['name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('products.links.fields.search')) ?></span>
<input class="form-control" type="text" name="links_query" value="<?= $e($linksQueryValue) ?>" placeholder="<?= $e($t('products.links.search_placeholder')) ?>">
</label>
<button type="submit" class="btn btn--primary"><?= $e($t('products.links.actions.search')) ?></button>
</form>
<?php if ($offers === []): ?>
<p class="muted mt-12"><?= $e($t('products.links.empty_offers')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('products.links.fields.offer_name')) ?></th>
<th>SKU</th>
<th>EAN</th>
<th><?= $e($t('products.links.fields.external_product_id')) ?></th>
<th><?= $e($t('products.links.fields.external_variant_id')) ?></th>
<th><?= $e($t('products.links.fields.match_hint')) ?></th>
<th><?= $e($t('products.links.fields.confidence')) ?></th>
<th><?= $e($t('products.links.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($offers as $offer): ?>
<tr>
<td><?= $e((string) ($offer['name'] ?? '')) ?></td>
<td><?= $e((string) ($offer['sku'] ?? '')) ?></td>
<td><?= $e((string) ($offer['ean'] ?? '')) ?></td>
<td><?= $e((string) ($offer['external_product_id'] ?? '')) ?></td>
<td><?= $e((string) ($offer['external_variant_id'] ?? '')) ?></td>
<td><?= $e((string) ($offer['match_hint'] ?? '')) ?></td>
<td><?= $e((string) ((int) ($offer['match_confidence'] ?? 0)) . '%') ?></td>
<td>
<form action="/products/<?= $e((string) $productIdValue) ?>/links" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="product_id" value="<?= $e((string) ($productId ?? 0)) ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($offer['integration_id'] ?? 0)) ?>">
<input type="hidden" name="external_product_id" value="<?= $e((string) ($offer['external_product_id'] ?? '')) ?>">
<input type="hidden" name="external_variant_id" value="<?= $e((string) ($offer['external_variant_id'] ?? '')) ?>">
<button type="submit" class="btn btn--primary"><?= $e($t('products.links.actions.link')) ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>"><?= $e($t('products.actions.preview')) ?></a>
<a class="btn btn--primary" href="/products/edit?id=<?= $e((string) ($productId ?? 0)) ?>"><?= $e($t('products.actions.edit')) ?></a>
</section>
<script>
(function () {
var unlinkForms = document.querySelectorAll('.product-links-unlink-form');
var relinkForms = document.querySelectorAll('.product-links-relink-form');
var unlinkMessage = <?= json_encode((string) $t('products.links.confirm.unlink_message'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var relinkMessage = <?= json_encode((string) $t('products.links.confirm.relink_message'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var confirmTitle = <?= json_encode((string) $t('products.links.confirm.title'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var confirmYes = <?= json_encode((string) $t('products.links.confirm.yes'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
var confirmNo = <?= json_encode((string) $t('products.links.confirm.no'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
async function handleConfirmSubmit(event, message, danger) {
if (!window.OrderProAlerts || typeof window.OrderProAlerts.confirm !== 'function') {
return;
}
event.preventDefault();
var accepted = await window.OrderProAlerts.confirm({
title: confirmTitle,
message: message,
confirmLabel: confirmYes,
cancelLabel: confirmNo,
danger: danger === true
});
if (!accepted) {
return;
}
event.target.submit();
}
unlinkForms.forEach(function (form) {
form.addEventListener('submit', function (event) {
handleConfirmSubmit(event, unlinkMessage, true);
});
});
relinkForms.forEach(function (form) {
form.addEventListener('submit', function (event) {
handleConfirmSubmit(event, relinkMessage, false);
});
});
})();
</script>

View File

@@ -0,0 +1,135 @@
<section class="card">
<h1><?= $e($t('products.show.title', ['id' => (string) ($productId ?? 0)])) ?></h1>
<p class="muted"><?= $e($t('products.show.description')) ?></p>
</section>
<?php $item = is_array($product ?? null) ? $product : []; ?>
<?php $images = is_array($productImages ?? null) ? $productImages : []; ?>
<?php $variants = is_array($productVariants ?? null) ? $productVariants : []; ?>
<?php $importWarning = is_array($productImportWarning ?? null) ? $productImportWarning : null; ?>
<?php $productIdValue = (int) ($productId ?? 0); ?>
<section class="card mt-16">
<div class="product-tabs-nav">
<span class="btn btn--primary"><?= $e($t('products.tabs.details')) ?></span>
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>/links"><?= $e($t('products.tabs.links')) ?></a>
</div>
</section>
<section class="card mt-16">
<?php if ($importWarning !== null && !empty($importWarning['messages'])): ?>
<div class="alert alert--danger" role="alert">
<div><strong><?= $e($t('products.variants.import_warning_title')) ?></strong></div>
<?php foreach ((array) ($importWarning['messages'] ?? []) as $warning): ?>
<div><?= $e((string) $warning) ?></div>
<?php endforeach; ?>
<?php if (!empty($importWarning['created_at'])): ?>
<div class="muted mt-8"><?= $e($t('products.variants.import_warning_date')) ?>: <?= $e((string) $importWarning['created_at']) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<h3><?= $e($t('products.show.details')) ?></h3>
<table class="table mt-12">
<tbody>
<tr><th>ID</th><td><?= $e((string) ($item['id'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.name')) ?></th><td><?= $e((string) ($item['name'] ?? '')) ?></td></tr>
<tr><th>SKU</th><td><?= $e((string) ($item['sku'] ?? '')) ?></td></tr>
<tr><th>EAN</th><td><?= $e((string) ($item['ean'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.type')) ?></th><td><?= $e((string) ($item['type'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.status')) ?></th><td><?= $e((string) ($item['status'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.promoted')) ?></th><td><?= $e((string) ($item['promoted'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.vat')) ?></th><td><?= $e((string) ($item['vat'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.weight')) ?></th><td><?= $e((string) ($item['weight'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.quantity')) ?></th><td><?= $e((string) ($item['quantity'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_brutto')) ?></th><td><?= $e((string) ($item['price_brutto'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_netto')) ?></th><td><?= $e((string) ($item['price_netto'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_brutto_promo')) ?></th><td><?= $e((string) ($item['price_brutto_promo'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.price_netto_promo')) ?></th><td><?= $e((string) ($item['price_netto_promo'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.short_description')) ?></th><td><?= $e((string) ($item['short_description'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.description')) ?></th><td><?= $e((string) ($item['description'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.meta_title')) ?></th><td><?= $e((string) ($item['meta_title'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.meta_description')) ?></th><td><?= $e((string) ($item['meta_description'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.meta_keywords')) ?></th><td><?= $e((string) ($item['meta_keywords'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.seo_link')) ?></th><td><?= $e((string) ($item['seo_link'] ?? '')) ?></td></tr>
<tr><th><?= $e($t('products.fields.updated_at')) ?></th><td><?= $e((string) ($item['updated_at'] ?? '')) ?></td></tr>
</tbody>
</table>
</section>
<section class="card mt-16">
<h3><?= $e($t('products.variants.title')) ?></h3>
<?php if ($variants === []): ?>
<p class="muted"><?= $e($t('products.variants.empty')) ?></p>
<?php else: ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>SKU</th>
<th>EAN</th>
<th><?= $e($t('products.fields.price_brutto')) ?></th>
<th><?= $e($t('products.fields.price_netto')) ?></th>
<th><?= $e($t('products.fields.weight')) ?></th>
<th><?= $e($t('products.fields.status')) ?></th>
<th><?= $e($t('products.variants.attributes')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($variants as $variant): ?>
<?php
$attributes = is_array($variant['attributes'] ?? null) ? $variant['attributes'] : [];
$attributeText = [];
foreach ($attributes as $attribute) {
$attributeName = (string) ($attribute['attribute_name'] ?? '');
$valueName = (string) ($attribute['value_name'] ?? '');
if ($attributeName === '' || $valueName === '') {
continue;
}
$attributeText[] = $attributeName . ': ' . $valueName;
}
?>
<tr>
<td><?= $e((string) ($variant['id'] ?? 0)) ?></td>
<td><?= $e((string) ($variant['sku'] ?? '')) ?></td>
<td><?= $e((string) ($variant['ean'] ?? '')) ?></td>
<td><?= $e((string) ($variant['price_brutto'] ?? '')) ?></td>
<td><?= $e((string) ($variant['price_netto'] ?? '')) ?></td>
<td><?= $e((string) ($variant['weight'] ?? '')) ?></td>
<td><?= $e(((int) ($variant['status'] ?? 0)) === 1 ? $t('products.status.active') : $t('products.status.inactive')) ?></td>
<td><?= $e($attributeText !== [] ? implode(', ', $attributeText) : '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<h3><?= $e($t('products.images.title')) ?></h3>
<?php if ($images === []): ?>
<p class="muted"><?= $e($t('products.images.empty')) ?></p>
<?php else: ?>
<div class="product-show-images-grid mt-12">
<?php foreach ($images as $image): ?>
<div class="product-show-image-card">
<div><strong>ID:</strong> <?= $e((string) ($image['id'] ?? 0)) ?><?= ((int) ($image['is_main'] ?? 0) === 1) ? ' | <strong>' . $e($t('products.images.main')) . '</strong>' : '' ?></div>
<div class="muted"><?= $e((string) ($image['storage_path'] ?? '')) ?></div>
<?php if ((string) ($image['public_url'] ?? '') !== ''): ?>
<div class="mt-12">
<img src="<?= $e((string) $image['public_url']) ?>" alt="<?= $e((string) ($image['alt'] ?? '')) ?>" class="product-show-image">
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="card mt-16">
<a class="btn btn--secondary" href="/products"><?= $e($t('products.actions.back')) ?></a>
<a class="btn btn--secondary" href="/products/<?= $e((string) $productIdValue) ?>/links"><?= $e($t('products.actions.links')) ?></a>
<a class="btn btn--primary" href="/products/edit?id=<?= $e((string) $productIdValue) ?>"><?= $e($t('products.actions.edit')) ?></a>
</section>

View File

@@ -11,7 +11,8 @@ $logs = (array) ($runLogs ?? []);
<h1><?= $e($t('settings.title')) ?></h1> <h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p> <p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>"> <nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link is-active" href="/settings/database"><?= $e($t('settings.database.title')) ?></a> <a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? '' : ' is-active' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
</nav> </nav>
</section> </section>

View File

@@ -0,0 +1,186 @@
<?php
$list = (array) ($integrations ?? []);
$selected = is_array($selectedIntegration ?? null) ? $selectedIntegration : null;
$formValues = is_array($form ?? null) ? $form : [];
$tests = (array) ($recentTests ?? []);
$isEdit = ((int) ($formValues['integration_id'] ?? 0)) > 0;
?>
<section class="card">
<h1><?= $e($t('settings.title')) ?></h1>
<p class="muted"><?= $e($t('settings.description')) ?></p>
<nav class="settings-nav mt-16" aria-label="<?= $e($t('settings.submenu_label')) ?>">
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? '' : ' is-active' ?>" href="/settings/database"><?= $e($t('settings.database.title')) ?></a>
<a class="settings-nav__link<?= ($activeSettings ?? '') === 'integrations' ? ' is-active' : '' ?>" href="/settings/integrations/shoppro"><?= $e($t('settings.integrations.title')) ?></a>
</nav>
</section>
<section class="card mt-16">
<h2 class="section-title"><?= $e($t('settings.integrations.list_title')) ?></h2>
<?php if (!empty($errorMessage)): ?>
<div class="alert alert--danger mt-12" role="alert">
<?= $e((string) $errorMessage) ?>
</div>
<?php endif; ?>
<?php if (!empty($successMessage)): ?>
<div class="alert alert--success mt-12" role="status">
<?= $e((string) $successMessage) ?>
</div>
<?php endif; ?>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th><?= $e($t('settings.integrations.fields.name')) ?></th>
<th><?= $e($t('settings.integrations.fields.base_url')) ?></th>
<th><?= $e($t('settings.integrations.fields.active')) ?></th>
<th><?= $e($t('settings.integrations.fields.last_test')) ?></th>
<th><?= $e($t('settings.integrations.fields.actions')) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($list)): ?>
<tr>
<td colspan="6" class="muted"><?= $e($t('settings.integrations.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($list as $item): ?>
<?php
$status = (string) ($item['last_test_status'] ?? '');
$statusLabel = $status === 'ok'
? $t('settings.integrations.test_status.ok')
: ($status === 'error' ? $t('settings.integrations.test_status.error') : $t('settings.integrations.test_status.never'));
?>
<tr>
<td><?= $e((string) ($item['id'] ?? 0)) ?></td>
<td><?= $e((string) ($item['name'] ?? '')) ?></td>
<td><?= $e((string) ($item['base_url'] ?? '')) ?></td>
<td>
<?php if ((bool) ($item['is_active'] ?? false)): ?>
<span class="status-pill is-active"><?= $e($t('settings.integrations.active.yes')) ?></span>
<?php else: ?>
<span class="status-pill"><?= $e($t('settings.integrations.active.no')) ?></span>
<?php endif; ?>
</td>
<td>
<div><?= $e($statusLabel) ?></div>
<?php if (!empty($item['last_test_at'])): ?>
<small class="muted"><?= $e((string) $item['last_test_at']) ?><?php if (($item['last_test_http_code'] ?? null) !== null): ?> | HTTP <?= $e((string) $item['last_test_http_code']) ?><?php endif; ?></small>
<?php endif; ?>
</td>
<td>
<a class="btn btn--secondary" href="/settings/integrations/shoppro?id=<?= $e((string) ($item['id'] ?? 0)) ?>"><?= $e($t('settings.integrations.actions.edit')) ?></a>
<form action="/settings/integrations/shoppro/test" method="post" style="display:inline-block; margin-left:6px;">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($item['id'] ?? 0)) ?>">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.test')) ?></button>
</form>
<form action="/settings/integrations/shoppro/import-offers-cache" method="post" style="display:inline-block; margin-left:6px;">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($item['id'] ?? 0)) ?>">
<button type="submit" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.import_offers_cache')) ?></button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="card mt-16">
<h2 class="section-title">
<?= $e($isEdit ? $t('settings.integrations.edit_title') : $t('settings.integrations.create_title')) ?>
</h2>
<form class="mt-16" action="/settings/integrations/shoppro/save" method="post">
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
<input type="hidden" name="integration_id" value="<?= $e((string) ($formValues['integration_id'] ?? '0')) ?>">
<div class="form-grid">
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.name')) ?></span>
<input class="form-control" type="text" name="name" value="<?= $e((string) ($formValues['name'] ?? '')) ?>" required>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.base_url')) ?></span>
<input class="form-control" type="url" name="base_url" value="<?= $e((string) ($formValues['base_url'] ?? '')) ?>" placeholder="https://shoppro.project-dc.pl/" required>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.api_key')) ?></span>
<input class="form-control" type="password" name="api_key" value="" placeholder="<?= $e($isEdit ? $t('settings.integrations.api_key_placeholder_edit') : '') ?>">
<?php if ($isEdit): ?>
<small class="muted">
<?= $e(($selected['has_api_key'] ?? false) ? $t('settings.integrations.api_key_saved') : $t('settings.integrations.api_key_missing')) ?>
</small>
<?php endif; ?>
</label>
<label class="form-field">
<span class="field-label"><?= $e($t('settings.integrations.fields.timeout_seconds')) ?></span>
<input class="form-control" type="number" min="3" max="60" name="timeout_seconds" value="<?= $e((string) ($formValues['timeout_seconds'] ?? '10')) ?>">
</label>
</div>
<label class="form-field mt-12">
<span class="field-label">
<input type="checkbox" name="is_active" value="1"<?= ((string) ($formValues['is_active'] ?? '1')) === '1' ? ' checked' : '' ?>>
<?= $e($t('settings.integrations.fields.active_checkbox')) ?>
</span>
</label>
<div class="form-actions mt-16">
<button type="submit" class="btn btn--primary"><?= $e($t('settings.integrations.actions.save')) ?></button>
<a href="/settings/integrations/shoppro" class="btn btn--secondary"><?= $e($t('settings.integrations.actions.new')) ?></a>
<?php if ($isEdit): ?>
<button
type="submit"
class="btn btn--secondary"
formaction="/settings/integrations/shoppro/test"
formmethod="post"
><?= $e($t('settings.integrations.actions.test_now')) ?></button>
<button
type="submit"
class="btn btn--secondary"
formaction="/settings/integrations/shoppro/import-offers-cache"
formmethod="post"
><?= $e($t('settings.integrations.actions.import_offers_cache')) ?></button>
<?php endif; ?>
</div>
</form>
<?php if ($selected !== null && !empty($tests)): ?>
<h3 class="section-title mt-16"><?= $e($t('settings.integrations.logs_title')) ?></h3>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('settings.integrations.logs.fields.tested_at')) ?></th>
<th><?= $e($t('settings.integrations.logs.fields.status')) ?></th>
<th><?= $e($t('settings.integrations.logs.fields.http_code')) ?></th>
<th><?= $e($t('settings.integrations.logs.fields.message')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($tests as $test): ?>
<?php $httpCode = $test['http_code'] ?? null; ?>
<tr>
<td><?= $e((string) ($test['tested_at'] ?? '')) ?></td>
<td><?= $e((string) ($test['status'] ?? '')) ?></td>
<td><?= $e($httpCode === null ? '-' : (string) $httpCode) ?></td>
<td><?= $e((string) ($test['message'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>

View File

@@ -40,33 +40,4 @@
</form> </form>
</section> </section>
<section class="card mt-16"> <?php require __DIR__ . '/../components/table-list.php'; ?>
<h2 class="section-title"><?= $e($t('users.list_title')) ?></h2>
<div class="table-wrap mt-12">
<table class="table">
<thead>
<tr>
<th><?= $e($t('users.fields.name')) ?></th>
<th><?= $e($t('users.fields.email')) ?></th>
<th><?= $e($t('users.fields.created_at')) ?></th>
</tr>
</thead>
<tbody>
<?php if (empty($users)): ?>
<tr>
<td colspan="3" class="muted"><?= $e($t('users.empty')) ?></td>
</tr>
<?php else: ?>
<?php foreach ($users as $row): ?>
<tr>
<td><?= $e((string) ($row['name'] ?? '')) ?></td>
<td><?= $e((string) ($row['email'] ?? '')) ?></td>
<td><?= $e((string) ($row['created_at'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>

View File

@@ -7,7 +7,19 @@ use App\Core\Http\Response;
use App\Core\Security\Csrf; use App\Core\Security\Csrf;
use App\Modules\Auth\AuthController; use App\Modules\Auth\AuthController;
use App\Modules\Auth\AuthMiddleware; use App\Modules\Auth\AuthMiddleware;
use App\Modules\ProductLinks\ChannelOffersRepository;
use App\Modules\ProductLinks\LinkMatcherService;
use App\Modules\ProductLinks\OfferImportService;
use App\Modules\ProductLinks\ProductLinksController;
use App\Modules\ProductLinks\ProductLinksRepository;
use App\Modules\ProductLinks\ProductLinksService;
use App\Modules\Products\ProductRepository;
use App\Modules\Products\ProductsController;
use App\Modules\Products\ProductService;
use App\Modules\Products\ProductValidator;
use App\Modules\Settings\IntegrationRepository;
use App\Modules\Settings\SettingsController; use App\Modules\Settings\SettingsController;
use App\Modules\Settings\ShopProClient;
use App\Modules\Users\UsersController; use App\Modules\Users\UsersController;
return static function (Application $app): void { return static function (Application $app): void {
@@ -18,7 +30,46 @@ return static function (Application $app): void {
$authController = new AuthController($template, $auth, $translator); $authController = new AuthController($template, $auth, $translator);
$usersController = new UsersController($template, $translator, $auth, $app->users()); $usersController = new UsersController($template, $translator, $auth, $app->users());
$settingsController = new SettingsController($template, $translator, $auth, $app->migrator()); $integrationRepository = new IntegrationRepository(
$app->db(),
(string) $app->config('app.integrations.secret', '')
);
$shopProClient = new ShopProClient();
$channelOffersRepository = new ChannelOffersRepository($app->db());
$productLinksRepository = new ProductLinksRepository($app->db());
$linkMatcherService = new LinkMatcherService();
$productLinksService = new ProductLinksService(
$productLinksRepository,
$channelOffersRepository,
$integrationRepository,
$linkMatcherService,
$app->db()
);
$productLinksController = new ProductLinksController($translator, $auth, $productLinksService);
$offerImportService = new OfferImportService($shopProClient, $channelOffersRepository, $app->db());
$productRepository = new ProductRepository($app->db());
$settingsController = new SettingsController(
$template,
$translator,
$auth,
$app->migrator(),
$integrationRepository,
$shopProClient,
$offerImportService,
$productRepository,
$app->db()
);
$productValidator = new ProductValidator();
$productService = new ProductService($app->db(), $productRepository, $productValidator);
$productsController = new ProductsController(
$template,
$translator,
$auth,
$productRepository,
$productService,
$integrationRepository,
$productLinksService
);
$authMiddleware = new AuthMiddleware($auth); $authMiddleware = new AuthMiddleware($auth);
$router->get('/health', static fn (Request $request): Response => Response::json([ $router->get('/health', static fn (Request $request): Response => Response::json([
@@ -51,6 +102,37 @@ return static function (Application $app): void {
$router->get('/users', [$usersController, 'index'], [$authMiddleware]); $router->get('/users', [$usersController, 'index'], [$authMiddleware]);
$router->post('/users', [$usersController, 'store'], [$authMiddleware]); $router->post('/users', [$usersController, 'store'], [$authMiddleware]);
$router->get('/products', [$productsController, 'index'], [$authMiddleware]);
$router->get('/products/show', [$productsController, 'show'], [$authMiddleware]);
$router->get('/products/{id}', [$productsController, 'show'], [$authMiddleware]);
$router->get('/products/links', [$productsController, 'links'], [$authMiddleware]);
$router->get('/products/{id}/links', [$productsController, 'links'], [$authMiddleware]);
$router->get('/products/{id}/links/suggestions', [$productsController, 'linkSuggestions'], [$authMiddleware]);
$router->get('/products/create', [$productsController, 'create'], [$authMiddleware]);
$router->post('/products', [$productsController, 'store'], [$authMiddleware]);
$router->get('/products/edit', [$productsController, 'edit'], [$authMiddleware]);
$router->post('/products/update', [$productsController, 'update'], [$authMiddleware]);
$router->post('/products/delete', [$productsController, 'destroy'], [$authMiddleware]);
$router->post('/products/images/upload', [$productsController, 'uploadImage'], [$authMiddleware]);
$router->post('/products/images/set-main', [$productsController, 'setMainImage'], [$authMiddleware]);
$router->post('/products/images/delete', [$productsController, 'deleteImage'], [$authMiddleware]);
$router->post('/products/links/create', [$productLinksController, 'create'], [$authMiddleware]);
$router->post('/products/links/relink', [$productLinksController, 'relink'], [$authMiddleware]);
$router->post('/products/links/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
$router->post('/products/{id}/links', [$productLinksController, 'create'], [$authMiddleware]);
$router->post('/products/{id}/links/{mapId}/relink', [$productLinksController, 'relink'], [$authMiddleware]);
$router->post('/products/{id}/links/{mapId}/unlink', [$productLinksController, 'unlink'], [$authMiddleware]);
$router->post('/products/import/shoppro', [$settingsController, 'importProducts'], [$authMiddleware]);
$router->get('/settings/integrations', static fn (Request $request): Response => Response::redirect('/settings/integrations/shoppro'), [$authMiddleware]);
$router->get('/settings/integrations/shoppro', [$settingsController, 'integrations'], [$authMiddleware]);
$router->post('/settings/integrations/save', [$settingsController, 'saveIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/save', [$settingsController, 'saveIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/test', [$settingsController, 'testIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/test', [$settingsController, 'testIntegration'], [$authMiddleware]);
$router->post('/settings/integrations/import-one-product', [$settingsController, 'importOneProduct'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/import-one-product', [$settingsController, 'importOneProduct'], [$authMiddleware]);
$router->post('/settings/integrations/import-offers-cache', [$settingsController, 'importOffersCache'], [$authMiddleware]);
$router->post('/settings/integrations/shoppro/import-offers-cache', [$settingsController, 'importOffersCache'], [$authMiddleware]);
$router->get('/settings/database', [$settingsController, 'database'], [$authMiddleware]); $router->get('/settings/database', [$settingsController, 'database'], [$authMiddleware]);
$router->post('/settings/database/migrate', [$settingsController, 'migrate'], [$authMiddleware]); $router->post('/settings/database/migrate', [$settingsController, 'migrate'], [$authMiddleware]);
}; };

View File

@@ -74,14 +74,16 @@ final class Migrator
continue; continue;
} }
$this->pdo->beginTransaction();
try { try {
$this->pdo->beginTransaction();
$this->pdo->exec($sql); $this->pdo->exec($sql);
$insert->execute([ $insert->execute([
'filename' => $filename, 'filename' => $filename,
'executed_at' => date('Y-m-d H:i:s'), 'executed_at' => date('Y-m-d H:i:s'),
]); ]);
$this->pdo->commit(); if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
$executed++; $executed++;
$logs[] = '[ok] ' . $filename; $logs[] = '[ok] ' . $filename;
} catch (Throwable $exception) { } catch (Throwable $exception) {

View File

@@ -8,18 +8,21 @@ final class Request
/** /**
* @param array<string, mixed> $query * @param array<string, mixed> $query
* @param array<string, mixed> $request * @param array<string, mixed> $request
* @param array<string, mixed> $files
* @param array<string, mixed> $server * @param array<string, mixed> $server
*/ */
public function __construct( public function __construct(
private readonly array $query, private readonly array $query,
private readonly array $request, private readonly array $request,
private readonly array $server private readonly array $files,
private readonly array $server,
private readonly array $attributes = []
) { ) {
} }
public static function capture(): self public static function capture(): self
{ {
return new self($_GET, $_POST, $_SERVER); return new self($_GET, $_POST, $_FILES, $_SERVER);
} }
public function method(): string public function method(): string
@@ -45,9 +48,21 @@ final class Request
return $this->query[$key]; return $this->query[$key];
} }
if (array_key_exists($key, $this->attributes)) {
return $this->attributes[$key];
}
return $default; return $default;
} }
/**
* @param array<string, mixed> $attributes
*/
public function withAttributes(array $attributes): self
{
return new self($this->query, $this->request, $this->files, $this->server, $attributes);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@@ -55,4 +70,21 @@ final class Request
{ {
return array_merge($this->query, $this->request); return array_merge($this->query, $this->request);
} }
public function file(string $key, mixed $default = null): mixed
{
if (array_key_exists($key, $this->files)) {
return $this->files[$key];
}
return $default;
}
/**
* @return array<string, mixed>
*/
public function allFiles(): array
{
return $this->files;
}
} }

View File

@@ -39,6 +39,11 @@ final class Router
return Response::html('Not found', 404); return Response::html('Not found', 404);
} }
$params = (array) ($route['params'] ?? []);
if ($params !== []) {
$request = $request->withAttributes($params);
}
$handler = $route['handler']; $handler = $route['handler'];
$middlewares = $route['middlewares']; $middlewares = $route['middlewares'];
@@ -93,6 +98,23 @@ final class Router
} }
} }
foreach ($this->routes as $route) {
if ($route['method'] !== $normalizedMethod) {
continue;
}
if (!$this->hasRouteParams($route['path'])) {
continue;
}
$match = $this->matchPathParams($route['path'], $normalizedPath);
if ($match === null) {
continue;
}
$route['params'] = $match;
return $route;
}
return null; return null;
} }
@@ -123,4 +145,43 @@ final class Router
throw new RuntimeException('Invalid middleware definition'); throw new RuntimeException('Invalid middleware definition');
}; };
} }
private function hasRouteParams(string $path): bool
{
return str_contains($path, '{') && str_contains($path, '}');
}
/**
* @return array<string, string>|null
*/
private function matchPathParams(string $routePath, string $requestPath): ?array
{
$routeSegments = explode('/', trim($routePath, '/'));
$regexSegments = [];
foreach ($routeSegments as $segment) {
if (preg_match('/^\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/', $segment, $match) === 1) {
$regexSegments[] = '(?P<' . $match[1] . '>[^/]+)';
continue;
}
$regexSegments[] = preg_quote($segment, '#');
}
$pattern = '#^/' . implode('/', $regexSegments) . '$#';
if ($routePath === '/') {
$pattern = '#^/$#';
}
if (preg_match($pattern, $requestPath, $matches) !== 1) {
return null;
}
$params = [];
foreach ($matches as $key => $value) {
if (is_string($key)) {
$params[$key] = (string) $value;
}
}
return $params;
}
} }

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use PDO;
final class ChannelOffersRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listByIntegration(int $integrationId, int $limit = 100): array
{
$statement = $this->pdo->prepare(
'SELECT co.id, co.integration_id, co.channel_id,
co.external_product_id, co.external_variant_id, co.external_offer_id,
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
co.created_at, co.updated_at,
sc.code AS channel_code, sc.name AS channel_name
FROM channel_offers co
INNER JOIN sales_channels sc ON sc.id = co.channel_id
WHERE co.integration_id = :integration_id
ORDER BY co.last_seen_at DESC, co.id DESC
LIMIT :limit'
);
$statement->bindValue(':integration_id', $integrationId, PDO::PARAM_INT);
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapOfferRow'], $rows);
}
/**
* @return array<int, array<string, mixed>>
*/
public function search(
int $integrationId,
?int $channelId,
string $query,
int $limit = 50
): array {
$sql = 'SELECT co.id, co.integration_id, co.channel_id,
co.external_product_id, co.external_variant_id, co.external_offer_id,
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
co.created_at, co.updated_at,
sc.code AS channel_code, sc.name AS channel_name
FROM channel_offers co
INNER JOIN sales_channels sc ON sc.id = co.channel_id
WHERE co.integration_id = :integration_id';
$params = ['integration_id' => $integrationId];
if ($channelId !== null && $channelId > 0) {
$sql .= ' AND co.channel_id = :channel_id';
$params['channel_id'] = $channelId;
}
$needle = trim($query);
if ($needle !== '') {
$sql .= ' AND (
co.name LIKE :query_name
OR co.sku LIKE :query_sku
OR co.ean LIKE :query_ean
OR co.external_product_id LIKE :query_external_product_id
)';
$like = '%' . $needle . '%';
$params['query_name'] = $like;
$params['query_sku'] = $like;
$params['query_ean'] = $like;
$params['query_external_product_id'] = $like;
}
$sql .= ' ORDER BY co.last_seen_at DESC, co.id DESC LIMIT :limit';
$statement = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$statement->bindValue(':' . $key, $value);
}
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapOfferRow'], $rows);
}
public function upsertOffer(
int $integrationId,
int $channelId,
string $externalProductId,
?string $externalVariantId,
?string $externalOfferId,
string $name,
?string $sku,
?string $ean,
?float $priceBrutto,
?float $quantity,
?string $currency,
string $offerStatus,
?string $sourceUpdatedAt,
string $lastSeenAt,
?string $payloadJson
): void {
$statement = $this->pdo->prepare(
'INSERT INTO channel_offers (
integration_id, channel_id, external_product_id, external_variant_id, external_offer_id,
name, sku, ean, price_brutto, quantity, currency, offer_status,
source_updated_at, last_seen_at, payload_json, created_at, updated_at
) VALUES (
:integration_id, :channel_id, :external_product_id, :external_variant_id, :external_offer_id,
:name, :sku, :ean, :price_brutto, :quantity, :currency, :offer_status,
:source_updated_at, :last_seen_at, :payload_json, :created_at, :updated_at
) ON DUPLICATE KEY UPDATE
channel_id = VALUES(channel_id),
external_offer_id = VALUES(external_offer_id),
name = VALUES(name),
sku = VALUES(sku),
ean = VALUES(ean),
price_brutto = VALUES(price_brutto),
quantity = VALUES(quantity),
currency = VALUES(currency),
offer_status = VALUES(offer_status),
source_updated_at = VALUES(source_updated_at),
last_seen_at = VALUES(last_seen_at),
payload_json = VALUES(payload_json),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'integration_id' => $integrationId,
'channel_id' => $channelId,
'external_product_id' => trim($externalProductId),
'external_variant_id' => $this->nullableText($externalVariantId),
'external_offer_id' => $this->nullableText($externalOfferId),
'name' => trim($name),
'sku' => $this->nullableText($sku),
'ean' => $this->nullableText($ean),
'price_brutto' => $priceBrutto,
'quantity' => $quantity,
'currency' => $this->nullableText($currency),
'offer_status' => trim($offerStatus),
'source_updated_at' => $this->nullableText($sourceUpdatedAt),
'last_seen_at' => $lastSeenAt,
'payload_json' => $this->nullableJson($payloadJson),
'created_at' => $now,
'updated_at' => $now,
]);
}
/**
* @return array<string, mixed>|null
*/
public function findByExternalIdentity(
int $integrationId,
string $externalProductId,
?string $externalVariantId
): ?array {
$statement = $this->pdo->prepare(
'SELECT co.id, co.integration_id, co.channel_id,
co.external_product_id, co.external_variant_id, co.external_offer_id,
co.name, co.sku, co.ean, co.price_brutto, co.quantity, co.currency,
co.offer_status, co.source_updated_at, co.last_seen_at, co.payload_json,
co.created_at, co.updated_at,
sc.code AS channel_code, sc.name AS channel_name
FROM channel_offers co
INNER JOIN sales_channels sc ON sc.id = co.channel_id
WHERE co.integration_id = :integration_id
AND co.external_product_id = :external_product_id
AND (
(:external_variant_id_value IS NULL AND co.external_variant_id IS NULL)
OR co.external_variant_id = :external_variant_id_match
)
ORDER BY co.id DESC
LIMIT 1'
);
$statement->execute([
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id_value' => $this->nullableText($externalVariantId),
'external_variant_id_match' => $this->nullableText($externalVariantId),
]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapOfferRow($row);
}
private function nullableText(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function nullableJson(?string $payload): ?string
{
if ($payload === null) {
return null;
}
$trimmed = trim($payload);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapOfferRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'channel_id' => (int) ($row['channel_id'] ?? 0),
'external_product_id' => (string) ($row['external_product_id'] ?? ''),
'external_variant_id' => isset($row['external_variant_id']) ? (string) $row['external_variant_id'] : null,
'external_offer_id' => isset($row['external_offer_id']) ? (string) $row['external_offer_id'] : null,
'name' => (string) ($row['name'] ?? ''),
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
'ean' => isset($row['ean']) ? (string) $row['ean'] : null,
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
'quantity' => $row['quantity'] === null ? null : (float) $row['quantity'],
'currency' => isset($row['currency']) ? (string) $row['currency'] : null,
'offer_status' => (string) ($row['offer_status'] ?? 'active'),
'source_updated_at' => isset($row['source_updated_at']) ? (string) $row['source_updated_at'] : null,
'last_seen_at' => (string) ($row['last_seen_at'] ?? ''),
'payload_json' => isset($row['payload_json']) ? (string) $row['payload_json'] : null,
'channel_code' => isset($row['channel_code']) ? (string) $row['channel_code'] : '',
'channel_name' => isset($row['channel_name']) ? (string) $row['channel_name'] : '',
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
final class LinkMatcherService
{
/**
* @param array<string, mixed> $product
* @param array<string, mixed> $offer
* @return array{match_hint:string,confidence:int,link_type:string}
*/
public function match(array $product, array $offer): array
{
$productEan = trim((string) ($product['ean'] ?? ''));
$productSku = trim((string) ($product['sku'] ?? ''));
$offerEan = trim((string) ($offer['ean'] ?? ''));
$offerSku = trim((string) ($offer['sku'] ?? ''));
if ($productEan !== '' && $offerEan !== '' && $productEan === $offerEan) {
return [
'match_hint' => 'EAN exact',
'confidence' => 98,
'link_type' => 'auto_ean',
];
}
if ($productSku !== '' && $offerSku !== '' && mb_strtolower($productSku) === mb_strtolower($offerSku)) {
return [
'match_hint' => 'SKU exact',
'confidence' => 90,
'link_type' => 'auto_sku',
];
}
$normalizedProductSku = $this->normalizeSku($productSku);
$normalizedOfferSku = $this->normalizeSku($offerSku);
if ($normalizedProductSku !== '' && $normalizedProductSku === $normalizedOfferSku) {
return [
'match_hint' => 'SKU normalized',
'confidence' => 75,
'link_type' => 'auto_sku',
];
}
return [
'match_hint' => '',
'confidence' => 0,
'link_type' => 'manual',
];
}
private function normalizeSku(string $value): string
{
if ($value === '') {
return '';
}
$lower = mb_strtolower($value);
return preg_replace('/[\s\-_]+/u', '', $lower) ?? '';
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use App\Modules\Settings\ShopProClient;
use PDO;
use Throwable;
final class OfferImportService
{
public function __construct(
private readonly ShopProClient $shopProClient,
private readonly ChannelOffersRepository $offers,
private readonly PDO $pdo
) {
}
/**
* @param array<string, mixed> $credentials
* @return array{
* ok:bool,
* imported:int,
* failed:int,
* pages:int,
* message:string
* }
*/
public function importShopProOffers(array $credentials, int $perPage = 100, int $maxPages = 200): array
{
$integrationId = (int) ($credentials['id'] ?? 0);
$baseUrl = trim((string) ($credentials['base_url'] ?? ''));
$apiKey = trim((string) ($credentials['api_key'] ?? ''));
$timeout = (int) ($credentials['timeout_seconds'] ?? 10);
if ($integrationId <= 0) {
return [
'ok' => false,
'imported' => 0,
'failed' => 0,
'pages' => 0,
'message' => 'Niepoprawne ID integracji.',
];
}
if ($baseUrl === '' || $apiKey === '') {
return [
'ok' => false,
'imported' => 0,
'failed' => 0,
'pages' => 0,
'message' => 'Brak base_url lub api_key dla integracji.',
];
}
$channelId = $this->findChannelIdByCode('shoppro');
if ($channelId === null) {
return [
'ok' => false,
'imported' => 0,
'failed' => 0,
'pages' => 0,
'message' => 'Brak kanalu sprzedazy shoppro. Uruchom seeding kanalow.',
];
}
$imported = 0;
$failed = 0;
$pages = 0;
$errors = [];
$now = date('Y-m-d H:i:s');
$page = 1;
$safePerPage = max(1, min(100, $perPage));
$safeMaxPages = max(1, min(500, $maxPages));
while ($page <= $safeMaxPages) {
$listResult = $this->shopProClient->fetchProducts($baseUrl, $apiKey, $timeout, $page, $safePerPage);
if (($listResult['ok'] ?? false) !== true) {
return [
'ok' => false,
'imported' => $imported,
'failed' => $failed,
'pages' => $pages,
'message' => 'Blad pobierania listy produktow (strona ' . $page . '): ' . (string) ($listResult['message'] ?? ''),
];
}
$items = is_array($listResult['items'] ?? null) ? $listResult['items'] : [];
if ($items === []) {
break;
}
$pages++;
foreach ($items as $item) {
$mapped = $this->mapExternalItem($item, $now);
if ($mapped === null) {
$failed++;
if (count($errors) < 3) {
$errors[] = 'Pominieto rekord bez poprawnego ID produktu z API.';
}
continue;
}
try {
$this->offers->upsertOffer(
$integrationId,
$channelId,
(string) $mapped['external_product_id'],
$mapped['external_variant_id'] === null ? null : (string) $mapped['external_variant_id'],
$mapped['external_offer_id'] === null ? null : (string) $mapped['external_offer_id'],
(string) $mapped['name'],
$mapped['sku'] === null ? null : (string) $mapped['sku'],
$mapped['ean'] === null ? null : (string) $mapped['ean'],
$mapped['price_brutto'] === null ? null : (float) $mapped['price_brutto'],
$mapped['quantity'] === null ? null : (float) $mapped['quantity'],
$mapped['currency'] === null ? null : (string) $mapped['currency'],
(string) $mapped['offer_status'],
$mapped['source_updated_at'] === null ? null : (string) $mapped['source_updated_at'],
(string) $mapped['last_seen_at'],
(string) $mapped['payload_json']
);
$imported++;
} catch (Throwable $exception) {
$failed++;
if (count($errors) < 3) {
$errors[] = $exception->getMessage();
}
}
}
if (count($items) < $safePerPage) {
break;
}
$page++;
}
$message = '';
if ($errors !== []) {
$message = implode(' | ', $errors);
}
return [
'ok' => true,
'imported' => $imported,
'failed' => $failed,
'pages' => $pages,
'message' => $message,
];
}
private function findChannelIdByCode(string $code): ?int
{
$statement = $this->pdo->prepare('SELECT id FROM sales_channels WHERE code = :code LIMIT 1');
$statement->execute(['code' => trim($code)]);
$value = $statement->fetchColumn();
if ($value === false) {
return null;
}
return (int) $value;
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>|null
*/
private function mapExternalItem(array $item, string $lastSeenAt): ?array
{
$externalProductId = $this->nullableText($item['id'] ?? null);
if ($externalProductId === null) {
return null;
}
$name = $this->nullableText($item['name'] ?? null);
if ($name === null) {
$name = 'shopPRO #' . $externalProductId;
}
$payloadJson = json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($payloadJson === false) {
$payloadJson = '{}';
}
return [
'external_product_id' => $externalProductId,
'external_variant_id' => $this->nullableText($item['variant_id'] ?? $item['external_variant_id'] ?? null),
'external_offer_id' => $this->nullableText($item['offer_id'] ?? $item['external_offer_id'] ?? null),
'name' => $name,
'sku' => $this->nullableText($item['sku'] ?? null),
'ean' => $this->nullableText($item['ean'] ?? null),
'price_brutto' => $this->nullableFloat($item['price_brutto'] ?? $item['price'] ?? null),
'quantity' => $this->nullableFloat($item['quantity'] ?? null, 3),
'currency' => $this->nullableText($item['currency'] ?? null),
'offer_status' => ((int) ($item['status'] ?? 1)) === 1 ? 'active' : 'inactive',
'source_updated_at' => $this->nullableText($item['updated_at'] ?? $item['modified_at'] ?? null),
'last_seen_at' => $lastSeenAt,
'payload_json' => $payloadJson,
];
}
private function nullableText(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function nullableFloat(mixed $value, int $precision = 2): ?float
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return round((float) $text, $precision);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
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\Modules\Auth\AuthService;
final class ProductLinksController
{
public function __construct(
private readonly Translator $translator,
private readonly AuthService $auth,
private readonly ProductLinksService $service
) {
}
public function create(Request $request): Response
{
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
if (!$this->validateCsrf($request, $productId)) {
return Response::redirect($this->linksPagePath($productId));
}
$integrationId = $this->resolvePositiveInt($request, ['integration_id']);
$externalProductId = (string) $request->input('external_product_id', '');
$externalVariantId = $this->nullableText((string) $request->input('external_variant_id', ''));
$userId = $this->resolveUserId();
$result = $this->service->createManualLink(
$productId,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
if (($result['ok'] ?? false) !== true) {
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.link_failed')));
return Response::redirect($this->linksPagePath($productId));
}
Flash::set('product_links_success', $this->translator->get('products.links.flash.linked'));
return Response::redirect($this->linksPagePath($productId));
}
public function relink(Request $request): Response
{
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
if (!$this->validateCsrf($request, $productId)) {
return Response::redirect($this->linksPagePath($productId));
}
$mapId = $this->resolvePositiveInt($request, ['mapId', 'map_id']);
$integrationId = $this->resolvePositiveInt($request, ['integration_id']);
$externalProductId = (string) $request->input('external_product_id', '');
$externalVariantId = $this->nullableText((string) $request->input('external_variant_id', ''));
$userId = $this->resolveUserId();
$result = $this->service->relink(
$productId,
$mapId,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
if (($result['ok'] ?? false) !== true) {
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.relink_failed')));
return Response::redirect($this->linksPagePath($productId));
}
Flash::set('product_links_success', $this->translator->get('products.links.flash.relinked'));
return Response::redirect($this->linksPagePath($productId));
}
public function unlink(Request $request): Response
{
$productId = $this->resolvePositiveInt($request, ['id', 'product_id']);
if (!$this->validateCsrf($request, $productId)) {
return Response::redirect($this->linksPagePath($productId));
}
$mapId = $this->resolvePositiveInt($request, ['mapId', 'map_id']);
$userId = $this->resolveUserId();
$result = $this->service->unlink($productId, $mapId, $userId);
if (($result['ok'] ?? false) !== true) {
Flash::set('product_links_error', (string) ($result['message'] ?? $this->translator->get('products.links.flash.unlink_failed')));
return Response::redirect($this->linksPagePath($productId));
}
Flash::set('product_links_success', $this->translator->get('products.links.flash.unlinked'));
return Response::redirect($this->linksPagePath($productId));
}
private function validateCsrf(Request $request, int $productId): bool
{
$csrfToken = (string) $request->input('_token', '');
if (Csrf::validate($csrfToken)) {
return true;
}
Flash::set('product_links_error', $this->translator->get('auth.errors.csrf_expired'));
if ($productId > 0) {
Flash::set('products_error', $this->translator->get('auth.errors.csrf_expired'));
}
return false;
}
private function resolveUserId(): ?int
{
$user = $this->auth->user();
if (!is_array($user)) {
return null;
}
$id = (int) ($user['id'] ?? 0);
return $id > 0 ? $id : null;
}
private function nullableText(string $value): ?string
{
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
/**
* @param array<int, string> $keys
*/
private function resolvePositiveInt(Request $request, array $keys): int
{
foreach ($keys as $key) {
$value = max(0, (int) $request->input($key, 0));
if ($value > 0) {
return $value;
}
}
return 0;
}
private function linksPagePath(int $productId): string
{
if ($productId > 0) {
return '/products/' . $productId . '/links';
}
return '/products/links';
}
}

View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use PDO;
final class ProductLinksRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @return array<int, array<string, mixed>>
*/
public function listByProductId(int $productId): array
{
$statement = $this->pdo->prepare(
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
pcm.link_type, pcm.link_status, pcm.confidence,
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
sc.code AS channel_code, sc.name AS channel_name,
i.name AS integration_name
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN integrations i ON i.id = pcm.integration_id
WHERE pcm.product_id = :product_id
ORDER BY pcm.id DESC'
);
$statement->execute(['product_id' => $productId]);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapLinkRow'], $rows);
}
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
pcm.link_type, pcm.link_status, pcm.confidence,
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
sc.code AS channel_code, sc.name AS channel_name,
i.name AS integration_name
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN integrations i ON i.id = pcm.integration_id
WHERE pcm.id = :id
LIMIT 1'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapLinkRow($row);
}
public function findByProductAndIdentity(
int $productId,
int $integrationId,
string $externalProductId,
?string $externalVariantId
): ?array {
$statement = $this->pdo->prepare(
'SELECT pcm.id, pcm.product_id, pcm.channel_id, pcm.integration_id,
pcm.external_product_id, pcm.external_variant_id, pcm.sync_state,
pcm.link_type, pcm.link_status, pcm.confidence,
pcm.linked_at, pcm.linked_by_user_id, pcm.unlinked_at, pcm.unlinked_by_user_id,
pcm.sync_meta_json, pcm.last_sync_at, pcm.created_at, pcm.updated_at,
sc.code AS channel_code, sc.name AS channel_name,
i.name AS integration_name
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
LEFT JOIN integrations i ON i.id = pcm.integration_id
WHERE pcm.product_id = :product_id
AND pcm.integration_id = :integration_id
AND pcm.external_product_id = :external_product_id
AND (
(:external_variant_id_value IS NULL AND pcm.external_variant_id IS NULL)
OR pcm.external_variant_id = :external_variant_id_match
)
ORDER BY pcm.id DESC
LIMIT 1'
);
$statement->execute([
'product_id' => $productId,
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id_value' => $this->normalizeNullableText($externalVariantId),
'external_variant_id_match' => $this->normalizeNullableText($externalVariantId),
]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapLinkRow($row);
}
public function insertLink(
int $productId,
int $channelId,
?int $integrationId,
string $externalProductId,
?string $externalVariantId,
string $syncState,
string $linkType,
string $linkStatus,
?int $confidence,
?int $linkedByUserId,
?string $syncMetaJson
): int {
$now = date('Y-m-d H:i:s');
$statement = $this->pdo->prepare(
'INSERT INTO product_channel_map (
product_id, channel_id, integration_id, external_product_id, external_variant_id,
sync_state, link_type, link_status, confidence,
linked_at, linked_by_user_id, sync_meta_json, last_sync_at, created_at, updated_at
) VALUES (
:product_id, :channel_id, :integration_id, :external_product_id, :external_variant_id,
:sync_state, :link_type, :link_status, :confidence,
:linked_at, :linked_by_user_id, :sync_meta_json, :last_sync_at, :created_at, :updated_at
)'
);
$statement->execute([
'product_id' => $productId,
'channel_id' => $channelId,
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id' => $this->normalizeNullableText($externalVariantId),
'sync_state' => trim($syncState),
'link_type' => trim($linkType),
'link_status' => trim($linkStatus),
'confidence' => $confidence,
'linked_at' => $now,
'linked_by_user_id' => $linkedByUserId,
'sync_meta_json' => $this->normalizeJson($syncMetaJson),
'last_sync_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->pdo->lastInsertId();
}
public function updateLink(
int $mapId,
?int $integrationId,
string $externalProductId,
?string $externalVariantId,
string $syncState,
string $linkType,
string $linkStatus,
?int $confidence,
?int $linkedByUserId,
?string $syncMetaJson
): void {
$statement = $this->pdo->prepare(
'UPDATE product_channel_map SET
integration_id = :integration_id,
external_product_id = :external_product_id,
external_variant_id = :external_variant_id,
sync_state = :sync_state,
link_type = :link_type,
link_status = :link_status,
confidence = :confidence,
linked_at = :linked_at,
linked_by_user_id = :linked_by_user_id,
unlinked_at = NULL,
unlinked_by_user_id = NULL,
sync_meta_json = :sync_meta_json,
last_sync_at = :last_sync_at,
updated_at = :updated_at
WHERE id = :id'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'id' => $mapId,
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id' => $this->normalizeNullableText($externalVariantId),
'sync_state' => trim($syncState),
'link_type' => trim($linkType),
'link_status' => trim($linkStatus),
'confidence' => $confidence,
'linked_at' => $now,
'linked_by_user_id' => $linkedByUserId,
'sync_meta_json' => $this->normalizeJson($syncMetaJson),
'last_sync_at' => $now,
'updated_at' => $now,
]);
}
public function markAsUnlinked(
int $mapId,
?int $userId,
string $linkStatus = 'inactive',
string $syncState = 'unlinked'
): void
{
$statement = $this->pdo->prepare(
'UPDATE product_channel_map SET
sync_state = :sync_state,
link_status = :link_status,
unlinked_at = :unlinked_at,
unlinked_by_user_id = :unlinked_by_user_id,
updated_at = :updated_at
WHERE id = :id'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'id' => $mapId,
'sync_state' => trim($syncState),
'link_status' => trim($linkStatus),
'unlinked_at' => $now,
'unlinked_by_user_id' => $userId,
'updated_at' => $now,
]);
}
public function deleteById(int $id): bool
{
$statement = $this->pdo->prepare('DELETE FROM product_channel_map WHERE id = :id LIMIT 1');
$statement->execute(['id' => $id]);
return $statement->rowCount() > 0;
}
/**
* @return array<int, array<string, mixed>>
*/
public function findActiveByExternalIdentity(
int $integrationId,
string $externalProductId,
?string $externalVariantId
): array {
$statement = $this->pdo->prepare(
'SELECT id, product_id, channel_id, integration_id, external_product_id, external_variant_id,
sync_state, link_type, link_status, confidence, linked_at, linked_by_user_id,
unlinked_at, unlinked_by_user_id, sync_meta_json, last_sync_at, created_at, updated_at
FROM product_channel_map
WHERE integration_id = :integration_id
AND external_product_id = :external_product_id
AND (
(:external_variant_id_value IS NULL AND external_variant_id IS NULL)
OR external_variant_id = :external_variant_id_match
)
AND link_status = :link_status
ORDER BY id DESC'
);
$statement->execute([
'integration_id' => $integrationId,
'external_product_id' => trim($externalProductId),
'external_variant_id_value' => $this->normalizeNullableText($externalVariantId),
'external_variant_id_match' => $this->normalizeNullableText($externalVariantId),
'link_status' => 'active',
]);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapLinkRow'], $rows);
}
public function logEvent(
int $productChannelMapId,
string $eventType,
?array $before,
?array $after,
?int $createdByUserId
): int {
$statement = $this->pdo->prepare(
'INSERT INTO product_link_events (
product_channel_map_id, event_type, before_json, after_json, created_by_user_id, created_at
) VALUES (
:product_channel_map_id, :event_type, :before_json, :after_json, :created_by_user_id, :created_at
)'
);
$beforeJson = $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$afterJson = $after === null ? null : json_encode($after, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$statement->execute([
'product_channel_map_id' => $productChannelMapId,
'event_type' => trim($eventType),
'before_json' => $beforeJson === false ? null : $beforeJson,
'after_json' => $afterJson === false ? null : $afterJson,
'created_by_user_id' => $createdByUserId,
'created_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->pdo->lastInsertId();
}
/**
* @return array<int, array<string, mixed>>
*/
public function listEventsByMapId(int $productChannelMapId, int $limit = 50): array
{
$statement = $this->pdo->prepare(
'SELECT id, product_channel_map_id, event_type, before_json, after_json, created_by_user_id, created_at
FROM product_link_events
WHERE product_channel_map_id = :product_channel_map_id
ORDER BY id DESC
LIMIT :limit'
);
$statement->bindValue(':product_channel_map_id', $productChannelMapId, PDO::PARAM_INT);
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapEventRow'], $rows);
}
private function normalizeNullableText(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
private function normalizeJson(?string $json): ?string
{
if ($json === null) {
return null;
}
$trimmed = trim($json);
if ($trimmed === '') {
return null;
}
return $trimmed;
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapLinkRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'channel_id' => (int) ($row['channel_id'] ?? 0),
'integration_id' => $row['integration_id'] === null ? null : (int) $row['integration_id'],
'external_product_id' => (string) ($row['external_product_id'] ?? ''),
'external_variant_id' => isset($row['external_variant_id']) ? (string) $row['external_variant_id'] : null,
'sync_state' => (string) ($row['sync_state'] ?? ''),
'link_type' => (string) ($row['link_type'] ?? 'manual'),
'link_status' => (string) ($row['link_status'] ?? 'active'),
'confidence' => $row['confidence'] === null ? null : (int) $row['confidence'],
'linked_at' => isset($row['linked_at']) ? (string) $row['linked_at'] : null,
'linked_by_user_id' => $row['linked_by_user_id'] === null ? null : (int) $row['linked_by_user_id'],
'unlinked_at' => isset($row['unlinked_at']) ? (string) $row['unlinked_at'] : null,
'unlinked_by_user_id' => $row['unlinked_by_user_id'] === null ? null : (int) $row['unlinked_by_user_id'],
'sync_meta_json' => isset($row['sync_meta_json']) ? (string) $row['sync_meta_json'] : null,
'last_sync_at' => isset($row['last_sync_at']) ? (string) $row['last_sync_at'] : null,
'channel_code' => isset($row['channel_code']) ? (string) $row['channel_code'] : '',
'channel_name' => isset($row['channel_name']) ? (string) $row['channel_name'] : '',
'integration_name' => isset($row['integration_name']) ? (string) $row['integration_name'] : '',
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapEventRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'product_channel_map_id' => (int) ($row['product_channel_map_id'] ?? 0),
'event_type' => (string) ($row['event_type'] ?? ''),
'before_json' => isset($row['before_json']) ? (string) $row['before_json'] : null,
'after_json' => isset($row['after_json']) ? (string) $row['after_json'] : null,
'created_by_user_id' => $row['created_by_user_id'] === null ? null : (int) $row['created_by_user_id'],
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
}

View File

@@ -0,0 +1,455 @@
<?php
declare(strict_types=1);
namespace App\Modules\ProductLinks;
use App\Modules\Settings\IntegrationRepository;
use PDO;
use Throwable;
final class ProductLinksService
{
public function __construct(
private readonly ProductLinksRepository $links,
private readonly ChannelOffersRepository $offers,
private readonly IntegrationRepository $integrations,
private readonly LinkMatcherService $matcher,
private readonly PDO $pdo
) {
}
/**
* @param array<string, mixed> $product
* @return array{
* links:array<int, array<string, mixed>>,
* link_events_by_map:array<int, array<int, array<string, mixed>>>,
* integrations:array<int, array<string, mixed>>,
* selected_integration_id:int,
* search_query:string,
* offers:array<int, array<string, mixed>>
* }
*/
public function buildProductLinksViewData(
int $productId,
array $product,
int $selectedIntegrationId,
string $searchQuery
): array {
$links = array_values(array_filter(
$this->links->listByProductId($productId),
static function (array $link): bool {
$integrationId = (int) ($link['integration_id'] ?? 0);
return $integrationId > 0;
}
));
$linkEventsByMap = [];
foreach ($links as $link) {
$mapId = (int) ($link['id'] ?? 0);
if ($mapId <= 0) {
continue;
}
$linkEventsByMap[$mapId] = $this->links->listEventsByMapId($mapId, 10);
}
$integrations = array_values(array_filter(
$this->integrations->listByType('shoppro'),
static fn (array $row): bool => ($row['is_active'] ?? false) === true
&& ($row['has_api_key'] ?? false) === true
));
$effectiveIntegrationId = $selectedIntegrationId;
if ($effectiveIntegrationId <= 0 && $integrations !== []) {
$effectiveIntegrationId = (int) ($integrations[0]['id'] ?? 0);
}
$query = trim($searchQuery);
$offers = [];
if ($effectiveIntegrationId > 0) {
if ($query !== '') {
$offers = $this->offers->search($effectiveIntegrationId, null, $query, 30);
} else {
$offers = $this->offerSuggestions($effectiveIntegrationId, $product);
}
}
return [
'links' => $links,
'link_events_by_map' => $linkEventsByMap,
'integrations' => $integrations,
'selected_integration_id' => $effectiveIntegrationId,
'search_query' => $query,
'offers' => $this->decorateOffersWithMatchHint($offers, $product),
];
}
/**
* @return array{ok:bool,conflict:bool,message:string}
*/
public function createManualLink(
int $productId,
int $integrationId,
string $externalProductId,
?string $externalVariantId,
?int $userId
): array {
return $this->upsertManualLink(
$productId,
null,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
}
/**
* @return array{ok:bool,conflict:bool,message:string}
*/
public function relink(
int $productId,
int $mapId,
int $integrationId,
string $externalProductId,
?string $externalVariantId,
?int $userId
): array {
$existingMap = $this->links->findById($mapId);
if ($existingMap === null || (int) ($existingMap['product_id'] ?? 0) !== $productId) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Nie znaleziono wskazanego powiazania produktu.',
];
}
return $this->upsertManualLink(
$productId,
$mapId,
$integrationId,
$externalProductId,
$externalVariantId,
$userId
);
}
/**
* @return array{ok:bool,message:string}
*/
public function unlink(int $productId, int $mapId, ?int $userId): array
{
$existingMap = $this->links->findById($mapId);
if ($existingMap === null || (int) ($existingMap['product_id'] ?? 0) !== $productId) {
return [
'ok' => false,
'message' => 'Nie znaleziono wskazanego powiazania produktu.',
];
}
try {
$this->pdo->beginTransaction();
$this->links->markAsUnlinked($mapId, $userId, 'inactive', 'unlinked');
$after = $this->links->findById($mapId);
if ($after === null) {
throw new \RuntimeException('Nie udalo sie odlaczyc wskazanego powiazania.');
}
$this->links->logEvent($mapId, 'unlinked', $existingMap, $after, $userId);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return [
'ok' => false,
'message' => $exception->getMessage(),
];
}
return [
'ok' => true,
'message' => '',
];
}
/**
* @return array{ok:bool,conflict:bool,message:string}
*/
private function upsertManualLink(
int $productId,
?int $mapId,
int $integrationId,
string $externalProductId,
?string $externalVariantId,
?int $userId
): array {
$normalizedExternalProductId = trim($externalProductId);
$normalizedExternalVariantId = $this->nullableText($externalVariantId);
if ($integrationId <= 0) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Wybierz poprawna integracje.',
];
}
if ($normalizedExternalProductId === '') {
return [
'ok' => false,
'conflict' => false,
'message' => 'Podaj poprawne external_product_id.',
];
}
$offer = $this->offers->findByExternalIdentity(
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId
);
if ($offer === null) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Nie znaleziono oferty w lokalnym cache. Najpierw wykonaj import ofert.',
];
}
$channelId = (int) ($offer['channel_id'] ?? 0);
if ($channelId <= 0) {
return [
'ok' => false,
'conflict' => false,
'message' => 'Oferta ma niepoprawny channel_id.',
];
}
$syncMetaJson = json_encode([
'offer_name' => (string) ($offer['name'] ?? ''),
'external_offer_id' => $offer['external_offer_id'] ?? null,
'offer_status' => (string) ($offer['offer_status'] ?? ''),
'source' => 'channel_offers',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($syncMetaJson === false) {
$syncMetaJson = null;
}
$activeMatches = $this->links->findActiveByExternalIdentity(
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId
);
$conflictingActive = null;
foreach ($activeMatches as $match) {
if ($mapId !== null && (int) ($match['id'] ?? 0) === $mapId) {
continue;
}
if ((int) ($match['product_id'] ?? 0) !== $productId) {
$conflictingActive = $match;
break;
}
}
$targetMap = $mapId === null
? $this->links->findByProductAndIdentity(
$productId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId
)
: $this->links->findById($mapId);
try {
$this->pdo->beginTransaction();
if ($conflictingActive !== null) {
$targetMapId = $targetMap === null
? $this->links->insertLink(
$productId,
$channelId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'conflict',
'manual',
'conflict',
null,
$userId,
$syncMetaJson
)
: (int) ($targetMap['id'] ?? 0);
if ($targetMap !== null) {
$this->links->updateLink(
$targetMapId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'conflict',
'manual',
'conflict',
null,
$userId,
$syncMetaJson
);
}
$afterConflict = $this->links->findById($targetMapId);
$this->links->logEvent(
$targetMapId,
'conflict_detected',
$targetMap,
$afterConflict,
$userId
);
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
return [
'ok' => false,
'conflict' => true,
'message' => 'Wykryto konflikt: oferta jest aktywnie powiazana z innym produktem.',
];
}
if ($targetMap === null) {
$targetMapId = $this->links->insertLink(
$productId,
$channelId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'linked',
'manual',
'active',
null,
$userId,
$syncMetaJson
);
$after = $this->links->findById($targetMapId);
$this->links->logEvent($targetMapId, 'linked', null, $after, $userId);
} else {
$targetMapId = (int) ($targetMap['id'] ?? 0);
$this->links->updateLink(
$targetMapId,
$integrationId,
$normalizedExternalProductId,
$normalizedExternalVariantId,
'linked',
'manual',
'active',
null,
$userId,
$syncMetaJson
);
$after = $this->links->findById($targetMapId);
$eventType = $mapId === null ? 'linked' : 'relinked';
$this->links->logEvent($targetMapId, $eventType, $targetMap, $after, $userId);
}
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return [
'ok' => false,
'conflict' => false,
'message' => $exception->getMessage(),
];
}
return [
'ok' => true,
'conflict' => false,
'message' => '',
];
}
/**
* @param array<string, mixed> $product
* @return array<int, array<string, mixed>>
*/
private function offerSuggestions(int $integrationId, array $product): array
{
$sku = trim((string) ($product['sku'] ?? ''));
$ean = trim((string) ($product['ean'] ?? ''));
$combined = [];
if ($ean !== '') {
foreach ($this->offers->search($integrationId, null, $ean, 15) as $row) {
$combined[$this->offerKey($row)] = $row;
}
}
if ($sku !== '') {
foreach ($this->offers->search($integrationId, null, $sku, 15) as $row) {
$combined[$this->offerKey($row)] = $row;
}
$normalizedSku = preg_replace('/[\s\-_]+/u', '', $sku) ?? '';
if ($normalizedSku !== '' && $normalizedSku !== $sku) {
foreach ($this->offers->search($integrationId, null, $normalizedSku, 15) as $row) {
$combined[$this->offerKey($row)] = $row;
}
}
}
$offers = $this->decorateOffersWithMatchHint(array_values($combined), $product);
usort($offers, static function (array $a, array $b): int {
$left = (int) ($a['match_confidence'] ?? 0);
$right = (int) ($b['match_confidence'] ?? 0);
if ($left === $right) {
return strcmp((string) ($b['last_seen_at'] ?? ''), (string) ($a['last_seen_at'] ?? ''));
}
return $right <=> $left;
});
return $offers;
}
/**
* @param array<int, array<string, mixed>> $offers
* @param array<string, mixed> $product
* @return array<int, array<string, mixed>>
*/
private function decorateOffersWithMatchHint(array $offers, array $product): array
{
return array_map(function (array $offer) use ($product): array {
$match = $this->matcher->match($product, $offer);
$offer['match_hint'] = (string) ($match['match_hint'] ?? '');
$offer['match_confidence'] = (int) ($match['confidence'] ?? 0);
$offer['suggested_link_type'] = (string) ($match['link_type'] ?? 'manual');
return $offer;
}, $offers);
}
/**
* @param array<string, mixed> $offer
*/
private function offerKey(array $offer): string
{
return (string) ($offer['integration_id'] ?? 0)
. '|'
. (string) ($offer['external_product_id'] ?? '')
. '|'
. (string) ($offer['external_variant_id'] ?? '');
}
private function nullableText(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed === '' ? null : $trimmed;
}
}

View File

@@ -0,0 +1,628 @@
<?php
declare(strict_types=1);
namespace App\Modules\Products;
use PDO;
final class ProductRepository
{
public function __construct(private readonly PDO $pdo)
{
}
/**
* @param array<string, mixed> $filters
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
*/
public function paginate(array $filters, string $lang = 'pl'): array
{
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
[$whereSql, $params] = $this->buildFilters($filters);
$sort = $this->resolveSort((string) ($filters['sort'] ?? 'id'));
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$countStmt = $this->pdo->prepare(
'SELECT COUNT(*)
FROM products p
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_count
' . $whereSql
);
$countStmt->execute(array_merge(['lang_count' => $lang], $params));
$total = (int) $countStmt->fetchColumn();
$listStmt = $this->pdo->prepare(
'SELECT p.id, p.type, p.sku, p.ean, p.status, p.promoted, p.price_brutto, p.quantity, p.updated_at,
COALESCE(pt.name, "") AS name,
(
SELECT pi.storage_path
FROM product_images pi
WHERE pi.product_id = p.id
ORDER BY pi.is_main DESC, pi.sort_order ASC, pi.id ASC
LIMIT 1
) AS main_image_path
FROM products p
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang_list
' . $whereSql . '
ORDER BY ' . $sort . ' ' . $sortDir . '
LIMIT :limit OFFSET :offset'
);
foreach (array_merge(['lang_list' => $lang], $params) as $key => $value) {
$listStmt->bindValue(':' . $key, $value);
}
$listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$listStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$listStmt->execute();
$rows = $listStmt->fetchAll();
if (!is_array($rows)) {
$rows = [];
}
return [
'items' => array_map([$this, 'mapListRow'], $rows),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id, string $lang = 'pl'): ?array
{
$stmt = $this->pdo->prepare(
'SELECT p.*, pt.name, pt.short_description, pt.description, pt.meta_title,
pt.meta_description, pt.meta_keywords, pt.seo_link
FROM products p
LEFT JOIN product_translations pt ON pt.product_id = p.id AND pt.lang = :lang
WHERE p.id = :id
LIMIT 1'
);
$stmt->execute([
'id' => $id,
'lang' => $lang,
]);
$row = $stmt->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapDetailsRow($row);
}
public function existsSku(string $sku, ?int $excludeProductId = null): bool
{
$normalized = trim($sku);
if ($normalized === '') {
return false;
}
$sql = 'SELECT 1 FROM products WHERE sku = :sku';
$params = ['sku' => $normalized];
if ($excludeProductId !== null) {
$sql .= ' AND id <> :exclude_id';
$params['exclude_id'] = $excludeProductId;
}
$sql .= ' LIMIT 1';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn() !== false;
}
public function findIdBySku(string $sku): ?int
{
$normalized = trim($sku);
if ($normalized === '') {
return null;
}
$stmt = $this->pdo->prepare('SELECT id FROM products WHERE sku = :sku LIMIT 1');
$stmt->execute(['sku' => $normalized]);
$value = $stmt->fetchColumn();
return $value === false ? null : (int) $value;
}
public function findIdByEan(string $ean): ?int
{
$normalized = trim($ean);
if ($normalized === '') {
return null;
}
$stmt = $this->pdo->prepare('SELECT id FROM products WHERE ean = :ean LIMIT 1');
$stmt->execute(['ean' => $normalized]);
$value = $stmt->fetchColumn();
return $value === false ? null : (int) $value;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $translation
*/
public function create(array $payload, array $translation): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO products (
uuid, type, sku, ean, status, promoted, vat, weight,
price_brutto, price_brutto_promo, price_netto, price_netto_promo,
quantity, producer_id, product_unit_id, created_at, updated_at
) VALUES (
:uuid, :type, :sku, :ean, :status, :promoted, :vat, :weight,
:price_brutto, :price_brutto_promo, :price_netto, :price_netto_promo,
:quantity, :producer_id, :product_unit_id, :created_at, :updated_at
)'
);
$stmt->execute($payload);
$productId = (int) $this->pdo->lastInsertId();
$translationStmt = $this->pdo->prepare(
'INSERT INTO product_translations (
product_id, lang, name, short_description, description,
meta_title, meta_description, meta_keywords, seo_link, created_at, updated_at
) VALUES (
:product_id, :lang, :name, :short_description, :description,
:meta_title, :meta_description, :meta_keywords, :seo_link, :created_at, :updated_at
)'
);
$translationStmt->execute(array_merge(['product_id' => $productId], $translation));
return $productId;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $translation
*/
public function update(int $id, array $payload, array $translation): void
{
$stmt = $this->pdo->prepare(
'UPDATE products SET
type = :type,
sku = :sku,
ean = :ean,
status = :status,
promoted = :promoted,
vat = :vat,
weight = :weight,
price_brutto = :price_brutto,
price_brutto_promo = :price_brutto_promo,
price_netto = :price_netto,
price_netto_promo = :price_netto_promo,
quantity = :quantity,
producer_id = :producer_id,
product_unit_id = :product_unit_id,
updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute(array_merge($payload, ['id' => $id]));
$translationUpsert = $this->pdo->prepare(
'INSERT INTO product_translations (
product_id, lang, name, short_description, description,
meta_title, meta_description, meta_keywords, seo_link, created_at, updated_at
) VALUES (
:product_id, :lang, :name, :short_description, :description,
:meta_title, :meta_description, :meta_keywords, :seo_link, :created_at, :updated_at
) ON DUPLICATE KEY UPDATE
name = VALUES(name),
short_description = VALUES(short_description),
description = VALUES(description),
meta_title = VALUES(meta_title),
meta_description = VALUES(meta_description),
meta_keywords = VALUES(meta_keywords),
seo_link = VALUES(seo_link),
updated_at = VALUES(updated_at)'
);
$translationUpsert->execute(array_merge(['product_id' => $id], $translation));
}
public function deleteById(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM products WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $id]);
return $stmt->rowCount() > 0;
}
/**
* @return array<int, array<string, mixed>>
*/
public function findImagesByProductId(int $productId): array
{
$stmt = $this->pdo->prepare(
'SELECT id, product_id, storage_path, alt, sort_order, is_main, created_at, updated_at
FROM product_images
WHERE product_id = :product_id
ORDER BY is_main DESC, sort_order ASC, id ASC'
);
$stmt->execute(['product_id' => $productId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(static function (array $row): array {
return [
'id' => (int) ($row['id'] ?? 0),
'product_id' => (int) ($row['product_id'] ?? 0),
'storage_path' => (string) ($row['storage_path'] ?? ''),
'alt' => isset($row['alt']) ? (string) $row['alt'] : null,
'sort_order' => (int) ($row['sort_order'] ?? 0),
'is_main' => (int) ($row['is_main'] ?? 0),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}, $rows);
}
public function existsVariantSku(string $sku, ?int $excludeProductId = null): bool
{
$normalized = trim($sku);
if ($normalized === '') {
return false;
}
$sql = 'SELECT 1
FROM product_variants pv
INNER JOIN products p ON p.id = pv.product_id
WHERE pv.sku = :sku
AND p.deleted_at IS NULL';
$params = ['sku' => $normalized];
if ($excludeProductId !== null && $excludeProductId > 0) {
$sql .= ' AND pv.product_id <> :exclude_product_id';
$params['exclude_product_id'] = $excludeProductId;
}
$sql .= ' LIMIT 1';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn() !== false;
}
/**
* @return array<int, array<string, mixed>>
*/
public function findVariantsByProductId(int $productId, string $lang = 'pl'): array
{
$variantStmt = $this->pdo->prepare(
'SELECT id, product_id, permutation_hash, sku, ean, status,
price_brutto, price_brutto_promo, price_netto, price_netto_promo, weight,
created_at, updated_at
FROM product_variants
WHERE product_id = :product_id
ORDER BY id ASC'
);
$variantStmt->execute(['product_id' => $productId]);
$variantRows = $variantStmt->fetchAll();
if (!is_array($variantRows) || $variantRows === []) {
return [];
}
$variants = [];
foreach ($variantRows as $row) {
if (!is_array($row)) {
continue;
}
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
$variants[$id] = [
'id' => $id,
'product_id' => (int) ($row['product_id'] ?? 0),
'permutation_hash' => (string) ($row['permutation_hash'] ?? ''),
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
'ean' => isset($row['ean']) ? (string) $row['ean'] : null,
'status' => (int) ($row['status'] ?? 0),
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
'price_brutto_promo' => $row['price_brutto_promo'] === null ? null : (float) $row['price_brutto_promo'],
'price_netto' => $row['price_netto'] === null ? null : (float) $row['price_netto'],
'price_netto_promo' => $row['price_netto_promo'] === null ? null : (float) $row['price_netto_promo'],
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
'attributes' => [],
];
}
if ($variants === []) {
return [];
}
$attributeStmt = $this->pdo->prepare(
'SELECT pva.variant_id, pva.attribute_id, pva.value_id,
COALESCE(at.name, CONCAT("Atrybut #", pva.attribute_id)) AS attribute_name,
COALESCE(avt.name, CONCAT("Wartosc #", pva.value_id)) AS value_name
FROM product_variant_attributes pva
LEFT JOIN attribute_translations at ON at.attribute_id = pva.attribute_id AND at.lang = :lang_attr
LEFT JOIN attribute_value_translations avt ON avt.value_id = pva.value_id AND avt.lang = :lang_value
WHERE pva.variant_id IN (' . implode(',', array_map('intval', array_keys($variants))) . ')
ORDER BY pva.variant_id ASC, pva.attribute_id ASC'
);
$attributeStmt->execute([
'lang_attr' => $lang,
'lang_value' => $lang,
]);
$attributeRows = $attributeStmt->fetchAll();
if (is_array($attributeRows)) {
foreach ($attributeRows as $row) {
if (!is_array($row)) {
continue;
}
$variantId = (int) ($row['variant_id'] ?? 0);
if ($variantId <= 0 || !isset($variants[$variantId])) {
continue;
}
$variants[$variantId]['attributes'][] = [
'attribute_id' => (int) ($row['attribute_id'] ?? 0),
'value_id' => (int) ($row['value_id'] ?? 0),
'attribute_name' => (string) ($row['attribute_name'] ?? ''),
'value_name' => (string) ($row['value_name'] ?? ''),
];
}
}
return array_values($variants);
}
/**
* @return array<string, mixed>|null
*/
public function findLatestImportWarning(int $productId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT change_type, after_json, created_at
FROM product_change_log
WHERE product_id = :product_id
AND change_type IN ("product_import_warning", "product_import_warning_clear")
ORDER BY id DESC
LIMIT 1'
);
$stmt->execute(['product_id' => $productId]);
$row = $stmt->fetch();
if (!is_array($row)) {
return null;
}
if ((string) ($row['change_type'] ?? '') === 'product_import_warning_clear') {
return null;
}
$payloadRaw = $row['after_json'] ?? null;
$payload = null;
if (is_string($payloadRaw) && trim($payloadRaw) !== '') {
$decoded = json_decode($payloadRaw, true);
if (is_array($decoded)) {
$payload = $decoded;
}
} elseif (is_array($payloadRaw)) {
$payload = $payloadRaw;
}
if (!is_array($payload)) {
return null;
}
return [
'created_at' => (string) ($row['created_at'] ?? ''),
'title' => (string) ($payload['title'] ?? ''),
'messages' => array_values(array_filter(
(array) ($payload['messages'] ?? []),
static fn (mixed $item): bool => is_string($item) && trim($item) !== ''
)),
];
}
public function createImage(int $productId, string $storagePath, ?string $alt, int $sortOrder, int $isMain): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO product_images (product_id, storage_path, alt, sort_order, is_main, created_at, updated_at)
VALUES (:product_id, :storage_path, :alt, :sort_order, :is_main, :created_at, :updated_at)'
);
$now = date('Y-m-d H:i:s');
$stmt->execute([
'product_id' => $productId,
'storage_path' => trim($storagePath),
'alt' => $alt,
'sort_order' => $sortOrder,
'is_main' => $isMain === 1 ? 1 : 0,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->pdo->lastInsertId();
}
public function deleteImageById(int $productId, int $imageId): ?string
{
$find = $this->pdo->prepare(
'SELECT storage_path
FROM product_images
WHERE id = :id AND product_id = :product_id
LIMIT 1'
);
$find->execute(['id' => $imageId, 'product_id' => $productId]);
$path = $find->fetchColumn();
if (!is_string($path) || trim($path) === '') {
return null;
}
$delete = $this->pdo->prepare(
'DELETE FROM product_images
WHERE id = :id AND product_id = :product_id
LIMIT 1'
);
$delete->execute(['id' => $imageId, 'product_id' => $productId]);
if ($delete->rowCount() <= 0) {
return null;
}
return trim($path);
}
public function setMainImage(int $productId, int $imageId): void
{
$reset = $this->pdo->prepare('UPDATE product_images SET is_main = 0 WHERE product_id = :product_id');
$reset->execute(['product_id' => $productId]);
$setMain = $this->pdo->prepare(
'UPDATE product_images
SET is_main = 1
WHERE product_id = :product_id AND id = :image_id
LIMIT 1'
);
$setMain->execute([
'product_id' => $productId,
'image_id' => $imageId,
]);
}
/**
* @param array<string, mixed>|null $before
* @param array<string, mixed>|null $after
*/
public function logChange(int $productId, ?int $userId, string $changeType, ?array $before, ?array $after): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO product_change_log (product_id, user_id, change_type, before_json, after_json, created_at)
VALUES (:product_id, :user_id, :change_type, :before_json, :after_json, :created_at)'
);
$beforeJson = $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$afterJson = $after === null ? null : json_encode($after, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$stmt->execute([
'product_id' => $productId,
'user_id' => $userId,
'change_type' => $changeType,
'before_json' => $beforeJson === false ? null : $beforeJson,
'after_json' => $afterJson === false ? null : $afterJson,
'created_at' => date('Y-m-d H:i:s'),
]);
}
/**
* @param array<string, mixed> $filters
* @return array{0:string,1:array<string,mixed>}
*/
private function buildFilters(array $filters): array
{
$where = ['p.deleted_at IS NULL'];
$params = [];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(pt.name LIKE :search OR p.sku LIKE :search OR p.ean LIKE :search)';
$params['search'] = '%' . $search . '%';
}
$status = (string) ($filters['status'] ?? '');
if ($status !== '' && in_array($status, ['0', '1'], true)) {
$where[] = 'p.status = :status';
$params['status'] = (int) $status;
}
$type = (string) ($filters['type'] ?? '');
if ($type !== '' && in_array($type, ['simple', 'variant_parent'], true)) {
$where[] = 'p.type = :type';
$params['type'] = $type;
}
$whereSql = 'WHERE ' . implode(' AND ', $where);
return [$whereSql, $params];
}
private function resolveSort(string $sort): string
{
return match ($sort) {
'name' => 'pt.name',
'sku' => 'p.sku',
'price_brutto' => 'p.price_brutto',
'quantity' => 'p.quantity',
'status' => 'p.status',
'updated_at' => 'p.updated_at',
default => 'p.id',
};
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapListRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'main_image_path' => (string) ($row['main_image_path'] ?? ''),
'type' => (string) ($row['type'] ?? 'simple'),
'sku' => (string) ($row['sku'] ?? ''),
'ean' => (string) ($row['ean'] ?? ''),
'status' => (int) ($row['status'] ?? 0),
'promoted' => (int) ($row['promoted'] ?? 0),
'price_brutto' => (float) ($row['price_brutto'] ?? 0),
'quantity' => (float) ($row['quantity'] ?? 0),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapDetailsRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'uuid' => (string) ($row['uuid'] ?? ''),
'type' => (string) ($row['type'] ?? 'simple'),
'sku' => (string) ($row['sku'] ?? ''),
'ean' => (string) ($row['ean'] ?? ''),
'status' => (int) ($row['status'] ?? 1),
'promoted' => (int) ($row['promoted'] ?? 0),
'vat' => $row['vat'] === null ? null : (float) $row['vat'],
'weight' => $row['weight'] === null ? null : (float) $row['weight'],
'price_brutto' => $row['price_brutto'] === null ? null : (float) $row['price_brutto'],
'price_brutto_promo' => $row['price_brutto_promo'] === null ? null : (float) $row['price_brutto_promo'],
'price_netto' => $row['price_netto'] === null ? null : (float) $row['price_netto'],
'price_netto_promo' => $row['price_netto_promo'] === null ? null : (float) $row['price_netto_promo'],
'quantity' => (float) ($row['quantity'] ?? 0),
'producer_id' => $row['producer_id'] === null ? null : (int) $row['producer_id'],
'product_unit_id' => $row['product_unit_id'] === null ? null : (int) $row['product_unit_id'],
'name' => (string) ($row['name'] ?? ''),
'short_description' => (string) ($row['short_description'] ?? ''),
'description' => (string) ($row['description'] ?? ''),
'meta_title' => (string) ($row['meta_title'] ?? ''),
'meta_description' => (string) ($row['meta_description'] ?? ''),
'meta_keywords' => (string) ($row['meta_keywords'] ?? ''),
'seo_link' => (string) ($row['seo_link'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
}

View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace App\Modules\Products;
use PDO;
use Throwable;
final class ProductService
{
public function __construct(
private readonly PDO $pdo,
private readonly ProductRepository $products,
private readonly ProductValidator $validator
) {
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed>|null $actor
* @return array{ok:bool, errors:array<int, string>, id?:int}
*/
public function create(array $input, ?array $actor): array
{
$errors = $this->validator->validate($input, false);
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$sku = trim((string) ($input['sku'] ?? ''));
if ($sku !== '' && $this->products->existsSku($sku)) {
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
}
$normalized = $this->normalizeForSave($input);
$updatePayload = $this->toUpdatePayload($normalized['product']);
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
try {
$this->pdo->beginTransaction();
$productId = $this->products->create($normalized['product'], $normalized['translation']);
$this->products->logChange($productId, $actorId, 'product_created', null, $normalized['audit']);
$this->pdo->commit();
return ['ok' => true, 'errors' => [], 'id' => $productId];
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Nie udalo sie zapisac produktu: ' . $exception->getMessage()]];
}
}
/**
* @param array<string, mixed> $input
* @param array<string, mixed>|null $actor
* @return array{ok:bool, errors:array<int, string>}
*/
public function update(int $id, array $input, ?array $actor): array
{
$existing = $this->products->findById($id, 'pl');
if ($existing === null) {
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
}
$errors = $this->validator->validate($input, true);
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$sku = trim((string) ($input['sku'] ?? ''));
if ($sku !== '' && $this->products->existsSku($sku, $id)) {
return ['ok' => false, 'errors' => ['Podane SKU produktu jest juz zajete.']];
}
$normalized = $this->normalizeForSave($input);
$updatePayload = $this->toUpdatePayload($normalized['product']);
$actorId = isset($actor['id']) ? (int) $actor['id'] : null;
try {
$this->pdo->beginTransaction();
$this->products->update($id, $updatePayload, $normalized['translation']);
$criticalBefore = $this->extractCriticalFields($existing);
$criticalAfter = $this->extractCriticalFields($normalized['audit']);
if ($criticalBefore !== $criticalAfter) {
$this->products->logChange($id, $actorId, 'product_updated', $criticalBefore, $criticalAfter);
}
$this->pdo->commit();
return ['ok' => true, 'errors' => []];
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Nie udalo sie zaktualizowac produktu: ' . $exception->getMessage()]];
}
}
/**
* @param array<string, mixed>|null $actor
* @return array{ok:bool, errors:array<int, string>}
*/
public function delete(int $id, ?array $actor): array
{
$existing = $this->products->findById($id, 'pl');
if ($existing === null) {
return ['ok' => false, 'errors' => ['Produkt nie istnieje.']];
}
$imagePaths = $this->findProductImageStoragePaths($id);
try {
$this->pdo->beginTransaction();
$deleted = $this->products->deleteById($id);
if (!$deleted) {
throw new \RuntimeException('Nie udalo sie usunac produktu.');
}
$this->pdo->commit();
$this->deleteProductImageFiles($imagePaths);
return ['ok' => true, 'errors' => []];
} catch (Throwable $exception) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
return ['ok' => false, 'errors' => ['Nie udalo sie usunac produktu: ' . $exception->getMessage()]];
}
}
/**
* @param array<string, mixed> $input
* @return array{product:array<string,mixed>, translation:array<string,mixed>, audit:array<string,mixed>}
*/
private function normalizeForSave(array $input): array
{
$vatRaw = trim((string) ($input['vat'] ?? ''));
$vat = $vatRaw === '' ? null : round((float) $vatRaw, 2);
$pricePair = $this->resolvePricePair(
trim((string) ($input['price_brutto'] ?? '')),
trim((string) ($input['price_netto'] ?? '')),
$vat,
(string) ($input['price_input_mode'] ?? 'brutto')
);
$promoPair = $this->resolvePricePair(
trim((string) ($input['price_brutto_promo'] ?? '')),
trim((string) ($input['price_netto_promo'] ?? '')),
$vat,
(string) ($input['price_input_mode'] ?? 'brutto'),
true
);
$now = date('Y-m-d H:i:s');
$product = [
'uuid' => $this->uuidV4(),
'type' => (string) ($input['type'] ?? 'simple'),
'sku' => $this->nullableString($input['sku'] ?? null),
'ean' => $this->nullableString($input['ean'] ?? null),
'status' => (int) ($input['status'] ?? 1),
'promoted' => (int) ($input['promoted'] ?? 0),
'vat' => $vat,
'weight' => $this->nullableFloat($input['weight'] ?? null, 3),
'price_brutto' => $pricePair['brutto'] ?? 0.00,
'price_brutto_promo' => $promoPair['brutto'],
'price_netto' => $pricePair['netto'],
'price_netto_promo' => $promoPair['netto'],
'quantity' => round((float) ($input['quantity'] ?? 0), 3),
'producer_id' => $this->nullableInt($input['producer_id'] ?? null),
'product_unit_id' => $this->nullableInt($input['product_unit_id'] ?? null),
'created_at' => $now,
'updated_at' => $now,
];
$translation = [
'lang' => 'pl',
'name' => trim((string) ($input['name'] ?? '')),
'short_description' => $this->nullableString($input['short_description'] ?? null),
'description' => $this->nullableString($input['description'] ?? null),
'meta_title' => $this->nullableString($input['meta_title'] ?? null),
'meta_description' => $this->nullableString($input['meta_description'] ?? null),
'meta_keywords' => $this->nullableString($input['meta_keywords'] ?? null),
'seo_link' => $this->nullableString($input['seo_link'] ?? null),
'created_at' => $now,
'updated_at' => $now,
];
$audit = [
'type' => $product['type'],
'sku' => $product['sku'],
'ean' => $product['ean'],
'status' => $product['status'],
'promoted' => $product['promoted'],
'vat' => $product['vat'],
'price_brutto' => $product['price_brutto'],
'price_netto' => $product['price_netto'],
'price_brutto_promo' => $product['price_brutto_promo'],
'price_netto_promo' => $product['price_netto_promo'],
'quantity' => $product['quantity'],
'name' => $translation['name'],
];
return [
'product' => $product,
'translation' => $translation,
'audit' => $audit,
];
}
/**
* @return array{brutto:float|null, netto:float|null}
*/
private function resolvePricePair(
string $bruttoRaw,
string $nettoRaw,
?float $vat,
string $mode,
bool $allowEmpty = false
): array {
if ($allowEmpty && $bruttoRaw === '' && $nettoRaw === '') {
return ['brutto' => null, 'netto' => null];
}
$multiplier = 1 + (($vat ?? 0.0) / 100);
if ($mode === 'netto') {
if ($nettoRaw === '' && $bruttoRaw !== '') {
$brutto = round((float) $bruttoRaw, 2);
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
return ['brutto' => $brutto, 'netto' => $netto];
}
$netto = $nettoRaw === '' ? 0.0 : round((float) $nettoRaw, 2);
$brutto = round($netto * $multiplier, 2);
return ['brutto' => $brutto, 'netto' => $netto];
}
if ($bruttoRaw === '' && $nettoRaw !== '') {
$netto = round((float) $nettoRaw, 2);
$brutto = round($netto * $multiplier, 2);
return ['brutto' => $brutto, 'netto' => $netto];
}
$brutto = $bruttoRaw === '' ? 0.0 : round((float) $bruttoRaw, 2);
$netto = $multiplier > 0 ? round($brutto / $multiplier, 2) : $brutto;
return ['brutto' => $brutto, 'netto' => $netto];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function extractCriticalFields(array $row): array
{
return [
'sku' => $row['sku'] ?? null,
'ean' => $row['ean'] ?? null,
'status' => $row['status'] ?? null,
'promoted' => $row['promoted'] ?? null,
'price_brutto' => $row['price_brutto'] ?? null,
'price_netto' => $row['price_netto'] ?? null,
'price_brutto_promo' => $row['price_brutto_promo'] ?? null,
'price_netto_promo' => $row['price_netto_promo'] ?? null,
'quantity' => $row['quantity'] ?? null,
'name' => $row['name'] ?? null,
];
}
private function nullableString(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function nullableInt(mixed $value): ?int
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return (int) $text;
}
private function nullableFloat(mixed $value, int $precision = 2): ?float
{
$text = trim((string) $value);
if ($text === '' || !is_numeric($text)) {
return null;
}
return round((float) $text, $precision);
}
private function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
/**
* @param array<string, mixed> $product
* @return array<string, mixed>
*/
private function toUpdatePayload(array $product): array
{
return [
'type' => $product['type'] ?? 'simple',
'sku' => $product['sku'] ?? null,
'ean' => $product['ean'] ?? null,
'status' => $product['status'] ?? 1,
'promoted' => $product['promoted'] ?? 0,
'vat' => $product['vat'] ?? null,
'weight' => $product['weight'] ?? null,
'price_brutto' => $product['price_brutto'] ?? 0,
'price_brutto_promo' => $product['price_brutto_promo'] ?? null,
'price_netto' => $product['price_netto'] ?? null,
'price_netto_promo' => $product['price_netto_promo'] ?? null,
'quantity' => $product['quantity'] ?? 0,
'producer_id' => $product['producer_id'] ?? null,
'product_unit_id' => $product['product_unit_id'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
];
}
/**
* @return array<int, string>
*/
private function findProductImageStoragePaths(int $productId): array
{
$stmt = $this->pdo->prepare('SELECT storage_path FROM product_images WHERE product_id = :product_id');
$stmt->execute(['product_id' => $productId]);
$rows = $stmt->fetchAll();
if (!is_array($rows)) {
return [];
}
$paths = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$path = trim((string) ($row['storage_path'] ?? ''));
if ($path !== '') {
$paths[] = $path;
}
}
return array_values(array_unique($paths));
}
/**
* @param array<int, string> $storagePaths
*/
private function deleteProductImageFiles(array $storagePaths): void
{
foreach ($storagePaths as $storagePath) {
if ($this->storagePathHasOtherReferences($storagePath)) {
continue;
}
$resolvedFilePath = $this->resolveLocalImageFilePath($storagePath);
if ($resolvedFilePath === null || !is_file($resolvedFilePath)) {
continue;
}
@unlink($resolvedFilePath);
}
}
private function storagePathHasOtherReferences(string $storagePath): bool
{
$stmt = $this->pdo->prepare(
'SELECT 1 FROM product_images WHERE storage_path = :storage_path LIMIT 1'
);
$stmt->execute(['storage_path' => $storagePath]);
return $stmt->fetchColumn() !== false;
}
private function resolveLocalImageFilePath(string $storagePath): ?string
{
$path = trim($storagePath);
if ($path === '') {
return null;
}
if (preg_match('#^https?://#i', $path) === 1 || str_starts_with($path, '//')) {
return null;
}
$projectRoot = dirname(__DIR__, 3);
$projectRootReal = realpath($projectRoot);
if ($projectRootReal === false) {
return null;
}
$trimmed = ltrim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path), DIRECTORY_SEPARATOR);
$candidates = [];
if ($this->isAbsolutePath($path)) {
$candidates[] = str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path);
}
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . $trimmed;
$candidates[] = $projectRoot . DIRECTORY_SEPARATOR . $trimmed;
foreach ($candidates as $candidate) {
$real = realpath($candidate);
if ($real === false || !is_file($real)) {
continue;
}
if ($real === $projectRootReal || str_starts_with($real, $projectRootReal . DIRECTORY_SEPARATOR)) {
return $real;
}
}
return null;
}
private function isAbsolutePath(string $path): bool
{
if ($path === '') {
return false;
}
return preg_match('/^[A-Za-z]:[\\\\\\/]/', $path) === 1 || str_starts_with($path, DIRECTORY_SEPARATOR);
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Modules\Products;
final class ProductValidator
{
/**
* @param array<string, mixed> $input
* @param bool $isUpdate
* @return array<int, string>
*/
public function validate(array $input, bool $isUpdate = false): array
{
$errors = [];
$name = trim((string) ($input['name'] ?? ''));
if (mb_strlen($name) < 2) {
$errors[] = 'Nazwa produktu musi miec co najmniej 2 znaki.';
}
$type = (string) ($input['type'] ?? 'simple');
if (!in_array($type, ['simple', 'variant_parent'], true)) {
$errors[] = 'Niepoprawny typ produktu.';
}
$sku = trim((string) ($input['sku'] ?? ''));
if ($sku !== '' && mb_strlen($sku) > 128) {
$errors[] = 'SKU produktu moze miec maksymalnie 128 znakow.';
}
$ean = trim((string) ($input['ean'] ?? ''));
if ($ean !== '' && !preg_match('/^[0-9]{8,14}$/', $ean)) {
$errors[] = 'EAN musi zawierac od 8 do 14 cyfr.';
}
$status = (string) ($input['status'] ?? '1');
if (!in_array($status, ['0', '1'], true)) {
$errors[] = 'Status produktu jest niepoprawny.';
}
$promoted = (string) ($input['promoted'] ?? '0');
if (!in_array($promoted, ['0', '1'], true)) {
$errors[] = 'Flaga promocji jest niepoprawna.';
}
$priceInputMode = (string) ($input['price_input_mode'] ?? 'brutto');
if (!in_array($priceInputMode, ['brutto', 'netto'], true)) {
$errors[] = 'Tryb wprowadzania ceny jest niepoprawny.';
}
$vat = trim((string) ($input['vat'] ?? ''));
if ($vat !== '' && !is_numeric($vat)) {
$errors[] = 'Stawka VAT musi byc liczba.';
}
if ($vat !== '' && is_numeric($vat)) {
$vatValue = (float) $vat;
if ($vatValue < 0 || $vatValue > 100) {
$errors[] = 'Stawka VAT musi byc w zakresie 0-100.';
}
}
$quantity = trim((string) ($input['quantity'] ?? '0'));
if ($quantity === '' || !is_numeric($quantity) || (float) $quantity < 0) {
$errors[] = 'Stan magazynowy musi byc liczba >= 0.';
}
$weight = trim((string) ($input['weight'] ?? ''));
if ($weight !== '' && (!is_numeric($weight) || (float) $weight < 0)) {
$errors[] = 'Waga musi byc liczba >= 0.';
}
$priceBrutto = trim((string) ($input['price_brutto'] ?? ''));
$priceNetto = trim((string) ($input['price_netto'] ?? ''));
if ($priceInputMode === 'brutto') {
if ($priceBrutto === '' || !is_numeric($priceBrutto) || (float) $priceBrutto < 0) {
$errors[] = 'Cena brutto jest wymagana i musi byc liczba >= 0.';
}
}
if ($priceInputMode === 'netto') {
if ($priceNetto === '' || !is_numeric($priceNetto) || (float) $priceNetto < 0) {
$errors[] = 'Cena netto jest wymagana i musi byc liczba >= 0.';
}
}
$priceBruttoPromo = trim((string) ($input['price_brutto_promo'] ?? ''));
$priceNettoPromo = trim((string) ($input['price_netto_promo'] ?? ''));
if ($priceBruttoPromo !== '' && (!is_numeric($priceBruttoPromo) || (float) $priceBruttoPromo < 0)) {
$errors[] = 'Cena promocyjna brutto musi byc liczba >= 0.';
}
if ($priceNettoPromo !== '' && (!is_numeric($priceNettoPromo) || (float) $priceNettoPromo < 0)) {
$errors[] = 'Cena promocyjna netto musi byc liczba >= 0.';
}
return $errors;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,505 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
use PDO;
use RuntimeException;
final class IntegrationRepository
{
public function __construct(
private readonly PDO $pdo,
private readonly string $secret
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function listByType(string $type): array
{
$statement = $this->pdo->prepare(
'SELECT id, type, name, base_url, timeout_seconds, is_active,
last_test_status, last_test_http_code, last_test_message, last_test_at,
created_at, updated_at,
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
FROM integrations
WHERE type = :type
ORDER BY id DESC'
);
$statement->execute(['type' => $type]);
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map([$this, 'mapRow'], $rows);
}
/**
* @return array<string, mixed>|null
*/
public function findById(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, type, name, base_url, timeout_seconds, is_active,
last_test_status, last_test_http_code, last_test_message, last_test_at,
created_at, updated_at,
CASE WHEN api_key_encrypted IS NULL OR api_key_encrypted = "" THEN 0 ELSE 1 END AS has_api_key
FROM integrations
WHERE id = :id
LIMIT 1'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return $this->mapRow($row);
}
/**
* @return array<string, mixed>|null
*/
public function findApiCredentials(int $id): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted
FROM integrations
WHERE id = :id
LIMIT 1'
);
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
];
}
/**
* @return array<string, mixed>|null
*/
public function findActiveApiCredentialsByType(string $type): ?array
{
$statement = $this->pdo->prepare(
'SELECT id, name, base_url, timeout_seconds, api_key_encrypted
FROM integrations
WHERE type = :type AND is_active = 1
ORDER BY id DESC
LIMIT 1'
);
$statement->execute(['type' => $type]);
$row = $statement->fetch();
if (!is_array($row)) {
return null;
}
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'api_key' => $this->decryptApiKey((string) ($row['api_key_encrypted'] ?? '')),
];
}
public function create(
string $type,
string $name,
string $baseUrl,
int $timeoutSeconds,
bool $isActive,
string $apiKey
): int {
$statement = $this->pdo->prepare(
'INSERT INTO integrations (
type, name, base_url, api_key_encrypted, timeout_seconds, is_active, created_at, updated_at
) VALUES (
:type, :name, :base_url, :api_key_encrypted, :timeout_seconds, :is_active, :created_at, :updated_at
)'
);
$statement->execute([
'type' => $type,
'name' => $name,
'base_url' => $baseUrl,
'api_key_encrypted' => $this->encryptApiKey($apiKey),
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->pdo->lastInsertId();
}
public function update(
int $id,
string $name,
string $baseUrl,
int $timeoutSeconds,
bool $isActive,
?string $apiKey
): void {
$params = [
'id' => $id,
'name' => $name,
'base_url' => $baseUrl,
'timeout_seconds' => $timeoutSeconds,
'is_active' => $isActive ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s'),
];
$sql = 'UPDATE integrations SET
name = :name,
base_url = :base_url,
timeout_seconds = :timeout_seconds,
is_active = :is_active,
updated_at = :updated_at';
if ($apiKey !== null && trim($apiKey) !== '') {
$sql .= ', api_key_encrypted = :api_key_encrypted';
$params['api_key_encrypted'] = $this->encryptApiKey($apiKey);
}
$sql .= ' WHERE id = :id';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
}
public function setTestResult(
int $id,
string $status,
?int $httpCode,
string $message,
string $testedAt
): void {
$statement = $this->pdo->prepare(
'UPDATE integrations SET
last_test_status = :status,
last_test_http_code = :http_code,
last_test_message = :message,
last_test_at = :tested_at,
updated_at = :updated_at
WHERE id = :id'
);
$statement->execute([
'id' => $id,
'status' => $status,
'http_code' => $httpCode,
'message' => mb_substr($message, 0, 255),
'tested_at' => $testedAt,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
public function logTest(
int $integrationId,
string $status,
?int $httpCode,
string $message,
string $endpointUrl,
string $testedAt
): void {
$statement = $this->pdo->prepare(
'INSERT INTO integration_test_logs (
integration_id, status, http_code, message, endpoint_url, tested_at
) VALUES (
:integration_id, :status, :http_code, :message, :endpoint_url, :tested_at
)'
);
$statement->execute([
'integration_id' => $integrationId,
'status' => $status,
'http_code' => $httpCode,
'message' => mb_substr($message, 0, 255),
'endpoint_url' => mb_substr($endpointUrl, 0, 255),
'tested_at' => $testedAt,
]);
}
/**
* @return array<int, array<string, mixed>>
*/
public function recentTests(int $integrationId, int $limit = 5): array
{
$statement = $this->pdo->prepare(
'SELECT id, integration_id, status, http_code, message, endpoint_url, tested_at
FROM integration_test_logs
WHERE integration_id = :integration_id
ORDER BY tested_at DESC, id DESC
LIMIT :limit'
);
$statement->bindValue(':integration_id', $integrationId, PDO::PARAM_INT);
$statement->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
if (!is_array($rows)) {
return [];
}
return array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'integration_id' => (int) ($row['integration_id'] ?? 0),
'status' => (string) ($row['status'] ?? ''),
'http_code' => $row['http_code'] === null ? null : (int) $row['http_code'],
'message' => (string) ($row['message'] ?? ''),
'endpoint_url' => (string) ($row['endpoint_url'] ?? ''),
'tested_at' => (string) ($row['tested_at'] ?? ''),
],
$rows
);
}
public function nameExists(string $type, string $name, ?int $excludeId = null): bool
{
$sql = 'SELECT 1 FROM integrations WHERE type = :type AND name = :name';
$params = [
'type' => $type,
'name' => $name,
];
if ($excludeId !== null) {
$sql .= ' AND id <> :exclude_id';
$params['exclude_id'] = $excludeId;
}
$sql .= ' LIMIT 1';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
return $statement->fetchColumn() !== false;
}
public function ensureSalesChannelsSeeded(): void
{
$rows = [
['code' => 'shoppro', 'name' => 'shopPRO', 'type' => 'shop_instance'],
['code' => 'allegro', 'name' => 'Allegro', 'type' => 'marketplace'],
['code' => 'erli', 'name' => 'Erli', 'type' => 'marketplace'],
];
$statement = $this->pdo->prepare(
'INSERT INTO sales_channels (code, name, type, status, created_at, updated_at)
VALUES (:code, :name, :type, 1, :created_at, :updated_at)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
type = VALUES(type),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
foreach ($rows as $row) {
$statement->execute([
'code' => $row['code'],
'name' => $row['name'],
'type' => $row['type'],
'created_at' => $now,
'updated_at' => $now,
]);
}
}
public function findMappedProductId(string $channelCode, string $externalProductId, ?int $integrationId = null): ?int
{
$sql = 'SELECT pcm.product_id
FROM product_channel_map pcm
INNER JOIN sales_channels sc ON sc.id = pcm.channel_id
WHERE sc.code = :channel_code
AND pcm.external_product_id = :external_product_id';
$params = [
'channel_code' => $channelCode,
'external_product_id' => $externalProductId,
];
if ($integrationId !== null && $integrationId > 0) {
$sql .= ' AND pcm.integration_id = :integration_id';
$params['integration_id'] = $integrationId;
}
$sql .= ' LIMIT 1';
$statement = $this->pdo->prepare($sql);
$statement->execute($params);
$value = $statement->fetchColumn();
if ($value === false) {
return null;
}
return (int) $value;
}
public function upsertProductChannelMap(
int $productId,
string $channelCode,
string $syncState,
string $externalProductId = '',
string $externalVariantId = '',
?int $integrationId = null
): void {
$channelId = $this->findChannelIdByCode($channelCode);
if ($channelId === null) {
throw new RuntimeException('Brak kanalu sprzedazy: ' . $channelCode);
}
$externalProductId = trim($externalProductId);
$externalVariantId = trim($externalVariantId);
$normalizedIntegrationId = $integrationId !== null && $integrationId > 0
? $integrationId
: null;
$linkType = 'manual';
$linkStatus = $externalProductId !== '' ? 'active' : 'unverified';
$linkedAt = $externalProductId !== '' ? date('Y-m-d H:i:s') : null;
$statement = $this->pdo->prepare(
'INSERT INTO product_channel_map (
product_id, channel_id, integration_id, external_product_id, external_variant_id,
sync_state, link_type, link_status, linked_at, last_sync_at, created_at, updated_at
) VALUES (
:product_id, :channel_id, :integration_id, :external_product_id, :external_variant_id,
:sync_state, :link_type, :link_status, :linked_at, :last_sync_at, :created_at, :updated_at
) ON DUPLICATE KEY UPDATE
integration_id = VALUES(integration_id),
sync_state = VALUES(sync_state),
last_sync_at = VALUES(last_sync_at),
external_product_id = VALUES(external_product_id),
external_variant_id = VALUES(external_variant_id),
link_type = VALUES(link_type),
link_status = VALUES(link_status),
linked_at = VALUES(linked_at),
updated_at = VALUES(updated_at)'
);
$now = date('Y-m-d H:i:s');
$statement->execute([
'product_id' => $productId,
'channel_id' => $channelId,
'integration_id' => $normalizedIntegrationId,
'external_product_id' => $externalProductId,
'external_variant_id' => $externalVariantId !== '' ? $externalVariantId : null,
'sync_state' => $syncState,
'link_type' => $linkType,
'link_status' => $linkStatus,
'linked_at' => $linkedAt,
'last_sync_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function mapRow(array $row): array
{
return [
'id' => (int) ($row['id'] ?? 0),
'type' => (string) ($row['type'] ?? ''),
'name' => (string) ($row['name'] ?? ''),
'base_url' => (string) ($row['base_url'] ?? ''),
'timeout_seconds' => (int) ($row['timeout_seconds'] ?? 10),
'is_active' => (int) ($row['is_active'] ?? 0) === 1,
'last_test_status' => (string) ($row['last_test_status'] ?? ''),
'last_test_http_code' => $row['last_test_http_code'] === null ? null : (int) $row['last_test_http_code'],
'last_test_message' => (string) ($row['last_test_message'] ?? ''),
'last_test_at' => (string) ($row['last_test_at'] ?? ''),
'has_api_key' => (int) ($row['has_api_key'] ?? 0) === 1,
'created_at' => (string) ($row['created_at'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
];
}
private function encryptApiKey(string $apiKey): string
{
$plain = trim($apiKey);
if ($plain === '') {
return '';
}
$secret = trim($this->secret);
if ($secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET w konfiguracji .env.');
}
$iv = random_bytes(16);
$cipher = openssl_encrypt(
$plain,
'AES-256-CBC',
hash('sha256', $secret, true),
OPENSSL_RAW_DATA,
$iv
);
if ($cipher === false) {
throw new RuntimeException('Nie mozna zaszyfrowac klucza API.');
}
return base64_encode($iv) . ':' . base64_encode($cipher);
}
private function decryptApiKey(string $payload): string
{
$serialized = trim($payload);
if ($serialized === '') {
return '';
}
$secret = trim($this->secret);
if ($secret === '') {
throw new RuntimeException('Brak INTEGRATIONS_SECRET w konfiguracji .env.');
}
$parts = explode(':', $serialized, 2);
if (count($parts) !== 2) {
throw new RuntimeException('Niepoprawny format zapisanego klucza API.');
}
$iv = base64_decode($parts[0], true);
$cipher = base64_decode($parts[1], true);
if ($iv === false || $cipher === false || strlen($iv) !== 16) {
throw new RuntimeException('Nie mozna odczytac zapisanego klucza API.');
}
$plain = openssl_decrypt(
$cipher,
'AES-256-CBC',
hash('sha256', $secret, true),
OPENSSL_RAW_DATA,
$iv
);
if ($plain === false) {
throw new RuntimeException('Nie mozna odszyfrowac zapisanego klucza API.');
}
return $plain;
}
private function findChannelIdByCode(string $code): ?int
{
$statement = $this->pdo->prepare('SELECT id FROM sales_channels WHERE code = :code LIMIT 1');
$statement->execute(['code' => $code]);
$value = $statement->fetchColumn();
if ($value === false) {
return null;
}
return (int) $value;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
namespace App\Modules\Settings;
final class ShopProClient
{
/**
* @return array{ok:bool,http_code:int|null,message:string,items:array<int,array<string,mixed>>,total:int,page:int,per_page:int}
*/
public function fetchProducts(
string $baseUrl,
string $apiKey,
int $timeoutSeconds,
int $page = 1,
int $perPage = 1
): array {
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$query = http_build_query([
'endpoint' => 'products',
'action' => 'list',
'page' => max(1, $page),
'per_page' => max(1, min(100, $perPage)),
'sort' => 'id',
'sort_dir' => 'DESC',
'status' => '1',
]);
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac listy produktow z shopPRO.'),
'items' => [],
'total' => 0,
'page' => max(1, $page),
'per_page' => max(1, min(100, $perPage)),
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'items' => $items,
'total' => (int) ($data['total'] ?? 0),
'page' => (int) ($data['page'] ?? max(1, $page)),
'per_page' => (int) ($data['per_page'] ?? max(1, min(100, $perPage))),
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,product:array<string,mixed>|null}
*/
public function fetchProductById(string $baseUrl, string $apiKey, int $timeoutSeconds, int $productId): array
{
if ($productId <= 0) {
return [
'ok' => false,
'http_code' => null,
'message' => 'Niepoprawne ID produktu do importu.',
'product' => null,
];
}
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$query = http_build_query([
'endpoint' => 'products',
'action' => 'get',
'id' => $productId,
]);
$endpointUrl = $normalizedBaseUrl . '/api.php?' . $query;
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nie mozna pobrac produktu z shopPRO.'),
'product' => null,
];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : null;
if ($data === null) {
return [
'ok' => false,
'http_code' => $response['http_code'] ?? null,
'message' => 'shopPRO zwrocil pusty payload produktu.',
'product' => null,
];
}
return [
'ok' => true,
'http_code' => $response['http_code'] ?? null,
'message' => '',
'product' => $data,
];
}
/**
* @return array{ok:bool,status:string,http_code:int|null,message:string,endpoint_url:string,tested_at:string}
*/
public function testConnection(string $baseUrl, string $apiKey, int $timeoutSeconds): array
{
$normalizedBaseUrl = rtrim(trim($baseUrl), '/');
$endpointUrl = $normalizedBaseUrl . '/api.php?endpoint=dictionaries&action=statuses';
$testedAt = date('Y-m-d H:i:s');
$response = $this->requestJson($endpointUrl, $apiKey, $timeoutSeconds);
if (($response['ok'] ?? false) !== true) {
return [
'ok' => false,
'status' => 'error',
'http_code' => $response['http_code'] ?? null,
'message' => (string) ($response['message'] ?? 'Nieznany blad polaczenia.'),
'endpoint_url' => $endpointUrl,
'tested_at' => $testedAt,
];
}
$payload = is_array($response['payload'] ?? null) ? $response['payload'] : [];
$apiStatus = (string) ($payload['status'] ?? '');
$httpCode = isset($response['http_code']) ? (int) $response['http_code'] : null;
if ($httpCode === 200 && $apiStatus === 'ok') {
return [
'ok' => true,
'status' => 'ok',
'http_code' => $httpCode,
'message' => 'Polaczenie poprawne. Endpoint dictionaries/statuses zwrocil status=ok.',
'endpoint_url' => $endpointUrl,
'tested_at' => $testedAt,
];
}
$errorCode = (string) ($payload['code'] ?? '');
$errorMessage = (string) ($payload['message'] ?? 'Brak szczegolow bledu.');
return [
'ok' => false,
'status' => 'error',
'http_code' => $httpCode,
'message' => trim('shopPRO zwrocil blad. ' . $errorCode . ' ' . $errorMessage),
'endpoint_url' => $endpointUrl,
'tested_at' => $testedAt,
];
}
/**
* @return array{ok:bool,http_code:int|null,message:string,payload?:array<string,mixed>,data?:mixed}
*/
private function requestJson(string $endpointUrl, string $apiKey, int $timeoutSeconds): array
{
$timeout = max(3, min(60, $timeoutSeconds));
if (function_exists('curl_init')) {
[$httpCode, $body, $transportError, $contentType] = $this->requestByCurl($endpointUrl, $apiKey, $timeout);
} else {
[$httpCode, $body, $transportError, $contentType] = $this->requestByStream($endpointUrl, $apiKey, $timeout);
}
if ($transportError !== '') {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => 'Blad transportu: ' . $transportError,
];
}
$cleanBody = $this->stripUtf8Bom($body);
$bodyLength = strlen($cleanBody);
if ($bodyLength === 0) {
return [
'ok' => false,
'http_code' => $httpCode,
'message' => trim(
'Pusta odpowiedz z shopPRO. HTTP=' . (string) ($httpCode ?? 0)
. ' content_type="' . $contentType . '" body_length=0'
),
];
}
$payload = json_decode($cleanBody, true);
if (!is_array($payload)) {
$prefix = trim(mb_substr($cleanBody, 0, 180));
$looksLikeHtml = str_starts_with($prefix, '<');
$details = $looksLikeHtml
? 'Odpowiedz wyglada na HTML (np. redirect, blad serwera lub blokada).'
: 'Odpowiedz nie jest poprawnym JSON.';
return [
'ok' => false,
'http_code' => $httpCode,
'message' => trim(
$details
. ' HTTP=' . (string) ($httpCode ?? 0)
. ' content_type="' . $contentType . '"'
. ' body_length=' . (string) $bodyLength
. ' fragment="' . $prefix . '"'
),
];
}
$status = (string) ($payload['status'] ?? '');
if ($status !== 'ok') {
$errorCode = (string) ($payload['code'] ?? '');
$errorMessage = (string) ($payload['message'] ?? 'Brak szczegolow bledu.');
return [
'ok' => false,
'http_code' => $httpCode,
'message' => trim('shopPRO zwrocil blad. ' . $errorCode . ' ' . $errorMessage),
'payload' => $payload,
];
}
return [
'ok' => true,
'http_code' => $httpCode,
'message' => '',
'payload' => $payload,
'data' => $payload['data'] ?? null,
];
}
/**
* @return array{0:int|null,1:string,2:string,3:string}
*/
private function requestByCurl(string $url, string $apiKey, int $timeoutSeconds): array
{
$curl = curl_init($url);
if ($curl === false) {
return [null, '', 'Nie mozna zainicjalizowac cURL.'];
}
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-Api-Key: ' . $apiKey,
'Accept: application/json',
],
CURLOPT_TIMEOUT => $timeoutSeconds,
CURLOPT_CONNECTTIMEOUT => max(2, min(10, $timeoutSeconds)),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
]);
$body = curl_exec($curl);
$httpCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
$error = curl_error($curl);
curl_close($curl);
return [
$httpCode > 0 ? $httpCode : null,
is_string($body) ? $body : '',
$error,
$contentType,
];
}
/**
* @return array{0:int|null,1:string,2:string,3:string}
*/
private function requestByStream(string $url, string $apiKey, int $timeoutSeconds): array
{
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "X-Api-Key: {$apiKey}\r\nAccept: application/json\r\n",
'timeout' => $timeoutSeconds,
'ignore_errors' => true,
'follow_location' => 1,
'max_redirects' => 5,
],
]);
$body = @file_get_contents($url, false, $context);
$headers = is_array($http_response_header ?? null) ? $http_response_header : [];
$httpCode = $this->extractHttpStatusCode($headers);
$contentType = $this->extractContentType($headers);
$error = $body === false ? 'Nie mozna pobrac odpowiedzi HTTP.' : '';
return [
$httpCode,
$body === false ? '' : $body,
$error,
$contentType,
];
}
/**
* @param array<int, string> $headers
*/
private function extractHttpStatusCode(array $headers): ?int
{
foreach ($headers as $header) {
if (preg_match('/^HTTP\/\d\.\d\s+(\d{3})\b/i', $header, $matches) === 1) {
return (int) $matches[1];
}
}
return null;
}
/**
* @param array<int, string> $headers
*/
private function extractContentType(array $headers): string
{
foreach ($headers as $header) {
if (stripos($header, 'Content-Type:') === 0) {
return trim(substr($header, strlen('Content-Type:')));
}
}
return '';
}
private function stripUtf8Bom(string $body): string
{
if (str_starts_with($body, "\xEF\xBB\xBF")) {
return substr($body, 3);
}
return $body;
}
}

View File

@@ -34,6 +34,73 @@ final class UserRepository
); );
} }
/**
* @param array<string, mixed> $filters
* @return array{items:array<int, array<string, mixed>>, total:int, page:int, per_page:int}
*/
public function paginate(array $filters): array
{
$page = max(1, (int) ($filters['page'] ?? 1));
$perPage = max(1, min(100, (int) ($filters['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
$where = [];
$params = [];
$search = trim((string) ($filters['search'] ?? ''));
if ($search !== '') {
$where[] = '(name LIKE :search OR email LIKE :search)';
$params['search'] = '%' . $search . '%';
}
$whereSql = $where === [] ? '' : (' WHERE ' . implode(' AND ', $where));
$sort = (string) ($filters['sort'] ?? 'id');
$sortDir = strtoupper((string) ($filters['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$sortColumn = match ($sort) {
'name' => 'name',
'email' => 'email',
'created_at' => 'created_at',
default => 'id',
};
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM users' . $whereSql);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$listStmt = $this->pdo->prepare(
'SELECT id, name, email, created_at FROM users' . $whereSql
. ' ORDER BY ' . $sortColumn . ' ' . $sortDir
. ' LIMIT :limit OFFSET :offset'
);
foreach ($params as $key => $value) {
$listStmt->bindValue(':' . $key, $value);
}
$listStmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$listStmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$listStmt->execute();
$rows = $listStmt->fetchAll();
if (!is_array($rows)) {
$rows = [];
}
return [
'items' => array_map(
static fn (array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'email' => (string) ($row['email'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
],
$rows
),
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
public function hasAny(): bool public function hasAny(): bool
{ {
$statement = $this->pdo->query('SELECT 1 FROM users LIMIT 1'); $statement = $this->pdo->query('SELECT 1 FROM users LIMIT 1');

View File

@@ -23,22 +23,83 @@ final class UsersController
public function index(Request $request): Response public function index(Request $request): Response
{ {
$records = array_map( $filters = [
static fn (array $user): array => [ 'search' => trim((string) $request->input('search', '')),
'id' => (int) ($user['id'] ?? 0), 'sort' => (string) $request->input('sort', 'id'),
'name' => (string) ($user['name'] ?? ''), 'sort_dir' => (string) $request->input('sort_dir', 'DESC'),
'email' => (string) ($user['email'] ?? ''), 'page' => max(1, (int) $request->input('page', 1)),
'created_at' => (string) ($user['created_at'] ?? ''), 'per_page' => max(1, min(100, (int) $request->input('per_page', 20))),
], ];
$this->users->all() $result = $this->users->paginate($filters);
); $totalPages = max(1, (int) ceil(((int) $result['total']) / max(1, (int) $result['per_page'])));
$html = $this->template->render('users/index', [ $html = $this->template->render('users/index', [
'title' => $this->translator->get('users.title'), 'title' => $this->translator->get('users.title'),
'activeMenu' => 'users', 'activeMenu' => 'users',
'user' => $this->auth->user(), 'user' => $this->auth->user(),
'csrfToken' => Csrf::token(), 'csrfToken' => Csrf::token(),
'users' => $records, 'tableList' => [
'list_key' => 'users',
'base_path' => '/users',
'query' => $filters,
'filters' => [
[
'key' => 'search',
'label' => $this->translator->get('users.filters.search'),
'type' => 'text',
'value' => $filters['search'],
],
[
'key' => 'sort',
'label' => $this->translator->get('users.filters.sort'),
'type' => 'select',
'value' => $filters['sort'],
'options' => [
'id' => 'ID',
'name' => $this->translator->get('users.fields.name'),
'email' => $this->translator->get('users.fields.email'),
'created_at' => $this->translator->get('users.fields.created_at'),
],
],
[
'key' => 'sort_dir',
'label' => $this->translator->get('users.filters.direction'),
'type' => 'select',
'value' => $filters['sort_dir'],
'options' => [
'DESC' => 'DESC',
'ASC' => 'ASC',
],
],
[
'key' => 'per_page',
'label' => $this->translator->get('users.filters.per_page'),
'type' => 'select',
'value' => (string) $filters['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('users.fields.name'), 'sortable' => true, 'sort_key' => 'name'],
['key' => 'email', 'label' => $this->translator->get('users.fields.email'), 'sortable' => true, 'sort_key' => 'email'],
['key' => 'created_at', 'label' => $this->translator->get('users.fields.created_at'), 'sortable' => true, 'sort_key' => 'created_at'],
],
'rows' => (array) ($result['items'] ?? []),
'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('users.empty'),
'show_actions' => false,
],
'errorMessage' => (string) Flash::get('users_error', ''), 'errorMessage' => (string) Flash::get('users_error', ''),
'successMessage' => (string) Flash::get('users_success', ''), 'successMessage' => (string) Flash::get('users_success', ''),
'oldName' => (string) Flash::get('users_old_name', ''), 'oldName' => (string) Flash::get('users_old_name', ''),