Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c747af1b6 | ||
|
|
fc624ba0ef | ||
|
|
589f9d9a38 | ||
|
|
18c037915f | ||
|
|
649639319a | ||
|
|
fb2093129f | ||
|
|
09f51be1c1 | ||
|
|
951a82a5b1 |
4
.claude/memory/MEMORY.md
Normal file
4
.claude/memory/MEMORY.md
Normal 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
|
||||||
10
.claude/memory/feedback_git_push_retry.md
Normal file
10
.claude/memory/feedback_git_push_retry.md
Normal 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.
|
||||||
10
.claude/memory/feedback_updateignore_sonarqube.md
Normal file
10
.claude/memory/feedback_updateignore_sonarqube.md
Normal 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.
|
||||||
@@ -37,6 +37,15 @@ Status: Planning
|
|||||||
| 8 | Apilo orders not sending — diagnoza i naprawa | 1 | Done | 2026-03-16 |
|
| 8 | Apilo orders not sending — diagnoza i naprawa | 1 | Done | 2026-03-16 |
|
||||||
| 9 | Apilo email notification + infinite retry | 1 | Done | 2026-03-19 |
|
| 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 Details
|
||||||
|
|
||||||
### Phase 4 — CSRF protection
|
### Phase 4 — CSRF protection
|
||||||
@@ -67,4 +76,25 @@ Status: Planning
|
|||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-03-12*
|
*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*
|
||||||
|
|||||||
@@ -5,25 +5,25 @@
|
|||||||
See: .paul/PROJECT.md (updated 2026-03-12)
|
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.
|
**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
|
## Current Position
|
||||||
|
|
||||||
Milestone: Hotfix
|
Milestone: Hotfix
|
||||||
Phase: 9 — Apilo email notification + infinite retry — Complete
|
Phase: 13 — basket logging + TTL token — Planning
|
||||||
Plan: 09-01 complete (phase done)
|
Plan: 13-01 created, awaiting approval
|
||||||
Status: UNIFY complete, phase 9 finished
|
Status: UNIFY complete, phase 13 finished
|
||||||
Last activity: 2026-03-19 — 09-01 UNIFY complete
|
Last activity: 2026-03-25 — 13-01 UNIFY complete
|
||||||
|
|
||||||
Progress:
|
Progress:
|
||||||
- Phase 9: [██████████] 100% (COMPLETE)
|
- Phase 13: [██████████] 100% (COMPLETE)
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
Current loop state (phase 9, plan 01):
|
Current loop state (phase 13, plan 01):
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
✓ ✓ ✓ [Phase 9 complete]
|
✓ ✓ ✓ [Phase 13 complete]
|
||||||
```
|
```
|
||||||
|
|
||||||
Previous phases:
|
Previous phases:
|
||||||
@@ -34,6 +34,10 @@ Phase 6: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-0
|
|||||||
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-15]
|
Phase 7: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-15]
|
||||||
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-16]
|
Phase 8: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-16]
|
||||||
Phase 9: PLAN ──▶ APPLY ──▶ UNIFY ✓ ✓ ✓ [COMPLETE — 2026-03-19]
|
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
|
## 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: 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: 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: 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
|
### Deferred Issues
|
||||||
None.
|
None.
|
||||||
@@ -55,10 +68,10 @@ None.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-19
|
Last session: 2026-03-25
|
||||||
Stopped at: Phase 09 UNIFY complete
|
Stopped at: Phase 13 UNIFY complete
|
||||||
Next action: Deploy fix or /paul:progress for next work
|
Next action: /koniec-pracy or next feature
|
||||||
Resume file: .paul/phases/09-apilo-email-fix/09-01-SUMMARY.md
|
Resume file: .paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md
|
||||||
|
|
||||||
---
|
---
|
||||||
*STATE.md — Updated after every significant action*
|
*STATE.md — Updated after every significant action*
|
||||||
|
|||||||
221
.paul/phases/10-basket-edit-custom-fields/10-01-PLAN.md
Normal file
221
.paul/phases/10-basket-edit-custom-fields/10-01-PLAN.md
Normal 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>
|
||||||
114
.paul/phases/10-basket-edit-custom-fields/10-01-SUMMARY.md
Normal file
114
.paul/phases/10-basket-edit-custom-fields/10-01-SUMMARY.md
Normal 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*
|
||||||
225
.paul/phases/11-datalayer-ga4-fix/11-01-PLAN.md
Normal file
225
.paul/phases/11-datalayer-ga4-fix/11-01-PLAN.md
Normal 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>
|
||||||
114
.paul/phases/11-datalayer-ga4-fix/11-01-SUMMARY.md
Normal file
114
.paul/phases/11-datalayer-ga4-fix/11-01-SUMMARY.md
Normal 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*
|
||||||
125
.paul/phases/12-summaryview-redirect-fix/12-01-PLAN.md
Normal file
125
.paul/phases/12-summaryview-redirect-fix/12-01-PLAN.md
Normal 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>
|
||||||
88
.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md
Normal file
88
.paul/phases/12-summaryview-redirect-fix/12-01-SUMMARY.md
Normal 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*
|
||||||
270
.paul/phases/13-basket-logging-ttl-token/13-01-PLAN.md
Normal file
270
.paul/phases/13-basket-logging-ttl-token/13-01-PLAN.md
Normal 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>
|
||||||
102
.paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md
Normal file
102
.paul/phases/13-basket-logging-ttl-token/13-01-SUMMARY.md
Normal 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
@@ -2,5 +2,5 @@ projectKey=shopPRO
|
|||||||
serverUrl=https://sonar.project-pro.pl
|
serverUrl=https://sonar.project-pro.pl
|
||||||
serverVersion=26.3.0.120487
|
serverVersion=26.3.0.120487
|
||||||
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=shopPRO
|
dashboardUrl=https://sonar.project-pro.pl/dashboard?id=shopPRO
|
||||||
ceTaskId=cc124932-3cc6-464e-9f3b-36e783582dde
|
ceTaskId=5c360d0b-34ee-4995-ae1b-d5ac7ca47218
|
||||||
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=cc124932-3cc6-464e-9f3b-36e783582dde
|
ceTaskUrl=https://sonar.project-pro.pl/api/ce/task?id=5c360d0b-34ee-4995-ae1b-d5ac7ca47218
|
||||||
|
|||||||
@@ -132,3 +132,17 @@ read_only_memory_patterns: []
|
|||||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
# 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.
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
line_ending:
|
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: {}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class ShopBasketController
|
|||||||
{
|
{
|
||||||
private const ORDER_SUBMIT_TOKEN_SESSION_KEY = 'order-submit-token';
|
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_LAST_ORDER_ID_SESSION_KEY = 'order-submit-last-order-id';
|
||||||
|
private const ORDER_SUBMIT_TOKEN_TTL = 1800;
|
||||||
|
|
||||||
public static $title = [
|
public static $title = [
|
||||||
'mainView' => 'Koszyk'
|
'mainView' => 'Koszyk'
|
||||||
@@ -276,19 +277,6 @@ class ShopBasketController
|
|||||||
exit;
|
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' );
|
$client = \Shared\Helpers\Helpers::get_session( 'client' );
|
||||||
$orderSubmitToken = $this->createOrderSubmitToken();
|
$orderSubmitToken = $this->createOrderSubmitToken();
|
||||||
|
|
||||||
@@ -311,20 +299,23 @@ class ShopBasketController
|
|||||||
$orderSubmitToken = (string)\Shared\Helpers\Helpers::get( 'order_submit_token', true );
|
$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;
|
$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 ( !$this->isValidOrderSubmitToken( $orderSubmitToken ) )
|
||||||
{
|
{
|
||||||
if ( $existingOrderId > 0 )
|
$this->logOrder( 'Token validation failed. formToken=' . $orderSubmitToken . ' existingOrderId=' . $existingOrderId );
|
||||||
{
|
|
||||||
$existingOrderHash = $this->orderRepository->findHashById( $existingOrderId );
|
|
||||||
if ( $existingOrderHash )
|
|
||||||
{
|
|
||||||
header( 'Location: /zamowienie/' . $existingOrderHash );
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
|
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
|
||||||
header( 'Location: /koszyk' );
|
header( 'Location: /koszyk-podsumowanie' );
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +358,7 @@ class ShopBasketController
|
|||||||
}
|
}
|
||||||
catch ( \Exception $e )
|
catch ( \Exception $e )
|
||||||
{
|
{
|
||||||
|
$this->logOrder( 'createFromBasket exception: ' . $e->getMessage() );
|
||||||
error_log( '[basketSave] createFromBasket exception: ' . $e->getMessage() );
|
error_log( '[basketSave] createFromBasket exception: ' . $e->getMessage() );
|
||||||
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
|
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
|
||||||
header( 'Location: /koszyk' );
|
header( 'Location: /koszyk' );
|
||||||
@@ -400,6 +392,7 @@ class ShopBasketController
|
|||||||
}
|
}
|
||||||
else
|
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' ) );
|
\Shared\Helpers\Helpers::error( \Shared\Helpers\Helpers::lang( 'zamowienie-zostalo-zlozone-komunikat-blad' ) );
|
||||||
header( 'Location: /koszyk' );
|
header( 'Location: /koszyk' );
|
||||||
exit;
|
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 )
|
private function jsonBasketResponse( $basket, $coupon, $lang_id, $basket_transport_method_id )
|
||||||
{
|
{
|
||||||
global $settings;
|
global $settings;
|
||||||
@@ -471,8 +537,23 @@ class ShopBasketController
|
|||||||
|
|
||||||
private function createOrderSubmitToken()
|
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();
|
$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 );
|
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_LAST_ORDER_ID_SESSION_KEY );
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
@@ -495,10 +576,29 @@ class ShopBasketController
|
|||||||
if ( !$token )
|
if ( !$token )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
$sessionToken = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] ) ? (string)$_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] : '';
|
$sessionData = isset( $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ] )
|
||||||
if ( !$sessionToken )
|
? $_SESSION[ self::ORDER_SUBMIT_TOKEN_SESSION_KEY ]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ( !$sessionData )
|
||||||
return false;
|
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' ) )
|
if ( function_exists( 'hash_equals' ) )
|
||||||
return hash_equals( $sessionToken, $token );
|
return hash_equals( $sessionToken, $token );
|
||||||
|
|
||||||
@@ -509,4 +609,11 @@ class ShopBasketController
|
|||||||
{
|
{
|
||||||
\Shared\Helpers\Helpers::delete_session( self::ORDER_SUBMIT_TOKEN_SESSION_KEY );
|
\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 );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
## 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`
|
- **FIX**: `autoload/Domain/Product/ProductRepository.php` — kopiowanie custom fields przy duplikacji produktu uwzględnia teraz pola `type` i `is_required`
|
||||||
|
|||||||
10
docs/TODO.md
10
docs/TODO.md
@@ -104,3 +104,13 @@ Dodać możliwość ustawienia limitu znaków w wiadomościach do produktu
|
|||||||
- [ ] [MAJOR] autoload/Domain/Integrations/ApiloRepository.php:53 — 4 returns (max 3) (php:S1142)
|
- [ ] [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: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)
|
||||||
@@ -1,26 +1,52 @@
|
|||||||
<? if ( $this -> custom_fields ) : ?>
|
<? if ( $this -> custom_fields ) : ?>
|
||||||
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
|
<div class="custom-fields-display" data-product-code="<?= htmlspecialchars( $this->product_code ); ?>">
|
||||||
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
|
<? foreach ( $this -> custom_fields as $key => $val ) : ?>
|
||||||
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
|
<? $custom_field = ( new \Domain\Product\ProductRepository( $GLOBALS['mdb'] ) )->findCustomFieldCached( $key ); ?>
|
||||||
|
<? $field_type = !empty( $custom_field['type'] ) ? $custom_field['type'] : 'text'; ?>
|
||||||
|
|
||||||
<? if ( $field_type == 'text' ) : ?>
|
<? if ( $field_type == 'text' ) : ?>
|
||||||
<div class="custom-field">
|
<div class="custom-field">
|
||||||
<div class="_name">
|
<div class="_name">
|
||||||
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
|
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
|
||||||
|
</div>
|
||||||
|
<div class="_text">
|
||||||
|
<?= nl2br( htmlspecialchars( $val ) );?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="_text">
|
<? elseif ( $field_type == 'image' && !empty( $val ) ) : ?>
|
||||||
<?= nl2br( htmlspecialchars( $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>
|
</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>
|
</div>
|
||||||
<? elseif ( $field_type == 'image' && !empty( $val ) ) : ?>
|
<? endforeach; ?>
|
||||||
<div class="custom-field">
|
<div style="margin-top: 5px;">
|
||||||
<div class="_name">
|
<a href="#" class="btn btn-sm btn-primary btn-save-custom-fields">Zapisz</a>
|
||||||
<?= htmlspecialchars( $custom_field['name'] ) . ':'; ?>
|
<a href="#" class="btn btn-sm btn-default btn-cancel-custom-fields">Anuluj</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="_image">
|
</div>
|
||||||
<img src="<?= htmlspecialchars( $val );?>" alt="<?= htmlspecialchars( $custom_field['name'] );?>">
|
<? endif; ?>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<? endif; ?>
|
|
||||||
<? endforeach; ?>
|
|
||||||
<? endif;?>
|
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
<hr>
|
<hr>
|
||||||
<? endif; ?>
|
<? endif; ?>
|
||||||
<?= \Shared\Tpl\Tpl::view( 'shop-basket/_partials/product-custom-fields', [
|
<?= \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'] ):?>
|
<? if ( $product['additional_message'] ):?>
|
||||||
<div class="basket-product-message">
|
<div class="basket-product-message">
|
||||||
|
|||||||
@@ -1,4 +1,46 @@
|
|||||||
<? global $settings; ?>
|
<? 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="basket-container">
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<?= $this->basket_details; ?>
|
<?= $this->basket_details; ?>
|
||||||
@@ -508,4 +550,62 @@
|
|||||||
console.warn('#orlen_point_id nie został znaleziony.');
|
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>
|
</script>
|
||||||
@@ -73,10 +73,11 @@
|
|||||||
$begin_checkout_items .= ',';
|
$begin_checkout_items .= ',';
|
||||||
|
|
||||||
$begin_checkout_items .= '{';
|
$begin_checkout_items .= '{';
|
||||||
$begin_checkout_items .= '"id": "' . $product['id'] . '",';
|
$begin_checkout_items .= '"item_id": "' . $product['id'] . '",';
|
||||||
$begin_checkout_items .= '"name": "' . $product['language']['name'] . '",';
|
$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 .= '"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 .= '}';
|
$begin_checkout_items .= '}';
|
||||||
?>
|
?>
|
||||||
<? endforeach;?>
|
<? endforeach;?>
|
||||||
|
|||||||
@@ -169,17 +169,17 @@
|
|||||||
event: "purchase",
|
event: "purchase",
|
||||||
ecommerce: {
|
ecommerce: {
|
||||||
transaction_id: "<?= $this -> order['id'];?>",
|
transaction_id: "<?= $this -> order['id'];?>",
|
||||||
value: 25.42,
|
|
||||||
currency: "PLN",
|
currency: "PLN",
|
||||||
value: <?= \Shared\Helpers\Helpers::normalize_decimal( round( $this -> order['summary'], 2 ) ) - str_replace( ',', '.', round( $this -> order['transport_cost'], 2 ) );?>,
|
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'] );?>,
|
shipping: <?= \Shared\Helpers\Helpers::normalize_decimal( $this -> order['transport_cost'] );?>,
|
||||||
items: [
|
items: [
|
||||||
<? foreach ( $this -> order['products'] as $product ):?>
|
<? foreach ( $this -> order['products'] as $product ):?>
|
||||||
{
|
{
|
||||||
'id': <?= (int)$product['product_id'];?>,
|
item_id: "<?= $product['product_id'];?>",
|
||||||
'name': '<?= $product['name'];?>',
|
item_name: "<?= str_replace( '"', '', $product['name'] );?>",
|
||||||
'quantity': <?= $product['quantity'];?>,
|
quantity: <?= (int)$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'];?>
|
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 ',';?>
|
}<? if ( $product != end( $this -> order['products'] ) ) echo ',';?>
|
||||||
<? endforeach;?>
|
<? endforeach;?>
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -275,12 +275,15 @@
|
|||||||
dataLayer.push({
|
dataLayer.push({
|
||||||
event: "view_item",
|
event: "view_item",
|
||||||
ecommerce: {
|
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: [
|
items: [
|
||||||
{
|
{
|
||||||
item_id: "<?= $this -> product['id'];?>",
|
item_id: "<?= $this -> product['id'];?>",
|
||||||
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
|
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;?>',
|
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
|
quantity: 1,
|
||||||
|
google_business_vertical: "retail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -617,7 +620,8 @@
|
|||||||
item_id: "<?= $this -> product['id'];?>",
|
item_id: "<?= $this -> product['id'];?>",
|
||||||
item_name: "<?= str_replace( '"', '', $this -> product['language']['name'] );?>",
|
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;?>,
|
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
BIN
updates/0.30/ver_0.343.zip
Normal file
Binary file not shown.
24
updates/0.30/ver_0.343_manifest.json
Normal file
24
updates/0.30/ver_0.343_manifest.json
Normal 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
BIN
updates/0.30/ver_0.344.zip
Normal file
Binary file not shown.
26
updates/0.30/ver_0.344_manifest.json
Normal file
26
updates/0.30/ver_0.344_manifest.json
Normal 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": [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 />
|
<b>ver. 0.342 - 19.03.2026</b><br />
|
||||||
Apilo: email z danymi zamówienia + infinite retry co 30 min dla order jobów
|
Apilo: email z danymi zamówienia + infinite retry co 30 min dla order jobów
|
||||||
<hr>
|
<hr>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?
|
<?
|
||||||
$current_ver = 342;
|
$current_ver = 344;
|
||||||
|
|
||||||
for ($i = 1; $i <= $current_ver; $i++)
|
for ($i = 1; $i <= $current_ver; $i++)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user