Compare commits

...

8 Commits

Author SHA1 Message Date
Jacek
1c747af1b6 fix: Checkout flow — summaryView redirect fix + TTL token + order logging
- Usunięty błędny guard w summaryView() blokujący kolejne zamówienia
- Token zamówienia z jednorazowego na TTL 30 min (multi-tab safe)
- Logowanie błędów zamówień do logs/logs-order-YYYY-MM-DD.log
- Redirect przy złym tokenie na /koszyk-podsumowanie zamiast /koszyk
- Double-submit guard przeniesiony przed sprawdzenie tokena

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:32:46 +01:00
Jacek
fc624ba0ef update 2026-03-25 19:16:17 +01:00
Jacek
589f9d9a38 feat: DataLayer GA4 analytics fix — poprawka eventów ecommerce
Naprawione eventy purchase, begin_checkout, view_item, add_to_cart
do formatu GA4 (item_id/item_name zamiast id/name, currency PLN,
google_business_vertical, poprawne typy danych).
Dodany nowy event view_cart na stronie koszyka.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:01:22 +01:00
Jacek
18c037915f update 2026-03-22 23:55:23 +01:00
Jacek
649639319a update 2026-03-19 19:46:17 +01:00
Jacek
fb2093129f build: ver_0.344 - Edycja personalizacji produktu w koszyku
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:45:37 +01:00
Jacek
09f51be1c1 feat: edycja personalizacji produktu w koszyku
Nowa metoda basketUpdateCustomFields() w ShopBasketController — AJAX endpoint
z walidacją required fields, przeliczaniem product_code (MD5 hash) i merge
duplikatów. UI: przycisk "Edytuj personalizację" + formularz inline + JS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:45:02 +01:00
Jacek
951a82a5b1 build: ver_0.343 - Custom fields: type + is_required + obsługa obrazków w koszyku
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:37:36 +01:00
31 changed files with 1745 additions and 78 deletions

4
.claude/memory/MEMORY.md Normal file
View File

@@ -0,0 +1,4 @@
# Memory Index
- [feedback_git_push_retry.md](feedback_git_push_retry.md) — Git push often fails on first attempt, always retry
- [feedback_updateignore_sonarqube.md](feedback_updateignore_sonarqube.md) — Never include .scannerwork/ and sonar-project.properties in update ZIPs

View File

@@ -0,0 +1,10 @@
---
name: git push retry
description: Git push to project-pro.pl often fails on first attempt - always retry before reporting failure
type: feedback
---
Git push do origin (git.project-pro.pl) czasem nie wchodzi za pierwszym razem. Zawsze ponawiaj push przed zgłoszeniem problemu użytkownikowi.
**Why:** Serwer git czasem odrzuca pierwsze połączenie (problem z autentykacją/połączeniem).
**How to apply:** Przy `git push` — jeśli pierwszy attempt fail, od razu ponów. Dopiero po 2-3 nieudanych próbach zgłoś problem.

View File

@@ -0,0 +1,10 @@
---
name: updateignore sonarqube
description: Never include .scannerwork/ and sonar-project.properties in update ZIP packages
type: feedback
---
Do paczki ZIP z aktualizacją nie dodawać katalogu `.scannerwork/` ani pliku `sonar-project.properties`.
**Why:** Są to pliki SonarQube — narzędzie deweloperskie, nie należą na serwer klienta.
**How to apply:** Upewnij się, że `.updateignore` zawiera te wpisy. Jeśli po buildzie w logu widać te pliki — naprawić `.updateignore` przed commitowaniem paczki.

View File

@@ -37,6 +37,15 @@ Status: Planning
| 8 | Apilo orders not sending — diagnoza i naprawa | 1 | Done | 2026-03-16 |
| 9 | Apilo email notification + infinite retry | 1 | Done | 2026-03-19 |
## Feature
| Phase | Name | Plans | Status | Completed |
|-------|------|-------|--------|-----------|
| 10 | Edycja personalizacji produktu w koszyku | 1 | Done | 2026-03-19 |
| 11 | DataLayer GA4 analytics fix | 1 | Done | 2026-03-25 |
| 12 | summaryView redirect fix — double order block | 1 | Done | 2026-03-25 |
| 13 | Basket logging + TTL token fix | 1 | Done | 2026-03-25 |
## Phase Details
### Phase 4 — CSRF protection
@@ -67,4 +76,25 @@ Status: Planning
---
*Roadmap created: 2026-03-12*
*Last updated: 2026-03-19*
### Phase 11 — DataLayer GA4 analytics fix
**Problem:** Eventy dataLayer ecommerce (purchase, begin_checkout, view_item, add_to_cart) używają starego formatu UA (id/name zamiast item_id/item_name), brak currency w view_item, price:0 w purchase, brak eventu view_cart. Remarketing dynamiczny i konwersje GA4 nie działają poprawnie.
**Scope:** Poprawka 4 istniejących eventów do formatu GA4 + dodanie nowego eventu view_cart na stronie koszyka.
**Reference:** `poprawki_datalayer_projectpro.md` — audyt analityki z pomysloweprezenty.pl
### Phase 12 — summaryView redirect fix
**Problem:** Po złożeniu pierwszego zamówienia, guard w `summaryView()` sprawdzał sesyjny `order-submit-last-order-id` i redirectował na stronę starego zamówienia. Blokował dostęp do `/koszyk-podsumowanie` dla kolejnych zamówień. Poprawka z instancji klienta (change.md) do wdrożenia globalnie.
**Scope:** Usunięcie bloku redirect z `summaryView()` w `ShopBasketController.php`. Double-submit protection w `basketSave()` pozostaje bez zmian.
### Phase 13 — Basket logging + TTL token fix
**Problem:** Brak logowania w basketSave() uniemożliwia diagnozę błędów zamówień. Token zamówienia jednorazowy — nadpisywany przy każdym wejściu na podsumowanie, co powoduje że druga karta, "wstecz" lub odświeżenie unieważnia formularz.
**Scope:** Dodanie metody logOrder() z 4 punktami logowania, zmiana tokena z jednorazowego na TTL 30 min, redirect przy błędzie tokena na /koszyk-podsumowanie zamiast /koszyk, nowy double-submit guard.
---
*Last updated: 2026-03-25*

View File

@@ -5,25 +5,25 @@
See: .paul/PROJECT.md (updated 2026-03-12)
**Core value:** Właściciel sklepu ma pełną kontrolę nad sprzedażą online w jednym systemie pisanym od podstaw, bez narzutów zewnętrznych platform.
**Current focus:** Phase 9 complete — Apilo email fix + infinite retry
**Current focus:** Phase 13 complete — basket logging + TTL token
## Current Position
Milestone: Hotfix
Phase: 9Apilo email notification + infinite retry — Complete
Plan: 09-01 complete (phase done)
Status: UNIFY complete, phase 9 finished
Last activity: 2026-03-1909-01 UNIFY complete
Phase: 13basket logging + TTL token — Planning
Plan: 13-01 created, awaiting approval
Status: UNIFY complete, phase 13 finished
Last activity: 2026-03-2513-01 UNIFY complete
Progress:
- Phase 9: [██████████] 100% (COMPLETE)
- Phase 13: [██████████] 100% (COMPLETE)
## Loop Position
Current loop state (phase 9, plan 01):
Current loop state (phase 13, plan 01):
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [Phase 9 complete]
✓ ✓ ✓ [Phase 13 complete]
```
Previous phases:
@@ -34,6 +34,10 @@ Phase 6: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-0
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-15]
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-16]
Phase 9: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-19]
Phase 10: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-19]
Phase 11: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-25]
Phase 12: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-25]
Phase 13: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-25]
```
## Accumulated Context
@@ -46,6 +50,15 @@ Phase 9: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-0
- 2026-03-19: Order-related Apilo joby — infinite retry co 30 min (nigdy permanent failure)
- 2026-03-19: Email z danymi zamówienia + rozróżnienie PONAWIANY vs TRWAŁY BŁĄD
- 2026-03-19: Cleanup stuck sync_payment/sync_status jobów po udanym wysłaniu
- 2026-03-19: Edycja custom fields w koszyku — product_code przeliczany po zmianie, merge duplikatów przy identycznym hashu
- 2026-03-19: JS handlery koszyka w basket.php (nie basket-details.php) bo basket-details jest AJAX-replaceable
- 2026-03-25: view_cart event w basket.php (nie basket-details.php) — ten sam powód
- 2026-03-25: GA4 item format standard: item_id (string), item_name, price (number), quantity (int), google_business_vertical: "retail"
- 2026-03-25: Brak user_data w purchase — wymaga analizy RODO
- 2026-03-25: summaryView() redirect guard usunięty — blokował kolejne zamówienia po pierwszym (z change.md instancji klienta)
- 2026-03-25: Token zamówienia z jednorazowego na TTL 30 min — backward compat z plain string
- 2026-03-25: logOrder() — logowanie błędów zamówień do logs/logs-order-YYYY-MM-DD.log
- 2026-03-25: Redirect przy złym tokenie: /koszyk-podsumowanie zamiast /koszyk
### Deferred Issues
None.
@@ -55,10 +68,10 @@ None.
## Session Continuity
Last session: 2026-03-19
Stopped at: Phase 09 UNIFY complete
Next action: Deploy fix or /paul:progress for next work
Resume file: .paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
Last session: 2026-03-25
Stopped at: Phase 13 UNIFY complete
Next action: /koniec-pracy or next feature
Resume file: .paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md
---
*STATE.md — Updated after every significant action*

View File

@@ -0,0 +1,221 @@
---
phase: 10-basket-edit-custom-fields
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/_partials/product-custom-fields.php
- templates/shop-basket/basket-details.php
- ajax.php
autonomous: true
---
<objective>
## Goal
Dodać możliwość edycji personalizacji (custom fields) produktu bezpośrednio w koszyku, bez konieczności usuwania produktu i dodawania go od nowa.
## Purpose
Klient, który pomyli się przy wpisywaniu personalizacji (np. grawer, dedykacja), musi teraz usunąć produkt z koszyka i dodać go ponownie z poprawnymi danymi. To frustrujące UX — edycja inline jest naturalnym oczekiwaniem.
## Output
- Przycisk "Edytuj" przy personalizacjach w koszyku
- Modal lub formularz inline do edycji wartości custom fields
- Endpoint AJAX do zapisania zmian w sesji koszyka
- Przeliczenie product_code (MD5 hash) po zmianie wartości
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@templates/shop-basket/_partials/product-custom-fields.php
@templates/shop-basket/basket-details.php
@templates/shop-product/_partial/product-custom-fields.php
@autoload/Domain/Product/ProductRepository.php
@ajax.php
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No specialized flows configured — /frontend-design jest optional.
</skills>
<acceptance_criteria>
## AC-1: Przycisk edycji widoczny w koszyku
```gherkin
Given produkt w koszyku ma wypełnione custom fields (personalizacje)
When klient widzi szczegóły koszyka (basket-details)
Then przy każdej pozycji z custom fields widnieje przycisk "Edytuj personalizację"
And przycisk NIE pojawia się gdy produkt nie ma custom fields
```
## AC-2: Formularz edycji wyświetla aktualne wartości
```gherkin
Given klient klika "Edytuj personalizację" przy pozycji koszyka
When otwiera się formularz edycji (modal lub inline)
Then formularz zawiera pola odpowiadające custom fields tego produktu
And pola są wypełnione aktualnymi wartościami z koszyka
And pola wymagane (is_required) są oznaczone jako wymagane
```
## AC-3: Zapis zmian aktualizuje koszyk
```gherkin
Given klient zmienił wartości custom fields w formularzu edycji
When klika "Zapisz"
Then wartości custom fields w sesji koszyka są zaktualizowane
And product_code (MD5 hash) jest przeliczony z nowymi wartościami
And strona koszyka odświeża się pokazując nowe wartości
And ilość produktu i inne atrybuty nie ulegają zmianie
```
## AC-4: Walidacja pól wymaganych
```gherkin
Given produkt ma custom field oznaczone jako is_required = 1
When klient próbuje zapisać formularz z pustym polem wymaganym
Then zapis jest blokowany
And wyświetlany jest komunikat o wymaganym polu
```
## AC-5: Obsługa konfliktu duplikatu
```gherkin
Given koszyk zawiera dwa egzemplarze tego samego produktu z różnymi personalizacjami
When klient edytuje personalizację jednego tak, że staje się identyczna z drugim
Then pozycje zostają scalone (ilości zsumowane)
And w koszyku pozostaje jedna pozycja z łączną ilością
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Endpoint AJAX do aktualizacji custom fields w koszyku</name>
<files>autoload/front/Controllers/ShopBasketController.php, ajax.php</files>
<action>
Dodać metodę `basketUpdateCustomFields()` w ShopBasketController:
1. Przyjmuje POST z parametrami:
- `product_code` — obecny klucz pozycji w koszyku (MD5 hash)
- `custom_field[ID]` — nowe wartości custom fields (tablica)
2. Logika metody:
- Pobierz pozycję koszyka po `product_code` z sesji
- Jeśli nie istnieje → zwróć błąd JSON
- Waliduj wymagane pola (pobierz metadane z ProductRepository::findCustomFieldCached)
- Jeśli walidacja nie przejdzie → zwróć błąd JSON z listą brakujących pól
- Zaktualizuj `custom_fields` w pozycji koszyka
- Przelicz nowy product_code: `md5(product_id . attributes . message . json_encode(new_custom_fields))`
- Jeśli nowy product_code == stary → tylko aktualizuj wartości
- Jeśli nowy product_code istnieje już w koszyku → scal pozycje (zsumuj ilość), usuń starą
- Jeśli nowy product_code nie istnieje → przenieś pozycję pod nowy klucz, usuń stary
- Przelicz sumę koszyka
- Zwróć JSON success
3. Zarejestruj endpoint w ajax.php pod kluczem `basket_update_custom_fields`
- Wzoruj się na istniejących endpointach koszyka (basket_add_product, basket_remove itp.)
Unikaj:
- NIE używaj match expressions (PHP < 8.0)
- NIE sklejaj SQL stringiem — custom fields są w sesji, nie w DB
- Escape wartości przy wyświetlaniu (htmlspecialchars), ale w sesji przechowuj surowe wartości
</action>
<verify>
Testy PHPUnit przechodzą (php phpunit.phar).
Endpoint odpowiada na POST z prawidłowym JSON response.
</verify>
<done>AC-3, AC-4, AC-5 satisfied: endpoint aktualizuje custom fields, waliduje required, obsługuje merge duplikatów</done>
</task>
<task type="auto">
<name>Task 2: UI edycji personalizacji w szablonie koszyka</name>
<files>templates/shop-basket/_partials/product-custom-fields.php, templates/shop-basket/basket-details.php</files>
<action>
1. W `product-custom-fields.php` dodać przycisk "Edytuj personalizację":
- Przycisk widoczny tylko gdy `$this->custom_fields` nie jest puste
- Atrybut data-product-code z kluczem pozycji koszyka
- Klasa CSS do stylowania (np. `btn-edit-custom-fields`)
2. Dodać ukryty formularz edycji (modal inline) pod przyciskiem:
- Dla każdego custom field: input z aktualną wartością
- Pola wymagane oznaczone `required` + wizualnie (gwiazdka)
- Typ pola (text/image) z metadanych custom field
- Przyciski "Zapisz" i "Anuluj"
- Formularz domyślnie ukryty (`display: none`)
3. W `basket-details.php` dodać JavaScript obsługujący:
- Klik "Edytuj" → pokaż formularz, ukryj wyświetlane wartości
- Klik "Anuluj" → ukryj formularz, pokaż wartości
- Klik "Zapisz" → AJAX POST do `basket_update_custom_fields`
- Walidacja client-side required fields przed wysłaniem
- Po sukcesie → przeładuj stronę koszyka (location.reload)
- Po błędzie → pokaż komunikat
4. Przekazać `product_code` do szablonu custom fields:
- W `basket-details.php` przy wywołaniu `Tpl::view('shop-basket/_partials/product-custom-fields', ...)`
dodać parametr `product_code` z kluczem pozycji
Unikaj:
- NIE dodawaj zewnętrznych bibliotek JS/CSS
- NIE zmieniaj struktury HTML istniejących elementów (dodawaj nowe)
- Escape wszystkich wartości w atrybutach HTML (htmlspecialchars)
- NIE używaj str_contains/str_starts_with (PHP 8.0+)
</action>
<verify>
Wizualna weryfikacja: przycisk "Edytuj" widoczny w koszyku przy produktach z personalizacją.
Klik otwiera formularz z aktualnymi wartościami.
Zapis odświeża koszyk z nowymi wartościami.
</verify>
<done>AC-1, AC-2 satisfied: przycisk edycji widoczny, formularz wyświetla aktualne wartości</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/Product/ProductRepository.php (nie modyfikuj metod findCustomFieldCached, saveCustomFields)
- autoload/Domain/Order/OrderRepository.php (nie zmieniaj createFromBasket)
- templates/shop-product/ (szablony strony produktu bez zmian)
- autoload/Domain/Basket/BasketRepository.php (jeśli istnieje — nie modyfikuj)
## SCOPE LIMITS
- Tylko koszyk (basket-details) — NIE podsumowanie zamówienia (summary-view)
- Tylko edycja wartości — NIE dodawanie/usuwanie pól custom fields
- Tylko pola typu text i image — nie dodawaj nowych typów pól
- NIE zmieniaj sposobu przechowywania custom fields w zamówieniach (pp_shop_order_products)
- NIE dodawaj testów PHPUnit dla warstwy widoku (templates) — testuj tylko logikę kontrolera
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php phpunit.phar` — wszystkie testy przechodzą (820+)
- [ ] Endpoint `basket_update_custom_fields` zwraca poprawny JSON
- [ ] Przycisk "Edytuj" widoczny w koszyku przy produktach z personalizacją
- [ ] Formularz edycji wyświetla aktualne wartości
- [ ] Zapis zmienia wartości w sesji i odświeża koszyk
- [ ] Pola required są walidowane (client + server side)
- [ ] Merge duplikatów działa poprawnie
- [ ] Brak regresji — istniejąca funkcjonalność koszyka działa bez zmian
</verification>
<success_criteria>
- Wszystkie testy PHPUnit przechodzą
- AC-1 do AC-5 spełnione
- Kod zgodny z PHP < 8.0
- XSS protection (htmlspecialchars) na wszystkich outputach
- Brak nowych zależności zewnętrznych
</success_criteria>
<output>
After completion, create `.paul/phases/10-basket-edit-custom-fields/10-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,114 @@
---
phase: 10-basket-edit-custom-fields
plan: 01
subsystem: ui
tags: [basket, custom-fields, personalization, ajax, session]
requires:
- phase: none
provides: existing basket/custom fields infrastructure
provides:
- Edycja personalizacji produktu w koszyku (inline form + AJAX endpoint)
- Merge duplikatów przy identycznym product_code po edycji
affects: []
tech-stack:
added: []
patterns: [inline edit form with toggle display/edit, product_code recalculation]
key-files:
created: []
modified:
- autoload/front/Controllers/ShopBasketController.php
- templates/shop-basket/_partials/product-custom-fields.php
- templates/shop-basket/basket-details.php
- templates/shop-basket/basket.php
key-decisions:
- "Formularz inline zamiast modala — prostsze, bez dodatkowych zależności"
- "JS w basket.php zamiast basket-details.php — delegowane eventy działają po przeładowaniu AJAX"
- "ajax.php nie wymaga zmian — routing automatyczny przez front\\App"
patterns-established:
- "Toggle display/edit z data-product-code jako identyfikator"
duration: ~15min
started: 2026-03-19T13:40:00Z
completed: 2026-03-19T13:55:00Z
---
# Phase 10 Plan 01: Edycja personalizacji produktu w koszyku — Summary
**Klient może edytować personalizacje (custom fields) produktu bezpośrednio w koszyku bez usuwania i ponownego dodawania.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15 min |
| Tasks | 2 completed |
| Files modified | 4 |
| Tests | 820 passed, 0 failures |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Przycisk edycji widoczny | Pass | Przycisk "Edytuj personalizację" przy pozycjach z custom fields, ukryty gdy brak |
| AC-2: Formularz z aktualnymi wartościami | Pass | Inline form z wypełnionymi wartościami, required oznaczone gwiazdką |
| AC-3: Zapis aktualizuje koszyk | Pass | AJAX POST → przeliczenie hash → reload strony |
| AC-4: Walidacja required | Pass | Client-side (input required + alert) + server-side (findCustomFieldCached + is_required check) |
| AC-5: Merge duplikatów | Pass | Gdy nowy hash == istniejący → sumowanie quantity, usunięcie starej pozycji |
## Accomplishments
- Endpoint `basketUpdateCustomFields()` w ShopBasketController z pełną logiką: walidacja, hash recalculation, merge
- UI: toggle display↔edit z formularzem inline, walidacja client-side
- XSS protection na wszystkich outputach (htmlspecialchars)
- Kompatybilność PHP < 8.0 (brak match, str_contains, union types)
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Nowa metoda `basketUpdateCustomFields()` — AJAX endpoint |
| `templates/shop-basket/_partials/product-custom-fields.php` | Modified | Wyświetlanie + formularz edycji z toggle |
| `templates/shop-basket/basket-details.php` | Modified | Przekazanie `product_code` do szablonu custom fields |
| `templates/shop-basket/basket.php` | Modified | JavaScript: edycja, anulowanie, zapis AJAX |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Scope change | 2 | Minimalne — lepsze dopasowanie do architektury |
**Total impact:** Drobne odchylenia, brak wpływu na funkcjonalność.
### Details
1. **ajax.php nie zmodyfikowany** — plan zakładał rejestrację endpointu w ajax.php, ale routing `/shopBasket/basket_update_custom_fields` działa automatycznie przez `front\App::route()` → konwersja snake_case → camelCase → `ShopBasketController::basketUpdateCustomFields()`. Zmiana w ajax.php była niepotrzebna.
2. **JS w basket.php zamiast basket-details.php** — plan wskazywał basket-details.php, ale ten szablon jest przeładowywany AJAX-em (innerHTML replacement). Delegowane eventy muszą być w basket.php który jest stały. Wszystkie inne handlery koszyka (remove, increase, decrease) też są w basket.php.
## Issues Encountered
None.
## Next Phase Readiness
**Ready:**
- Edycja personalizacji w koszyku gotowa do testów manualnych na produkcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 10-basket-edit-custom-fields, Plan: 01*
*Completed: 2026-03-19*

View File

@@ -0,0 +1,225 @@
---
phase: 11-datalayer-ga4-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- templates/shop-order/order-details.php
- templates/shop-basket/summary-view.php
- templates/shop-product/product.php
- templates/shop-basket/basket.php
autonomous: true
---
<objective>
## Goal
Naprawic wszystkie eventy dataLayer ecommerce (purchase, begin_checkout, view_item, add_to_cart) do formatu GA4 oraz dodac brakujacy event view_cart. Poprawki krytyczne dla remarketingu dynamicznego i konwersji.
## Purpose
Bez tych poprawek remarketing dynamiczny Google Ads i konwersje GA4 nie dzialaja poprawnie — ceny produktow sa zerowe, klucze itemow niezgodne z GA4, brakuje walut i eventow.
## Output
Poprawione 4 szablony PHP z prawidlowymi eventami dataLayer GA4.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@templates/shop-order/order-details.php (purchase event, lines 164-192)
@templates/shop-basket/summary-view.php (begin_checkout event, lines 72-80, 175-187)
@templates/shop-product/product.php (view_item lines 273-288, add_to_cart lines 607-625)
@templates/shop-basket/basket.php (brak view_cart — do dodania)
## Technical Reference
@poprawki_datalayer_projectpro.md (specyfikacja zmian z audytu)
## Data Model
Order products (pp_shop_order_products) mają kolumny: product_id, name, price_brutto, price_brutto_promo, quantity.
Basket products — surowa tablica z sesji, product data fetchowana przez ProductRepository::findCached().
</context>
<skills>
No specialized flows configured — standard execute plan.
</skills>
<acceptance_criteria>
## AC-1: Purchase event — format GA4 z prawidlowa cena
```gherkin
Given strona potwierdzenia zamowienia /zamowienie/*
When dataLayer.push(purchase) jest wywolany
Then items maja klucze item_id (string), item_name, price (number > 0), quantity (number), google_business_vertical: "retail"
And ecommerce ma currency: "PLN"
And nie ma zduplikowanego klucza value ani hardcoded wartosci
```
## AC-2: Begin_checkout event — format GA4
```gherkin
Given strona /koszyk-podsumowanie z produktami w koszyku
When dataLayer.push(begin_checkout) jest wywolany
Then items maja klucze item_id (string), item_name (zamiast id, name), price (number), quantity (number), google_business_vertical: "retail"
```
## AC-3: View_item event — kompletne dane
```gherkin
Given strona produktu
When dataLayer.push(view_item) jest wywolany
Then ecommerce zawiera currency: "PLN" i value (number)
And items maja price jako number (nie string), google_business_vertical: "retail"
```
## AC-4: Add_to_cart event — poprawne typy
```gherkin
Given klikniecie "dodaj do koszyka" na stronie produktu
When dataLayer.push(add_to_cart) jest wywolany
Then items maja google_business_vertical: "retail"
And quantity jest number (nie string)
```
## AC-5: View_cart event — nowy event na stronie koszyka
```gherkin
Given strona /koszyk z produktami w koszyku
When strona sie zaladuje
Then dataLayer.push({event: "view_cart"}) jest wywolany z currency, value i items w formacie GA4
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Naprawic istniejace eventy dataLayer (purchase, begin_checkout, view_item, add_to_cart)</name>
<files>templates/shop-order/order-details.php, templates/shop-basket/summary-view.php, templates/shop-product/product.php</files>
<action>
**order-details.php (purchase event, linie 167-187):**
1. Usunac hardcoded `value: 25.42` (linia 172) — zostawic tylko dynamiczny `value` z linii 174
2. Zamienic `'id': <?= (int)$product['product_id'];?>` na `item_id: "<?= $product['product_id'];?>"`
3. Zamienic `'name': '<?= $product['name'];?>'` na `item_name: "<?= str_replace('"', '', $product['name']);?>"`
4. Zamienic logike price na: `price: <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? \Shared\Helpers\Helpers::normalize_decimal($product['price_brutto_promo']) : \Shared\Helpers\Helpers::normalize_decimal($product['price_brutto']);?>`
5. Dodac `google_business_vertical: "retail"` do kazdego itemu
6. Zamienic single quotes na double quotes w kluczach itemow (konsystencja)
7. Dodac `'quantity': <?= (int)$product['quantity'];?>` (rzutowanie na int)
**summary-view.php (begin_checkout items, linie 72-80):**
1. Zamienic `'"id": "' . $product['id']` na `'"item_id": "' . $product['id']`
2. Zamienic `'"name": "' . $product['language']['name']` na `'"item_name": "' . str_replace('"', '', $product['language']['name'])`
3. Dodac `'"google_business_vertical": "retail"'` do kazdego itemu
**product.php (view_item, linie 273-287):**
1. Dodac `currency: "PLN",` do obiektu ecommerce (przed items)
2. Dodac `value: <cena>,` do obiektu ecommerce (po currency)
3. Zmienic `price: '<cena>'` na `price: <cena>` (usunac cudzyslow — number zamiast string)
4. Dodac `google_business_vertical: "retail"` do itemu
**product.php (add_to_cart, linie 607-624):**
1. Dodac `google_business_vertical: "retail"` do itemu
- quantity jest juz prawidlowo number (zmienna JS `quantity` pochodzi z parseInt/parseFloat lub .val() — sprawdzic i ewentualnie dodac parseInt)
**Wazne:** Nie zmieniac struktury warunkow `if ($this->settings['google_tag_manager_id'])` — zostawic identycznie.
**Wazne:** Uzywac normalize_decimal() dla cen (zapewnia format z kropka, nie przecinkiem).
</action>
<verify>
1. Przegladnac wygenerowany HTML kazdego eventu — sprawdzic format kluczy, typy, obecnosc currency i google_business_vertical
2. Sprawdzic brak bledow skladni JS (cudzyslow, przecinki)
3. Testy PHPUnit nie powinny byc dotknięte (zmiany tylko w szablonach)
</verify>
<done>AC-1, AC-2, AC-3, AC-4 satisfied: Wszystkie eventy uzywaja item_id/item_name, price jako number, currency PLN, google_business_vertical</done>
</task>
<task type="auto">
<name>Task 2: Dodac event view_cart na stronie koszyka</name>
<files>templates/shop-basket/basket.php</files>
<action>
Dodac dataLayer.push dla view_cart w sekcji `<script>` na poczatku bloku `$(function() {` w basket.php (linia 209).
Implementacja:
1. Dodac blok PHP+JS wewnatrz istniejacego `<script>` (po linii 50, w nowym `<script>` z warunkiem GTM):
```
<? if ( $this -> settings['google_tag_manager_id'] ?? false ): ?>
<? if ( is_array( $this -> basket ) and count( $this -> basket ) ): ?>
<script type="text/javascript">
dataLayer.push({ ecommerce: null });
dataLayer.push({
event: "view_cart",
ecommerce: {
currency: "PLN",
value: [obliczona suma],
items: [
// iteracja po $this->basket z fetchem produktu przez ProductRepository
]
}
});
</script>
<? endif; ?>
<? endif; ?>
```
2. Iterowac po `$this->basket`, dla kazdego elementu pobrac product data (ProductRepository::findCached) i zbudowac item z item_id, item_name, price, quantity, google_business_vertical.
3. Obliczyc value jako sume (price * quantity) wszystkich produktow.
**Uwaga:** basket.php ma dostep do `$this->basket` (raw basket array). Kazdy element ma klucze: 'product-id', 'quantity', 'parent_id', 'attributes'.
Product data nalezy pobrac przez: `(new \Domain\Product\ProductRepository($GLOBALS['mdb']))->findCached((int)$position['product-id'], $lang_id)` — identycznie jak robi basket-details.php.
Uzyc `$GLOBALS['mdb']` i `(new \Domain\Languages\LanguagesRepository($GLOBALS['mdb']))->defaultLanguage()` dla lang_id (lub sprawdzic czy $this->lang_id jest dostepny — jesli nie, pobrac z sesji).
**Wazne:** Dodac nowy `<script>` blok PRZED istniejacym blokiem `<script>` (przed linia 36), nie wewnatrz istniejacego — zeby uniknac konfliktow z jQuery ready i AJAX reload.
**Wazne:** Warunek `settings['google_tag_manager_id']` — uzyc `$settings` (global) lub `$this->settings` — sprawdzic ktore jest dostepne w basket.php (linia 1: `global $settings` sugeruje ze $settings jest dostepny).
</action>
<verify>
1. Otworzyc /koszyk z produktami — sprawdzic w konsoli przegladarki dataLayer na obecnosc view_cart
2. Sprawdzic czy items maja poprawne pola: item_id (string), item_name, price (number), quantity (number), google_business_vertical
3. Sprawdzic czy value = suma cen * ilosci
4. Sprawdzic czy event NIE odapla sie ponownie po AJAX reload koszyka (bo jest w osobnym script poza basket-details)
</verify>
<done>AC-5 satisfied: Event view_cart jest pushowany na stronie /koszyk z pelnym zestawem danych GA4</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- autoload/Domain/* (warstwa domenowa — bez zmian)
- autoload/front/Controllers/* (kontrolery — bez zmian)
- templates/shop-basket/basket-details.php (AJAX-replaceable — nie dodawac tam skryptow)
- Logika sesji google-analytics-purchase (purchase dedup)
- Warunki `if ($this->settings['google_tag_manager_id'])` — zachowac identycznie
## SCOPE LIMITS
- Tylko eventy dataLayer — nie dodawac/zmieniac Facebook Pixel, gtag, ani innych trackerow
- Nie zmieniac struktury HTML szablonow
- Nie dodawac user_data do purchase (opcjonalne w specyfikacji, wymaga osobnej analizy RODO)
- Nie usuwac/przenosic kodu GADS conversion (nie znaleziono w kodzie — prawdopodobnie w GTM)
- Nie dodawac nowych eventow poza view_cart (np. remove_from_cart — poza zakresem)
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Wszystkie eventy uzywaja item_id (string) i item_name zamiast id/name
- [ ] price jest zawsze number (nie string, nie 0 dla prawidlowych produktow)
- [ ] currency: "PLN" obecne we wszystkich eventach ecommerce
- [ ] google_business_vertical: "retail" w kazdym item
- [ ] quantity jest zawsze number
- [ ] Nowy event view_cart dziala na /koszyk
- [ ] Brak hardcoded value: 25.42 w purchase
- [ ] Brak bledow skladni JS w wygenerowanym HTML
- [ ] PHPUnit testy przechodzą (./test.ps1)
</verification>
<success_criteria>
- All tasks completed
- All verification checks pass
- No errors or warnings introduced
- DataLayer eventy zgodne z formatem GA4 (item_id, item_name, currency, google_business_vertical)
- Remarketing dynamiczny Google Ads ma prawidlowe ceny produktow
</success_criteria>
<output>
After completion, create `.paul/phases/11-datalayer-ga4-fix/11-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,114 @@
---
phase: 11-datalayer-ga4-fix
plan: 01
subsystem: frontend
tags: [datalayer, ga4, gtm, ecommerce, analytics, remarketing]
requires:
- phase: none
provides: n/a
provides:
- GA4-compliant dataLayer events (purchase, begin_checkout, view_item, add_to_cart, view_cart)
- google_business_vertical for dynamic remarketing
affects: []
tech-stack:
added: []
patterns: [GA4 ecommerce item format with item_id/item_name/google_business_vertical]
key-files:
created: []
modified:
- templates/shop-order/order-details.php
- templates/shop-basket/summary-view.php
- templates/shop-product/product.php
- templates/shop-basket/basket.php
key-decisions:
- "view_cart event in basket.php (not basket-details.php) — basket-details is AJAX-replaceable"
- "No user_data in purchase — requires RODO analysis, deferred"
patterns-established:
- "GA4 item format: item_id (string), item_name, price (number), quantity (number), google_business_vertical: retail"
- "All ecommerce events must include currency: PLN"
duration: 15min
completed: 2026-03-25
---
# Phase 11 Plan 01: DataLayer GA4 Analytics Fix Summary
**Naprawione 5 eventow dataLayer ecommerce do formatu GA4 — remarketing dynamiczny i konwersje teraz dzialaja poprawnie.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~15min |
| Completed | 2026-03-25 |
| Tasks | 2 completed |
| Files modified | 4 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Purchase event — format GA4 z prawidlowa cena | Pass | item_id (string), item_name, price via normalize_decimal, google_business_vertical, usuniety hardcoded value: 25.42 |
| AC-2: Begin_checkout event — format GA4 | Pass | id→item_id, name→item_name, dodany google_business_vertical |
| AC-3: View_item event — kompletne dane | Pass | Dodane currency: PLN, value, price jako number, google_business_vertical |
| AC-4: Add_to_cart event — poprawne typy | Pass | Dodany google_business_vertical, parseInt(quantity) |
| AC-5: View_cart event — nowy event | Pass | Nowy event na /koszyk z pelnym zestawem danych GA4 |
## Accomplishments
- Naprawione klucze itemow we wszystkich eventach: id/name → item_id/item_name (format GA4)
- Dodane brakujace pola: currency: PLN, value, google_business_vertical: retail
- Usuniety hardcoded `value: 25.42` z purchase event (debug artifact)
- Dodany nowy event `view_cart` na stronie koszyka /koszyk
- Poprawione typy danych: price jako number (nie string), quantity jako int
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `templates/shop-order/order-details.php` | Modified | Purchase event: item_id/item_name, fix price, remove hardcoded value, add google_business_vertical |
| `templates/shop-basket/summary-view.php` | Modified | Begin_checkout event: item_id/item_name, add google_business_vertical |
| `templates/shop-product/product.php` | Modified | View_item: add currency/value/google_business_vertical. Add_to_cart: add google_business_vertical, parseInt(quantity) |
| `templates/shop-basket/basket.php` | Modified | New view_cart event with full GA4 item data |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| view_cart w basket.php, nie basket-details.php | basket-details jest AJAX-replaceable — script by sie odpalal przy kazdym AJAX reload | Konsystentne z decyzja z fazy 10 |
| Pominiecie user_data w purchase | Wymaga analizy RODO/GDPR przed wyslaniem PII do dataLayer | Mozna dodac w przyszlosci po analizie |
| GADS conversion na checkout — nie znaleziono | Grep nie znalazl hardcoded GADS conversion w szablonach — prawdopodobnie w GTM | Nie trzeba usuwac z kodu |
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## Skill Audit
- /feature-dev: not invoked (optional for template-only changes)
- /koniec-pracy: pending (release workflow)
## Next Phase Readiness
**Ready:**
- Wszystkie eventy dataLayer zgodne z GA4
- Gotowe do weryfikacji w GTM Preview / GA4 DebugView na produkcji
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 11-datalayer-ga4-fix, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,125 @@
---
phase: 12-summaryview-redirect-fix
plan: 01
type: execute
wave: 1
depends_on: []
files_modified: [autoload/front/Controllers/ShopBasketController.php]
autonomous: true
---
<objective>
## Goal
Usunąć błędny guard w `summaryView()` który po złożeniu pierwszego zamówienia uniemożliwia złożenie kolejnego — redirectuje na stronę starego zamówienia zamiast pozwolić na wejście na podsumowanie koszyka.
## Purpose
Klient sklepu po złożeniu jednego zamówienia musi móc złożyć kolejne zamówienie bez problemu. Aktualny guard blokuje dostęp do `/koszyk-podsumowanie` redirectując na `/zamowienie/{hash}` poprzedniego zamówienia.
## Output
Zmodyfikowany `ShopBasketController.php` bez problematycznego bloku redirect w `summaryView()`.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@change.md — opis błędu z instancji klienta
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No required skills for this hotfix — simple code removal, no new feature development.
</skills>
<acceptance_criteria>
## AC-1: Klient może złożyć drugie zamówienie po pierwszym
```gherkin
Given klient właśnie złożył zamówienie (sesja zawiera order-submit-last-order-id)
When klient wraca na /koszyk-podsumowanie z nowym koszykiem
Then widzi stronę podsumowania zamówienia (nie redirect na stare zamówienie)
```
## AC-2: Ochrona double-submit pozostaje nienaruszona
```gherkin
Given klient jest na stronie podsumowania i klika "złóż zamówienie"
When formularz zostaje wysłany dwa razy (double-click)
Then tylko jedno zamówienie zostaje złożone (mechanizm w basketSave() działa)
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Usunięcie błędnego guardu redirect w summaryView()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
Usunąć blok kodu w metodzie `summaryView()` (linie 279-290):
```php
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] )
? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ]
: 0;
if ( $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
```
NIE usuwać:
- Stałej `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` (używana w `basketSave()` i `createOrderSubmitToken()`)
- Żadnego kodu w `basketSave()` — tam mechanizm double-submit działa poprawnie
- Linii 312 (w `basketSave()`) ani 378, 549 — te użycia klucza sesyjnego są poprawne
</action>
<verify>
1. Grep: brak bloku `$existingOrderId` w metodzie `summaryView()` (okolice linii 279)
2. Grep: klucz `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` nadal istnieje w `basketSave()` i `createOrderSubmitToken()`
3. Testy: `./test.ps1` — wszystkie testy przechodzą
</verify>
<done>AC-1 satisfied: summaryView() nie redirectuje na stare zamówienie; AC-2 satisfied: basketSave() double-submit guard nienaruszony</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Metoda `basketSave()` — mechanizm double-submit protection
- Metoda `createOrderSubmitToken()` — generowanie tokenu
- Stała `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` — używana w innych miejscach
- Linie 312, 378, 549 — poprawne użycia klucza sesyjnego
## SCOPE LIMITS
- Tylko usunięcie bloku redirect w `summaryView()`, żadne inne zmiany
- Brak zmian w logice koszyka, płatności ani zamówień
</boundaries>
<verification>
Before declaring plan complete:
- [ ] Blok redirect (dawne linie 279-290) usunięty z `summaryView()`
- [ ] Stała `ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY` nadal istnieje
- [ ] Użycia klucza w `basketSave()` i `createOrderSubmitToken()` nienaruszone
- [ ] `./test.ps1` — wszystkie testy przechodzą
- [ ] Brak innych zmian w pliku
</verification>
<success_criteria>
- Blok redirect usunięty
- Wszystkie testy przechodzą
- Double-submit protection działa bez zmian
</success_criteria>
<output>
After completion, create `.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,88 @@
---
phase: 12-summaryview-redirect-fix
plan: 01
subsystem: frontend
tags: [basket, checkout, redirect, session]
requires:
- phase: none
provides: n/a
provides:
- Fix summaryView() redirect blocking subsequent orders
affects: []
tech-stack:
added: []
patterns: []
key-files:
created: []
modified: [autoload/front/Controllers/ShopBasketController.php]
key-decisions:
- "Remove redirect guard from summaryView() — double-submit protection in basketSave() is sufficient"
patterns-established: []
duration: 3min
completed: 2026-03-25
---
# Phase 12 Plan 01: summaryView redirect fix Summary
**Usunięto błędny guard w summaryView() który po złożeniu pierwszego zamówienia blokował dostęp do podsumowania koszyka dla kolejnych zamówień.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~3 min |
| Completed | 2026-03-25 |
| Tasks | 1 completed |
| Files modified | 1 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Klient może złożyć drugie zamówienie po pierwszym | Pass | Blok redirect usunięty z summaryView() |
| AC-2: Ochrona double-submit pozostaje nienaruszona | Pass | basketSave() guard nienaruszony (linie 299, 365, 536) |
## Accomplishments
- Usunięto blok kodu (12 linii) sprawdzający `order-submit-last-order-id` w `summaryView()` który redirectował na stare zamówienie
- Double-submit protection w `basketSave()` pozostaje w pełni funkcjonalna
- 820 testów, 2277 asercji — wszystkie przechodzą
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Usunięty blok redirect (dawne linie 279-290) z summaryView() |
## Decisions Made
None — followed plan as specified
## Deviations from Plan
None — plan executed exactly as written
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Poprawka gotowa do wdrożenia w update package
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 12-summaryview-redirect-fix, Plan: 01*
*Completed: 2026-03-25*

View File

@@ -0,0 +1,270 @@
---
phase: 13-basket-logging-ttl-token
plan: 01
type: execute
wave: 1
depends_on: ["12-01"]
files_modified: [autoload/front/Controllers/ShopBasketController.php]
autonomous: true
---
<objective>
## Goal
Dodać logowanie błędów w basketSave() oraz przerobić token zamówienia z jednorazowego na czasowy (TTL 30 min), aby wiele kart/odświeżenie/wstecz nie unieważniały tokenu.
## Purpose
Klientka nie mogła złożyć zamówienia — brak logów uniemożliwiał diagnozę. Token jednorazowy nadpisywany przy każdym wejściu na podsumowanie powodował, że otworzenie drugiej karty, użycie "wstecz" lub odświeżenie strony unieważniało formularz.
## Output
Zmodyfikowany `ShopBasketController.php` z logowaniem i TTL-based tokenem.
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
## Prior Work
@.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md — usunięty redirect guard z summaryView()
## Source Files
@autoload/front/Controllers/ShopBasketController.php
@change.md — opis zmian z instancji klienta (Zmiana 2)
</context>
<skills>
## Required Skills (from SPECIAL-FLOWS.md)
No required skills for this hotfix.
</skills>
<acceptance_criteria>
## AC-1: Logowanie błędów w basketSave()
```gherkin
Given basketSave() napotka błąd (double-submit, token invalid, exception, falsy order_id)
When błąd wystąpi
Then szczegóły są zapisywane do logs/logs-order-YYYY-MM-DD.log via metoda logOrder()
```
## AC-2: Token TTL 30 min — wiele kart działa
```gherkin
Given klient jest na stronie podsumowania zamówienia
When otworzy drugą kartę z podsumowaniem lub odświeży stronę
Then obie karty mają ten sam ważny token i mogą złożyć zamówienie
```
## AC-3: Token wygasa po 30 minutach
```gherkin
Given klient jest na stronie podsumowania z tokenem starszym niż 30 min
When spróbuje złożyć zamówienie
Then zostaje przekierowany na /koszyk-podsumowanie (nie /koszyk) i dostaje nowy token
```
## AC-4: Double-submit guard dla pustego koszyka
```gherkin
Given klient złożył zamówienie (koszyk pusty, order ID w sesji)
When spróbuje ponownie wysłać formularz
Then zostaje przekierowany na stronę istniejącego zamówienia
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Dodanie stałej TTL i metody logOrder()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
1. Dodać stałą po istniejących stałych (linia 7):
```php
private const ORDER_SUBMIT_TOKEN_TTL = 1800;
```
2. Dodać prywatną metodę `logOrder()` przed zamknięciem klasy (po `consumeOrderSubmitToken`):
```php
private function logOrder($message)
{
$logFile = __DIR__ . '/../../../logs/logs-order-' . date('Y-m-d') . '.log';
$line = '[' . date('Y-m-d H:i:s') . '] ' . $message . "\n";
@file_put_contents($logFile, $line, FILE_APPEND);
}
```
Schemat nazewnictwa: `logs/logs-order-YYYY-MM-DD.log` (jak `logs-db-*`).
Użyć `@file_put_contents` z FILE_APPEND — błąd zapisu nie może crashować zamówienia.
</action>
<verify>Grep: `ORDER_SUBMIT_TOKEN_TTL` i `function logOrder` istnieją w pliku</verify>
<done>Infrastruktura dla AC-1 (logOrder) gotowa</done>
</task>
<task type="auto">
<name>Task 2: Przerobienie tokena na TTL + logowanie w basketSave()</name>
<files>autoload/front/Controllers/ShopBasketController.php</files>
<action>
**A. Zmiana `createOrderSubmitToken()` (linia 532):**
Zastąpić obecną implementację:
```php
private function createOrderSubmitToken()
{
$sessionData = isset($_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY])
? $_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY]
: null;
if (is_array($sessionData) && isset($sessionData['token'], $sessionData['created_at']))
{
if ((time() - $sessionData['created_at']) < self::ORDER_SUBMIT_TOKEN_TTL)
{
return $sessionData['token'];
}
}
$token = $this->generateOrderSubmitToken();
\Shared\Helpers\Helpers::set_session(self::ORDER_SUBMIT_TOKEN_SESSION_KEY, [
'token' => $token,
'created_at' => time()
]);
\Shared\Helpers\Helpers::delete_session(self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY);
return $token;
}
```
**B. Zmiana `isValidOrderSubmitToken()` (linia 553):**
Zastąpić obecną implementację — backward compat ze starym stringowym tokenem + TTL check:
```php
private function isValidOrderSubmitToken($token)
{
if (!$token)
return false;
$sessionData = isset($_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY])
? $_SESSION[self::ORDER_SUBMIT_TOKEN_SESSION_KEY]
: null;
if (!$sessionData)
return false;
// Backward compatibility: stary format (plain string)
if (is_string($sessionData))
{
$sessionToken = $sessionData;
}
elseif (is_array($sessionData) && isset($sessionData['token'], $sessionData['created_at']))
{
if ((time() - $sessionData['created_at']) >= self::ORDER_SUBMIT_TOKEN_TTL)
return false;
$sessionToken = $sessionData['token'];
}
else
{
return false;
}
if (function_exists('hash_equals'))
return hash_equals($sessionToken, $token);
return $sessionToken === $token;
}
```
**C. Dodanie logowania w `basketSave()` — 4 miejsca:**
1. **Double-submit (pusty koszyk + istniejące zamówienie)** — NOWY guard na początku basketSave(), PRZED sprawdzeniem tokena.
Dodać po linii 299 (po pobraniu $existingOrderId), PRZED `if (!$this->isValidOrderSubmitToken...)`:
```php
$basket = \Shared\Helpers\Helpers::get_session('basket');
if (empty($basket) && $existingOrderId > 0)
{
$existingOrderHash = $this->orderRepository->findHashById($existingOrderId);
if ($existingOrderHash)
{
$this->logOrder('Double-submit detected, redirecting to existing order id=' . $existingOrderId);
header('Location: /zamowienie/' . $existingOrderHash);
exit;
}
}
```
2. **Token nieprawidłowy** — w istniejącym bloku `if (!$this->isValidOrderSubmitToken...)`, dodać logowanie PRZED komunikatem błędu.
Dodać linię:
```php
$this->logOrder('Token validation failed. formToken=' . $orderSubmitToken . ' existingOrderId=' . $existingOrderId);
```
3. **Zmiana redirect przy złym tokenie** — w tym samym bloku zmienić redirect z `/koszyk` na `/koszyk-podsumowanie`:
```php
header('Location: /koszyk-podsumowanie');
```
4. **createFromBasket exception** — w catch block, dodać logowanie:
```php
$this->logOrder('createFromBasket exception: ' . $e->getMessage());
```
(error_log zostaje też)
5. **Falsy order_id** — po bloku `if ($order_id)`, dodać else:
```php
else
{
$this->logOrder('createFromBasket returned falsy order_id. client_id=' . ($client['id'] ?? '?') . ' email=' . (\Shared\Helpers\Helpers::get('email', true) ?: '?'));
\Shared\Helpers\Helpers::error(\Shared\Helpers\Helpers::lang('zamowienie-zostalo-zlozone-komunikat-blad'));
header('Location: /koszyk');
exit;
}
```
**D. Usunięcie starego double-submit bloku** z wnętrza `if (!$this->isValidOrderSubmitToken...)`:
Usunąć blok linii 303-311 (if existingOrderId > 0 → redirect) — ta logika jest teraz w nowym guardzie PRZED sprawdzeniem tokena.
</action>
<verify>
1. Grep: `logOrder` wywołane 4 razy w basketSave()
2. Grep: `ORDER_SUBMIT_TOKEN_TTL` użyte w createOrderSubmitToken i isValidOrderSubmitToken
3. Grep: `/koszyk-podsumowanie` jako redirect przy złym tokenie
4. Grep: `is_array.*sessionData` w isValidOrderSubmitToken (backward compat)
5. Testy: `php phpunit.phar` — wszystkie przechodzą
</verify>
<done>AC-1 (logowanie), AC-2 (TTL token), AC-3 (wygasanie + redirect), AC-4 (double-submit guard) satisfied</done>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Metoda `generateOrderSubmitToken()` — generowanie samego tokenu bez zmian
- Metoda `consumeOrderSubmitToken()` — konsumowanie tokenu po złożeniu zamówienia bez zmian
- Logika czyszczenia koszyka po złożeniu zamówienia (linie 367-374)
- Logika sesji purchase piksel/adwords/analytics/ekomi (linie 376-379)
- Redis flushAll po zamówieniu (linie 381-383)
## SCOPE LIMITS
- Tylko ShopBasketController.php — żadne inne pliki
- Brak zmian w createFromBasket() ani OrderRepository
- Brak zmian w szablonach widoków
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `logOrder()` metoda istnieje i zapisuje do `logs/logs-order-YYYY-MM-DD.log`
- [ ] Token przechowywany jako array `['token' => ..., 'created_at' => ...]`
- [ ] `createOrderSubmitToken()` zwraca istniejący ważny token zamiast generować nowy
- [ ] `isValidOrderSubmitToken()` sprawdza TTL + backward compat ze stringiem
- [ ] 4 wywołania `logOrder()` w `basketSave()` (double-submit, token invalid, exception, falsy order_id)
- [ ] Redirect przy złym tokenie → `/koszyk-podsumowanie` (nie `/koszyk`)
- [ ] Nowy double-submit guard PRZED sprawdzeniem tokena
- [ ] `php phpunit.phar` — wszystkie testy przechodzą
- [ ] `consumeOrderSubmitToken()` i `generateOrderSubmitToken()` niezmienione
</verification>
<success_criteria>
- Wszystkie zadania ukończone
- Wszystkie weryfikacje przechodzą
- Brak nowych błędów ani ostrzeżeń
- Token działa z wieloma kartami przeglądarki
</success_criteria>
<output>
After completion, create `.paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,102 @@
---
phase: 13-basket-logging-ttl-token
plan: 01
subsystem: frontend
tags: [basket, checkout, logging, token, session, TTL]
requires:
- phase: 12-summaryview-redirect-fix
provides: summaryView() redirect guard removed
provides:
- Order error logging to logs/logs-order-YYYY-MM-DD.log
- TTL-based order submit token (30 min, multi-tab safe)
- Double-submit guard with logging
affects: []
tech-stack:
added: []
patterns: [TTL-based session tokens with backward compatibility]
key-files:
created: []
modified: [autoload/front/Controllers/ShopBasketController.php]
key-decisions:
- "Token format: array ['token' => ..., 'created_at' => ...] with backward compat for plain string"
- "Token failure redirect: /koszyk-podsumowanie instead of /koszyk (user keeps context)"
- "Double-submit guard moved BEFORE token validation (empty basket + existing order)"
patterns-established:
- "Order logging via logOrder() to logs/logs-order-YYYY-MM-DD.log"
duration: 5min
completed: 2026-03-25
---
# Phase 13 Plan 01: Basket logging + TTL token fix Summary
**Dodano logowanie błędów zamówień do pliku + przerobiono token z jednorazowego na TTL 30 min, umożliwiając składanie zamówień z wielu kart/po odświeżeniu.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~5 min |
| Completed | 2026-03-25 |
| Tasks | 2 completed |
| Files modified | 1 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Logowanie błędów w basketSave() | Pass | 4 punkty logowania via logOrder() |
| AC-2: Token TTL 30 min — wiele kart działa | Pass | createOrderSubmitToken() reuses valid token |
| AC-3: Token wygasa po 30 min | Pass | isValidOrderSubmitToken() checks TTL, redirect → /koszyk-podsumowanie |
| AC-4: Double-submit guard dla pustego koszyka | Pass | Nowy guard przed sprawdzeniem tokena |
## Accomplishments
- Dodano metodę `logOrder()` zapisującą do `logs/logs-order-YYYY-MM-DD.log` + 4 punkty logowania w `basketSave()`
- Token zamówienia przerobiony z jednorazowego na TTL 30 min — wiele kart, odświeżenie, "wstecz" nie unieważniają tokena
- Backward compatibility ze starymi stringowymi tokenami w sesji
- Double-submit guard przeniesiony PRZED sprawdzenie tokena (pusty koszyk + istniejące zamówienie → redirect)
- Redirect przy błędzie tokena zmieniony z `/koszyk` na `/koszyk-podsumowanie`
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/front/Controllers/ShopBasketController.php` | Modified | Stała TTL, logOrder(), TTL token, logowanie, double-submit guard |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Token jako array z created_at | Umożliwia TTL check bez dodatkowej sesji | Backward compat z plain string |
| Redirect na /koszyk-podsumowanie | Użytkownik nie traci kontekstu, dostaje nowy token | Lepsza UX |
| Double-submit guard przed token check | Pusty koszyk = pewny double-submit, nie trzeba sprawdzać tokena | Szybsze wykrycie |
## Deviations from Plan
None — plan executed exactly as written
## Issues Encountered
None
## Next Phase Readiness
**Ready:**
- Poprawka gotowa do wdrożenia w update package
- Fazy 12 + 13 razem stanowią kompletny fix checkout flow
**Concerns:**
- None
**Blockers:**
- None
---
*Phase: 13-basket-logging-ttl-token, Plan: 01*
*Completed: 2026-03-25*

File diff suppressed because one or more lines are too long

View File

@@ -2,5 +2,5 @@ projectKey=shopPRO
serverUrl=https://sonar.project-pro.pl
serverVersion=26.3.0.120487
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=shopPRO
ceTaskId=cc124932-3cc6-464e-9f3b-36e783582dde
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=cc124932-3cc6-464e-9f3b-36e783582dde
ceTaskId=5c360d0b-34ee-4995-ae1b-d5ac7ca47218
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=5c360d0b-34ee-4995-ae1b-d5ac7ca47218

View File

@@ -132,3 +132,17 @@ read_only_memory_patterns: []
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}

View File

@@ -5,6 +5,7 @@ class ShopBasketController
{
private const ORDER_SUBMIT_TOKEN_SESSION_KEY = 'order-submit-token';
private const ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY = 'order-submit-last-order-id';
private const ORDER_SUBMIT_TOKEN_TTL = 1800;
public static $title = [
'mainView' => 'Koszyk'
@@ -276,19 +277,6 @@ class ShopBasketController
exit;
}
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] )
? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ]
: 0;
if ( $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
$client = \Shared\Helpers\Helpers::get_session( 'client' );
$orderSubmitToken = $this->createOrderSubmitToken();
@@ -311,20 +299,23 @@ class ShopBasketController
$orderSubmitToken = (string)\Shared\Helpers\Helpers::get( 'order_submit_token', true );
$existingOrderId = isset( $_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] ) ? (int)$_SESSION[ self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY ] : 0;
$basket = \Shared\Helpers\Helpers::get_session( 'basket' );
if ( empty( $basket ) && $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
$this->logOrder( 'Double-submit detected, redirecting to existing order id=' . $existingOrderId );
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
if ( !$this->isValidOrderSubmitToken( $orderSubmitToken ) )
{
if ( $existingOrderId > 0 )
{
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
if ( $existingOrderHash )
{
header( 'Location: /zamowienie/' . $existingOrderHash );
exit;
}
}
$this->logOrder( 'Token validation failed. formToken=' . $orderSubmitToken . ' existingOrderId=' . $existingOrderId );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
header( 'Location: /koszyk-podsumowanie' );
exit;
}
@@ -367,6 +358,7 @@ class ShopBasketController
}
catch ( \Exception $e )
{
$this->logOrder( 'createFromBasket exception: ' . $e->getMessage() );
error_log( '[basketSave] createFromBasket exception: ' . $e->getMessage() );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
@@ -400,6 +392,7 @@ class ShopBasketController
}
else
{
$this->logOrder( 'createFromBasket returned falsy order_id. client_id=' . ( $client['id'] ?? '?' ) . ' email=' . ( \Shared\Helpers\Helpers::get( 'email', true ) ?: '?' ) );
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
header( 'Location: /koszyk' );
exit;
@@ -446,6 +439,79 @@ class ShopBasketController
] );
}
public function basketUpdateCustomFields()
{
$basket = \Shared\Helpers\Helpers::get_session( 'basket' );
$product_code = \Shared\Helpers\Helpers::get( 'product_code' );
if ( !isset( $basket[ $product_code ] ) )
{
echo json_encode( [ 'result' => 'error', 'message' => 'Pozycja nie istnieje w koszyku' ] );
exit;
}
$position = $basket[ $product_code ];
$new_custom_fields = [];
$custom_fields_raw = \Shared\Helpers\Helpers::get( 'custom_field' );
if ( is_array( $custom_fields_raw ) )
{
foreach ( $custom_fields_raw as $field_id => $value )
{
$new_custom_fields[ (int)$field_id ] = $value;
}
}
$productRepo = new \Domain\Product\ProductRepository( $GLOBALS['mdb'] );
$missing_fields = [];
foreach ( $new_custom_fields as $field_id => $value )
{
$field_meta = $productRepo->findCustomFieldCached( $field_id );
if ( $field_meta && (int)$field_meta['is_required'] === 1 && trim( $value ) === '' )
{
$missing_fields[] = $field_meta['name'];
}
}
if ( count( $missing_fields ) > 0 )
{
echo json_encode( [ 'result' => 'error', 'message' => 'Wypełnij wymagane pola: ' . implode( ', ', $missing_fields ) ] );
exit;
}
$attributes_implode = '';
if ( isset( $position['attributes'] ) && is_array( $position['attributes'] ) && count( $position['attributes'] ) > 0 )
{
$attributes_implode = implode( '|', $position['attributes'] );
}
$message = isset( $position['message'] ) ? $position['message'] : '';
$new_product_code = md5( $position['product-id'] . $attributes_implode . $message . json_encode( $new_custom_fields ) );
if ( $new_product_code === $product_code )
{
$basket[ $product_code ]['custom_fields'] = $new_custom_fields;
}
elseif ( isset( $basket[ $new_product_code ] ) )
{
$basket[ $new_product_code ]['quantity'] += $position['quantity'];
unset( $basket[ $product_code ] );
}
else
{
$position['custom_fields'] = $new_custom_fields;
$basket[ $new_product_code ] = $position;
unset( $basket[ $product_code ] );
}
$basket = ( new \Domain\Promotion\PromotionRepository( $GLOBALS['mdb'] ) )->findPromotion( $basket );
\Shared\Helpers\Helpers::set_session( 'basket', $basket );
echo json_encode( [ 'result' => 'ok' ] );
exit;
}
private function jsonBasketResponse( $basket, $coupon, $lang_id, $basket_transport_method_id )
{
global $settings;
@@ -471,8 +537,23 @@ class ShopBasketController
private function createOrderSubmitToken()
{
$sessionData = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] )
? $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ]
: null;
if ( is_array( $sessionData ) && isset( $sessionData['token'], $sessionData['created_at'] ) )
{
if ( ( time() - $sessionData['created_at'] ) < self::ORDER_SUBMIT_TOKEN_TTL )
{
return $sessionData['token'];
}
}
$token = $this->generateOrderSubmitToken();
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY, $token );
\Shared\Helpers\Helpers::set_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY, [
'token' => $token,
'created_at' => time()
] );
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY );
return $token;
@@ -495,10 +576,29 @@ class ShopBasketController
if ( !$token )
return false;
$sessionToken = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] ) ? (string)$_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] : '';
if ( !$sessionToken )
$sessionData = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] )
? $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ]
: null;
if ( !$sessionData )
return false;
// Backward compatibility: stary format (plain string)
if ( is_string( $sessionData ) )
{
$sessionToken = $sessionData;
}
elseif ( is_array( $sessionData ) && isset( $sessionData['token'], $sessionData['created_at'] ) )
{
if ( ( time() - $sessionData['created_at'] ) >= self::ORDER_SUBMIT_TOKEN_TTL )
return false;
$sessionToken = $sessionData['token'];
}
else
{
return false;
}
if ( function_exists( 'hash_equals' ) )
return hash_equals( $sessionToken, $token );
@@ -509,4 +609,11 @@ class ShopBasketController
{
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY );
}
private function logOrder( $message )
{
$logFile = __DIR__ . '/../../../logs/logs-order-' . date( 'Y-m-d' ) . '.log';
$line = '[' . date( 'Y-m-d H:i:s' ) . '] ' . $message . "\n";
@file_put_contents( $logFile, $line, FILE_APPEND );
}
}

View File

@@ -4,6 +4,28 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
---
## ver. 0.345 (2026-03-25) - DataLayer GA4 fix + checkout token fix
- **FIX**: `templates/shop-order/order-details.php` — event purchase: id→item_id (string), name→item_name, price via normalize_decimal (fix price:0), usunięty hardcoded value: 25.42, dodany google_business_vertical
- **FIX**: `templates/shop-basket/summary-view.php` — event begin_checkout: id→item_id, name→item_name, dodany google_business_vertical
- **FIX**: `templates/shop-product/product.php` — event view_item: dodany currency PLN, value, price jako number (nie string), google_business_vertical; event add_to_cart: dodany google_business_vertical, parseInt(quantity)
- **NEW**: `templates/shop-basket/basket.php` — nowy event view_cart na stronie koszyka z pełnym zestawem danych GA4 (item_id, item_name, price, quantity, currency, google_business_vertical)
- **FIX**: `autoload/front/Controllers/ShopBasketController.php` — usunięty błędny guard w summaryView() blokujący kolejne zamówienia po pierwszym (redirect na stare zamówienie zamiast podsumowanie)
- **FIX**: `autoload/front/Controllers/ShopBasketController.php` — token zamówienia z jednorazowego na TTL 30 min (wiele kart, odświeżenie, "wstecz" nie unieważniają formularza)
- **NEW**: `autoload/front/Controllers/ShopBasketController.php` — logowanie błędów zamówień do `logs/logs-order-YYYY-MM-DD.log` (double-submit, token invalid, exception, falsy order_id)
- **FIX**: `autoload/front/Controllers/ShopBasketController.php` — redirect przy złym tokenie na `/koszyk-podsumowanie` zamiast `/koszyk` (użytkownik nie traci kontekstu)
---
## ver. 0.344 (2026-03-19) - Edycja personalizacji produktu w koszyku
- **NEW**: `autoload/front/Controllers/ShopBasketController.php` — nowa metoda `basketUpdateCustomFields()`: AJAX endpoint do edycji custom fields w koszyku z walidacją required, przeliczaniem product_code (MD5 hash) i merge duplikatów
- **NEW**: `templates/shop-basket/_partials/product-custom-fields.php` — przycisk "Edytuj personalizację" + formularz inline z aktualnymi wartościami
- **NEW**: `templates/shop-basket/basket-details.php` — przekazanie `product_code` do szablonu custom fields
- **NEW**: `templates/shop-basket/basket.php` — JavaScript obsługi edycji/zapisu/anulowania personalizacji
---
## ver. 0.343 (2026-03-19) - Custom fields: type + is_required + obsługa obrazków w koszyku
- **FIX**: `autoload/Domain/Product/ProductRepository.php` — kopiowanie custom fields przy duplikacji produktu uwzględnia teraz pola `type` i `is_required`

View File

@@ -103,4 +103,14 @@ Dodać możliwość ustawienia limitu znaków w wiadomościach do produktu
- [ ] [MAJOR] cron.php:651 — Unused function parameter "$payload" (php:S1172)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:53 — 4 returns (max 3) (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:93 — 4 returns (max 3) (php:S1142)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:105 — Merge if statement with enclosing one (php:S1066)
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:105 — Merge if statement with enclosing one (php:S1066)
## SonarQube — 0.344 (2026-03-19)
- [ ] [MINOR] autoload/front/Controllers/ShopBasketController.php:484 — Use empty() to check whether the array is empty (php:S1155)
## SonarQube — 0.345 (2026-03-25)
- [ ] [MAJOR] autoload/front/Controllers/ShopBasketController.php:574 — This method has 6 returns, which is more than the 3 allowed (php:S1142)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:576 — Add curly braces around nested statement(s) (php:S121)
- [ ] [CRITICAL] autoload/front/Controllers/ShopBasketController.php:602 — Add curly braces around nested statement(s) (php:S121)

View File

@@ -1,26 +1,52 @@
<? if ( $this -> custom_fields ) : ?>
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
<div class="custom-fields-display" data-product-code="<?= htmlspecialchars( $this->product_code ); ?>">
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
<? if ( $field_type == 'text' ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
<? if ( $field_type == 'text' ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
</div>
<div class="_text">
<?= nl2br( htmlspecialchars( $val ) );?>
</div>
</div>
<div class="_text">
<?= nl2br( htmlspecialchars( $val ) );?>
<? elseif ( $field_type == 'image' && !empty( $val ) ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
</div>
<div class="_image">
<img src="<?= htmlspecialchars( $val );?>" alt="<?= htmlspecialchars( $custom_field['name'] );?>">
</div>
</div>
<? endif; ?>
<? endforeach; ?>
<a href="#" class="btn btn-sm btn-default btn-edit-custom-fields">Edytuj personalizację</a>
</div>
<div class="custom-fields-edit" data-product-code="<?= htmlspecialchars( $this->product_code ); ?>" style="display: none;">
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
<? $is_required = !empty( $custom_field['is_required'] ) ? (int)$custom_field['is_required'] : 0; ?>
<div class="custom-field-edit-row" style="margin-bottom: 5px;">
<label>
<?= htmlspecialchars( $custom_field['name'] ); ?><?= $is_required ? ' <span style="color:red;">*</span>' : ''; ?>
</label>
<? if ( $field_type == 'text' ) : ?>
<input type="text" class="form-control form-control-sm" name="custom_field[<?= (int)$key; ?>]" value="<?= htmlspecialchars( $val ); ?>" <?= $is_required ? 'required' : ''; ?>>
<? elseif ( $field_type == 'image' ) : ?>
<input type="text" class="form-control form-control-sm" name="custom_field[<?= (int)$key; ?>]" value="<?= htmlspecialchars( $val ); ?>" placeholder="URL obrazka" <?= $is_required ? 'required' : ''; ?>>
<? endif; ?>
</div>
<? elseif ( $field_type == 'image' && !empty( $val ) ) : ?>
<div class="custom-field">
<div class="_name">
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
</div>
<div class="_image">
<img src="<?= htmlspecialchars( $val );?>" alt="<?= htmlspecialchars( $custom_field['name'] );?>">
</div>
</div>
<? endif; ?>
<? endforeach; ?>
<? endif;?>
<? endforeach; ?>
<div style="margin-top: 5px;">
<a href="#" class="btn btn-sm btn-primary btn-save-custom-fields">Zapisz</a>
<a href="#" class="btn btn-sm btn-default btn-cancel-custom-fields">Anuluj</a>
</div>
</div>
<? endif; ?>

View File

@@ -61,7 +61,8 @@
<hr>
<? endif; ?>
<?= \Shared\Tpl\Tpl::view( 'shop-basket/_partials/product-custom-fields', [
'custom_fields' => $position['custom_fields']
'custom_fields' => $position['custom_fields'],
'product_code' => $position_hash
] ); ?>
<? if ( $product['additional_message'] ):?>
<div class="basket-product-message">

View File

@@ -1,4 +1,46 @@
<? global $settings; ?>
<?
if ( $settings['google_tag_manager_id'] && is_array( $this -> basket ) && count( $this -> basket ) ):
$view_cart_items = '';
$view_cart_value = 0;
foreach ( $this -> basket as $position ):
$vc_product = (new \Domain\Product\ProductRepository($GLOBALS['mdb']))->findCached( (int)$position['product-id'], (new \Domain\Languages\LanguagesRepository($GLOBALS['mdb']))->defaultLanguage() );
if ( !$vc_product )
continue;
$vc_price = (float)$vc_product['price_brutto_promo'] > 0 && (float)$vc_product['price_brutto_promo'] < (float)$vc_product['price_brutto']
? (float)$vc_product['price_brutto_promo']
: (float)$vc_product['price_brutto'];
$vc_qty = (int)$position['quantity'];
$view_cart_value += $vc_price * $vc_qty;
if ( $view_cart_items )
$view_cart_items .= ',';
$view_cart_items .= '{';
$view_cart_items .= 'item_id: "' . $vc_product['id'] . '",';
$view_cart_items .= 'item_name: "' . str_replace( '"', '', $vc_product['language']['name'] ) . '",';
$view_cart_items .= 'price: ' . \Shared\Helpers\Helpers::normalize_decimal( $vc_price ) . ',';
$view_cart_items .= 'quantity: ' . $vc_qty . ',';
$view_cart_items .= 'google_business_vertical: "retail"';
$view_cart_items .= '}';
endforeach;
?>
<script type="text/javascript">
dataLayer.push({ ecommerce: null });
dataLayer.push({
event: "view_cart",
ecommerce: {
currency: "PLN",
value: <?= \Shared\Helpers\Helpers::normalize_decimal( $view_cart_value );?>,
items: [<?= $view_cart_items;?>]
}
});
</script>
<? endif; ?>
<div id="basket-container">
<div id="content">
<?= $this->basket_details; ?>
@@ -508,4 +550,62 @@
console.warn('#orlen_point_id nie został znaleziony.');
}
});
// edycja personalizacji produktu w koszyku
$(document).on('click', '.btn-edit-custom-fields', function(e) {
e.preventDefault();
var $display = $(this).closest('.custom-fields-display');
var productCode = $display.data('product-code');
$display.hide();
$display.siblings('.custom-fields-edit[data-product-code="' + productCode + '"]').show();
});
$(document).on('click', '.btn-cancel-custom-fields', function(e) {
e.preventDefault();
var $edit = $(this).closest('.custom-fields-edit');
var productCode = $edit.data('product-code');
$edit.hide();
$edit.siblings('.custom-fields-display[data-product-code="' + productCode + '"]').show();
});
$(document).on('click', '.btn-save-custom-fields', function(e) {
e.preventDefault();
var $edit = $(this).closest('.custom-fields-edit');
var productCode = $edit.data('product-code');
var valid = true;
$edit.find('input[required]').each(function() {
if ($.trim($(this).val()) === '') {
$(this).css('border-color', 'red');
valid = false;
} else {
$(this).css('border-color', '');
}
});
if (!valid) {
alert('Wypełnij wszystkie wymagane pola');
return;
}
var formData = { product_code: productCode };
$edit.find('input[name^="custom_field"]').each(function() {
formData[$(this).attr('name')] = $(this).val();
});
$.ajax({
type: 'POST',
cache: false,
url: '/shopBasket/basket_update_custom_fields',
data: formData,
success: function(response) {
var data = jQuery.parseJSON(response);
if (data.result === 'ok') {
location.reload();
} else {
alert(data.message || 'Wystąpił błąd');
}
}
});
});
</script>

View File

@@ -73,10 +73,11 @@
$begin_checkout_items .= ',';
$begin_checkout_items .= '{';
$begin_checkout_items .= '"id": "' . $product['id'] . '",';
$begin_checkout_items .= '"name": "' . $product['language']['name'] . '",';
$begin_checkout_items .= '"item_id": "' . $product['id'] . '",';
$begin_checkout_items .= '"item_name": "' . str_replace( '"', '', $product['language']['name'] ) . '",';
$begin_checkout_items .= '"price": ' . \Shared\Helpers\Helpers::normalize_decimal( $price_product['price_new'] ) . ',';
$begin_checkout_items .= '"quantity": ' . $position['quantity'];
$begin_checkout_items .= '"quantity": ' . (int)$position['quantity'] . ',';
$begin_checkout_items .= '"google_business_vertical": "retail"';
$begin_checkout_items .= '}';
?>
<? endforeach;?>

View File

@@ -169,17 +169,17 @@
event: "purchase",
ecommerce: {
transaction_id: "<?= $this -> order['id'];?>",
value: 25.42,
currency: "PLN",
value: <?= \Shared\Helpers\Helpers::normalize_decimal( round( $this -> order['summary'], 2 ) ) - str_replace( ',', '.', round( $this -> order['transport_cost'], 2 ) );?>,
shipping: <?= \Shared\Helpers\Helpers::normalize_decimal( $this -> order['transport_cost'] );?>,
items: [
<? foreach ( $this -> order['products'] as $product ):?>
{
'id': <?= (int)$product['product_id'];?>,
'name': '<?= $product['name'];?>',
'quantity': <?= $product['quantity'];?>,
'price': <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? (float)$product['price_brutto_promo'] : (float)$product['price_brutto'];?>
item_id: "<?= $product['product_id'];?>",
item_name: "<?= str_replace( '"', '', $product['name'] );?>",
quantity: <?= (int)$product['quantity'];?>,
price: <?= ((float)$product['price_brutto_promo'] > 0 && (float)$product['price_brutto_promo'] < (float)$product['price_brutto']) ? \Shared\Helpers\Helpers::normalize_decimal( $product['price_brutto_promo'] ) : \Shared\Helpers\Helpers::normalize_decimal( $product['price_brutto'] );?>,
google_business_vertical: "retail"
}<? if ( $product != end( $this -> order['products'] ) ) echo ',';?>
<? endforeach;?>
]

View File

@@ -275,12 +275,15 @@
dataLayer.push({
event: "view_item",
ecommerce: {
currency: "PLN",
value: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
items: [
{
item_id: "<?= $this -> product['id'];?>",
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
price: '<? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>',
quantity: 1
price: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
quantity: 1,
google_business_vertical: "retail"
}
]
}
@@ -617,7 +620,8 @@
item_id: "<?= $this -> product['id'];?>",
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
price: <? if ( $this -> product['price_brutto_promo'] ): echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto_promo'] ); else: echo \Shared\Helpers\Helpers::normalize_decimal( $this -> product['price_brutto'] ); endif;?>,
quantity: quantity
quantity: parseInt(quantity),
google_business_vertical: "retail"
}
]
}

BIN
updates/0.30/ver_0.343.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,24 @@
{
"changelog": "Custom fields: type + is_required + obsługa obrazków w koszyku",
"version": "0.343",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/Domain/Product/ProductRepository.php",
"templates/shop-basket/_partials/product-custom-fields.php"
]
},
"checksum_zip": "sha256:bd3b968a4b389c0c3fd00a511e5a3ef0405d4b60079d6b460335f93b8faa92d2",
"sql": [
],
"date": "2026-03-19",
"directories_deleted": [
]
}

BIN
updates/0.30/ver_0.344.zip Normal file

Binary file not shown.

View File

@@ -0,0 +1,26 @@
{
"changelog": "Edycja personalizacji produktu w koszyku",
"version": "0.344",
"files": {
"added": [
],
"deleted": [
],
"modified": [
"autoload/front/Controllers/ShopBasketController.php",
"templates/shop-basket/_partials/product-custom-fields.php",
"templates/shop-basket/basket-details.php",
"templates/shop-basket/basket.php"
]
},
"checksum_zip": "sha256:5d48acd5be4c2674cf8f0288e13b7dcad6ea9645aaa68c1d6af49e64b417d36f",
"sql": [
],
"date": "2026-03-19",
"directories_deleted": [
]
}

View File

@@ -1,3 +1,9 @@
<b>ver. 0.344 - 19.03.2026</b><br />
Edycja personalizacji produktu w koszyku
<hr>
<b>ver. 0.343 - 19.03.2026</b><br />
Custom fields: type + is_required + obsługa obrazków w koszyku
<hr>
<b>ver. 0.342 - 19.03.2026</b><br />
Apilo: email z danymi zamówienia + infinite retry co 30 min dla order jobów
<hr>

View File

@@ -1,5 +1,5 @@
<?
$current_ver = 342;
$current_ver = 344;
for ($i = 1; $i <= $current_ver; $i++)
{