From 783fdf451e33fe34d46562a9ec54b728a5777ac7 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 21:40:53 +0200 Subject: [PATCH 01/10] inital --- .../console-2026-07-11T11-01-40-401Z.log | 4 + .../console-2026-07-11T11-02-45-521Z.log | 1 + .../console-2026-07-11T11-02-54-584Z.log | 3 + .../console-2026-07-11T11-04-12-738Z.log | 4 + .../console-2026-07-11T11-05-51-521Z.log | 4 + .../console-2026-07-11T11-07-38-637Z.log | 4 + .../console-2026-07-11T11-08-36-938Z.log | 5 + .../console-2026-07-11T11-10-28-191Z.log | 1 + .../console-2026-07-11T11-10-28-362Z.log | 1 + .../console-2026-07-11T11-10-38-930Z.log | 1 + .../console-2026-07-11T11-12-12-689Z.log | 2 + .../console-2026-07-11T11-12-27-893Z.log | 1 + .../console-2026-07-11T11-13-17-355Z.log | 1 + .../console-2026-07-11T11-24-36-090Z.log | 1 + .../console-2026-07-11T11-24-36-383Z.log | 1 + .../console-2026-07-11T11-25-54-877Z.log | 1 + .../page-2026-07-11T11-01-40-796Z.yml | 131 ++++++ .../page-2026-07-11T11-02-45-664Z.yml | 131 ++++++ .../page-2026-07-11T11-02-54-726Z.yml | 98 +++++ .../page-2026-07-11T11-03-03-373Z.yml | 260 ++++++++++++ .../page-2026-07-11T11-04-12-885Z.yml | 98 +++++ .../page-2026-07-11T11-04-18-086Z.yml | 110 ++++++ .../page-2026-07-11T11-04-45-784Z.yml | 54 +++ .../page-2026-07-11T11-04-59-720Z.yml | 249 ++++++++++++ .../page-2026-07-11T11-05-51-648Z.yml | 98 +++++ .../page-2026-07-11T11-05-52-725Z.yml | 110 ++++++ .../page-2026-07-11T11-05-53-349Z.yml | 54 +++ .../page-2026-07-11T11-05-53-490Z.yml | 78 ++++ .../page-2026-07-11T11-06-10-106Z.yml | 89 +++++ .../page-2026-07-11T11-06-22-031Z.yml | 89 +++++ .../page-2026-07-11T11-06-23-086Z.yml | 105 +++++ .../page-2026-07-11T11-06-31-119Z.yml | 105 +++++ .../page-2026-07-11T11-06-40-194Z.yml | 48 +++ .../page-2026-07-11T11-07-38-774Z.yml | 98 +++++ .../page-2026-07-11T11-07-39-867Z.yml | 110 ++++++ .../page-2026-07-11T11-07-40-483Z.yml | 54 +++ .../page-2026-07-11T11-07-41-607Z.yml | 78 ++++ .../page-2026-07-11T11-07-51-525Z.yml | 89 +++++ .../page-2026-07-11T11-07-59-019Z.yml | 89 +++++ .../page-2026-07-11T11-08-00-072Z.yml | 105 +++++ .../page-2026-07-11T11-08-01-120Z.yml | 105 +++++ .../page-2026-07-11T11-08-11-001Z.yml | 106 +++++ .../page-2026-07-11T11-08-26-207Z.yml | 102 +++++ .../page-2026-07-11T11-08-27-255Z.yml | 102 +++++ .../page-2026-07-11T11-08-28-388Z.yml | 52 +++ .../page-2026-07-11T11-08-37-037Z.yml | 20 + .../page-2026-07-11T11-08-45-914Z.yml | 158 ++++++++ .../page-2026-07-11T11-08-53-729Z.yml | 289 ++++++++++++++ .../page-2026-07-11T11-09-06-483Z.yml | 113 ++++++ .../page-2026-07-11T11-09-15-668Z.yml | 274 +++++++++++++ .../page-2026-07-11T11-09-23-818Z.yml | 127 ++++++ .../page-2026-07-11T11-09-37-169Z.yml | 372 ++++++++++++++++++ .../page-2026-07-11T11-10-28-347Z.yml | 16 + .../page-2026-07-11T11-10-28-467Z.yml | 127 ++++++ .../page-2026-07-11T11-10-29-642Z.yml | 125 ++++++ .../page-2026-07-11T11-10-39-032Z.yml | 54 +++ .../page-2026-07-11T11-10-47-627Z.yml | 71 ++++ .../page-2026-07-11T11-10-58-585Z.yml | 71 ++++ .../page-2026-07-11T11-12-12-799Z.yml | 71 ++++ .../page-2026-07-11T11-12-19-324Z.yml | 60 +++ .../page-2026-07-11T11-12-28-195Z.yml | 100 +++++ .../page-2026-07-11T11-13-17-485Z.yml | 93 +++++ .../page-2026-07-11T11-13-26-021Z.yml | 100 +++++ .../page-2026-07-11T11-24-36-347Z.yml | 131 ++++++ .../page-2026-07-11T11-24-36-506Z.yml | 16 + .../page-2026-07-11T11-25-55-023Z.yml | 158 ++++++++ README.md | 7 + 67 files changed, 5585 insertions(+) create mode 100644 .playwright-mcp/console-2026-07-11T11-01-40-401Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-02-45-521Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-02-54-584Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-04-12-738Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-05-51-521Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-07-38-637Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-08-36-938Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-10-28-191Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-10-28-362Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-10-38-930Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-12-12-689Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-12-27-893Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-13-17-355Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-24-36-090Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-24-36-383Z.log create mode 100644 .playwright-mcp/console-2026-07-11T11-25-54-877Z.log create mode 100644 .playwright-mcp/page-2026-07-11T11-01-40-796Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-02-45-664Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-02-54-726Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-03-03-373Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-04-12-885Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-04-18-086Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-04-45-784Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-04-59-720Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-05-51-648Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-05-52-725Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-05-53-349Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-05-53-490Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-06-10-106Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-06-22-031Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-06-23-086Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-06-31-119Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-06-40-194Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-07-38-774Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-07-39-867Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-07-40-483Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-07-41-607Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-07-51-525Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-07-59-019Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-00-072Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-01-120Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-11-001Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-26-207Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-27-255Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-28-388Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-37-037Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-45-914Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-08-53-729Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-09-06-483Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-09-15-668Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-09-23-818Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-09-37-169Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-10-28-347Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-10-28-467Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-10-29-642Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-10-39-032Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-10-47-627Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-10-58-585Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-12-12-799Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-12-19-324Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-12-28-195Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-13-17-485Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-13-26-021Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-24-36-347Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-24-36-506Z.yml create mode 100644 .playwright-mcp/page-2026-07-11T11-25-55-023Z.yml create mode 100644 README.md diff --git a/.playwright-mcp/console-2026-07-11T11-01-40-401Z.log b/.playwright-mcp/console-2026-07-11T11-01-40-401Z.log new file mode 100644 index 00000000..7ca61290 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-01-40-401Z.log @@ -0,0 +1,4 @@ +[ 216ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:57 +[ 411ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/favicon.ico:0 +[ 29302ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:57 +[ 33075ms] [LOG] [vite] server connection lost. Polling for restart... @ http://acme-fashion.test/:116 diff --git a/.playwright-mcp/console-2026-07-11T11-02-45-521Z.log b/.playwright-mcp/console-2026-07-11T11-02-45-521Z.log new file mode 100644 index 00000000..61212ad4 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-02-45-521Z.log @@ -0,0 +1 @@ +[ 86ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:56 diff --git a/.playwright-mcp/console-2026-07-11T11-02-54-584Z.log b/.playwright-mcp/console-2026-07-11T11-02-54-584Z.log new file mode 100644 index 00000000..9974f20c --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-02-54-584Z.log @@ -0,0 +1,3 @@ +[ 105ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:56 +[ 7744ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://acme-fashion.test/livewire-6701cc17/update:0 +[ 7763ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ :6 diff --git a/.playwright-mcp/console-2026-07-11T11-04-12-738Z.log b/.playwright-mcp/console-2026-07-11T11-04-12-738Z.log new file mode 100644 index 00000000..ff90dc77 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-04-12-738Z.log @@ -0,0 +1,4 @@ +[ 92ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:56 +[ 32518ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/cart:56 +[ 45939ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://acme-fashion.test/livewire-6701cc17/update:0 +[ 45951ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ :6 diff --git a/.playwright-mcp/console-2026-07-11T11-05-51-521Z.log b/.playwright-mcp/console-2026-07-11T11-05-51-521Z.log new file mode 100644 index 00000000..15086794 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-05-51-521Z.log @@ -0,0 +1,4 @@ +[ 78ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:56 +[ 1296ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/cart:56 +[ 1940ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/2:56 +[ 48647ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/2/confirmation?order=19:56 diff --git a/.playwright-mcp/console-2026-07-11T11-07-38-637Z.log b/.playwright-mcp/console-2026-07-11T11-07-38-637Z.log new file mode 100644 index 00000000..498a1abc --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-07-38-637Z.log @@ -0,0 +1,4 @@ +[ 88ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:56 +[ 1315ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/cart:56 +[ 1954ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/3:56 +[ 48735ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/3/confirmation?order=20:56 diff --git a/.playwright-mcp/console-2026-07-11T11-08-36-938Z.log b/.playwright-mcp/console-2026-07-11T11-08-36-938Z.log new file mode 100644 index 00000000..3639e81a --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-08-36-938Z.log @@ -0,0 +1,5 @@ +[ 57ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/login:51 +[ 48918ms] [WARNING] The resource http://acme-fashion.test/build/assets/app-Cxwdcq-d.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://acme-fashion.test/admin/orders/5:0 +[ 59121ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://acme-fashion.test/livewire-6701cc17/update:0 +[ 59137ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ :6 +[ 62543ms] [WARNING] The resource http://acme-fashion.test/build/assets/app-Cxwdcq-d.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://acme-fashion.test/admin/orders/5:0 diff --git a/.playwright-mcp/console-2026-07-11T11-10-28-191Z.log b/.playwright-mcp/console-2026-07-11T11-10-28-191Z.log new file mode 100644 index 00000000..3229b315 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-10-28-191Z.log @@ -0,0 +1 @@ +[ 110ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/dashboard:51 diff --git a/.playwright-mcp/console-2026-07-11T11-10-28-362Z.log b/.playwright-mcp/console-2026-07-11T11-10-28-362Z.log new file mode 100644 index 00000000..0d72d14d --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-10-28-362Z.log @@ -0,0 +1 @@ +[ 59ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/5:50 diff --git a/.playwright-mcp/console-2026-07-11T11-10-38-930Z.log b/.playwright-mcp/console-2026-07-11T11-10-38-930Z.log new file mode 100644 index 00000000..d704af3c --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-10-38-930Z.log @@ -0,0 +1 @@ +[ 56ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/login:56 diff --git a/.playwright-mcp/console-2026-07-11T11-12-12-689Z.log b/.playwright-mcp/console-2026-07-11T11-12-12-689Z.log new file mode 100644 index 00000000..b671f953 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-12-12-689Z.log @@ -0,0 +1,2 @@ +[ 65ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account:56 +[ 6103ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/orders/1:56 diff --git a/.playwright-mcp/console-2026-07-11T11-12-27-893Z.log b/.playwright-mcp/console-2026-07-11T11-12-27-893Z.log new file mode 100644 index 00000000..0a5b6fb6 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-12-27-893Z.log @@ -0,0 +1 @@ +[ 149ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-electronics.test/_boost/browser-logs @ http://acme-electronics.test/:56 diff --git a/.playwright-mcp/console-2026-07-11T11-13-17-355Z.log b/.playwright-mcp/console-2026-07-11T11-13-17-355Z.log new file mode 100644 index 00000000..5f1a577a --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-13-17-355Z.log @@ -0,0 +1 @@ +[ 91ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-electronics.test/_boost/browser-logs @ http://acme-electronics.test/:56 diff --git a/.playwright-mcp/console-2026-07-11T11-24-36-090Z.log b/.playwright-mcp/console-2026-07-11T11-24-36-090Z.log new file mode 100644 index 00000000..2337dc8f --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-24-36-090Z.log @@ -0,0 +1 @@ +[ 210ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:56 diff --git a/.playwright-mcp/console-2026-07-11T11-24-36-383Z.log b/.playwright-mcp/console-2026-07-11T11-24-36-383Z.log new file mode 100644 index 00000000..172d9e91 --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-24-36-383Z.log @@ -0,0 +1 @@ +[ 67ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/dashboard:51 diff --git a/.playwright-mcp/console-2026-07-11T11-25-54-877Z.log b/.playwright-mcp/console-2026-07-11T11-25-54-877Z.log new file mode 100644 index 00000000..35bcd48f --- /dev/null +++ b/.playwright-mcp/console-2026-07-11T11-25-54-877Z.log @@ -0,0 +1 @@ +[ 95ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin:50 diff --git a/.playwright-mcp/page-2026-07-11T11-01-40-796Z.yml b/.playwright-mcp/page-2026-07-11T11-01-40-796Z.yml new file mode 100644 index 00000000..630f7911 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-01-40-796Z.yml @@ -0,0 +1,131 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart + - main [ref=e27]: + - generic [ref=e28]: + - generic [ref=e31]: + - generic [ref=e32]: New season + - heading "Find your next favorite." [level=1] [ref=e33] + - paragraph [ref=e34]: Explore curated essentials from Acme Fashion. + - link "Shop collections" [ref=e36] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e38]: + - generic [ref=e39]: + - generic [ref=e40]: + - paragraph [ref=e41]: Explore + - heading "Featured collections" [level=2] [ref=e42] + - link "View all" [ref=e43] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e44]: + - link [ref=e45] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - heading "New Arrivals" [level=3] [ref=e46] + - paragraph [ref=e47]: 7 products + - link [ref=e48] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - heading "T-Shirts" [level=3] [ref=e49] + - paragraph [ref=e50]: 4 products + - link [ref=e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - heading "Pants & Jeans" [level=3] [ref=e52] + - paragraph [ref=e53]: 4 products + - generic [ref=e54]: + - heading "Featured products" [level=2] [ref=e55] + - generic [ref=e56]: + - article [ref=e57]: + - link "Cashmere Overcoat image placeholder Cashmere Overcoat €499.99" [ref=e58] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - img "Cashmere Overcoat image placeholder" [ref=e59]: + - generic [ref=e60]: Cashmere Overcoat + - generic [ref=e61]: + - heading "Cashmere Overcoat" [level=2] [ref=e62] + - generic [ref=e63]: €499.99 + - article [ref=e65]: + - link "Gift Card image placeholder Gift Card €25.00" [ref=e66] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - img "Gift Card image placeholder" [ref=e67]: + - generic [ref=e68]: Gift Card + - generic [ref=e69]: + - heading "Gift Card" [level=2] [ref=e70] + - generic [ref=e71]: €25.00 + - article [ref=e73]: + - link "Backorder Denim Jacket image placeholder Backorder Denim Jacket €99.99" [ref=e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/backorder-denim-jacket + - img "Backorder Denim Jacket image placeholder" [ref=e75]: + - generic [ref=e76]: Backorder Denim Jacket + - generic [ref=e77]: + - heading "Backorder Denim Jacket" [level=2] [ref=e78] + - generic [ref=e79]: €99.99 + - article [ref=e81]: + - link "Limited Edition Sneakers image placeholder Limited Edition Sneakers €159.99" [ref=e82] [cursor=pointer]: + - /url: http://acme-fashion.test/products/limited-edition-sneakers + - img "Limited Edition Sneakers image placeholder" [ref=e83]: + - generic [ref=e84]: Limited Edition Sneakers + - generic [ref=e85]: + - heading "Limited Edition Sneakers" [level=2] [ref=e86] + - generic [ref=e87]: €159.99 + - article [ref=e89]: + - link "Bucket Hat image placeholder Bucket Hat €24.99" [ref=e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - img "Bucket Hat image placeholder" [ref=e91]: + - generic [ref=e92]: Bucket Hat + - generic [ref=e93]: + - heading "Bucket Hat" [level=2] [ref=e94] + - generic [ref=e95]: €24.99 + - article [ref=e97]: + - link "Canvas Tote Bag image placeholder Canvas Tote Bag €19.99" [ref=e98] [cursor=pointer]: + - /url: http://acme-fashion.test/products/canvas-tote-bag + - img "Canvas Tote Bag image placeholder" [ref=e99]: + - generic [ref=e100]: Canvas Tote Bag + - generic [ref=e101]: + - heading "Canvas Tote Bag" [level=2] [ref=e102] + - generic [ref=e103]: €19.99 + - article [ref=e105]: + - link "Wool Scarf image placeholder Wool Scarf €29.99" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wool-scarf + - img "Wool Scarf image placeholder" [ref=e107]: + - generic [ref=e108]: Wool Scarf + - generic [ref=e109]: + - heading "Wool Scarf" [level=2] [ref=e110] + - generic [ref=e111]: €29.99 + - article [ref=e113]: + - link "Wide Leg Trousers image placeholder Wide Leg Trousers €49.99 €69.99 Sale price" [ref=e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wide-leg-trousers + - img "Wide Leg Trousers image placeholder" [ref=e115]: + - generic [ref=e116]: Wide Leg Trousers + - generic [ref=e117]: + - heading "Wide Leg Trousers" [level=2] [ref=e118] + - generic [ref=e119]: + - generic [ref=e120]: €49.99 + - generic [ref=e121]: €69.99 + - generic [ref=e122]: Sale price + - contentinfo [ref=e123]: + - generic [ref=e124]: + - generic [ref=e125]: + - paragraph [ref=e126]: Acme Fashion + - paragraph [ref=e127]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e128]: + - link "Shop all collections" [ref=e129] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e130] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e131]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-02-45-664Z.yml b/.playwright-mcp/page-2026-07-11T11-02-45-664Z.yml new file mode 100644 index 00000000..630f7911 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-02-45-664Z.yml @@ -0,0 +1,131 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart + - main [ref=e27]: + - generic [ref=e28]: + - generic [ref=e31]: + - generic [ref=e32]: New season + - heading "Find your next favorite." [level=1] [ref=e33] + - paragraph [ref=e34]: Explore curated essentials from Acme Fashion. + - link "Shop collections" [ref=e36] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e38]: + - generic [ref=e39]: + - generic [ref=e40]: + - paragraph [ref=e41]: Explore + - heading "Featured collections" [level=2] [ref=e42] + - link "View all" [ref=e43] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e44]: + - link [ref=e45] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - heading "New Arrivals" [level=3] [ref=e46] + - paragraph [ref=e47]: 7 products + - link [ref=e48] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - heading "T-Shirts" [level=3] [ref=e49] + - paragraph [ref=e50]: 4 products + - link [ref=e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - heading "Pants & Jeans" [level=3] [ref=e52] + - paragraph [ref=e53]: 4 products + - generic [ref=e54]: + - heading "Featured products" [level=2] [ref=e55] + - generic [ref=e56]: + - article [ref=e57]: + - link "Cashmere Overcoat image placeholder Cashmere Overcoat €499.99" [ref=e58] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - img "Cashmere Overcoat image placeholder" [ref=e59]: + - generic [ref=e60]: Cashmere Overcoat + - generic [ref=e61]: + - heading "Cashmere Overcoat" [level=2] [ref=e62] + - generic [ref=e63]: €499.99 + - article [ref=e65]: + - link "Gift Card image placeholder Gift Card €25.00" [ref=e66] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - img "Gift Card image placeholder" [ref=e67]: + - generic [ref=e68]: Gift Card + - generic [ref=e69]: + - heading "Gift Card" [level=2] [ref=e70] + - generic [ref=e71]: €25.00 + - article [ref=e73]: + - link "Backorder Denim Jacket image placeholder Backorder Denim Jacket €99.99" [ref=e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/backorder-denim-jacket + - img "Backorder Denim Jacket image placeholder" [ref=e75]: + - generic [ref=e76]: Backorder Denim Jacket + - generic [ref=e77]: + - heading "Backorder Denim Jacket" [level=2] [ref=e78] + - generic [ref=e79]: €99.99 + - article [ref=e81]: + - link "Limited Edition Sneakers image placeholder Limited Edition Sneakers €159.99" [ref=e82] [cursor=pointer]: + - /url: http://acme-fashion.test/products/limited-edition-sneakers + - img "Limited Edition Sneakers image placeholder" [ref=e83]: + - generic [ref=e84]: Limited Edition Sneakers + - generic [ref=e85]: + - heading "Limited Edition Sneakers" [level=2] [ref=e86] + - generic [ref=e87]: €159.99 + - article [ref=e89]: + - link "Bucket Hat image placeholder Bucket Hat €24.99" [ref=e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - img "Bucket Hat image placeholder" [ref=e91]: + - generic [ref=e92]: Bucket Hat + - generic [ref=e93]: + - heading "Bucket Hat" [level=2] [ref=e94] + - generic [ref=e95]: €24.99 + - article [ref=e97]: + - link "Canvas Tote Bag image placeholder Canvas Tote Bag €19.99" [ref=e98] [cursor=pointer]: + - /url: http://acme-fashion.test/products/canvas-tote-bag + - img "Canvas Tote Bag image placeholder" [ref=e99]: + - generic [ref=e100]: Canvas Tote Bag + - generic [ref=e101]: + - heading "Canvas Tote Bag" [level=2] [ref=e102] + - generic [ref=e103]: €19.99 + - article [ref=e105]: + - link "Wool Scarf image placeholder Wool Scarf €29.99" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wool-scarf + - img "Wool Scarf image placeholder" [ref=e107]: + - generic [ref=e108]: Wool Scarf + - generic [ref=e109]: + - heading "Wool Scarf" [level=2] [ref=e110] + - generic [ref=e111]: €29.99 + - article [ref=e113]: + - link "Wide Leg Trousers image placeholder Wide Leg Trousers €49.99 €69.99 Sale price" [ref=e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wide-leg-trousers + - img "Wide Leg Trousers image placeholder" [ref=e115]: + - generic [ref=e116]: Wide Leg Trousers + - generic [ref=e117]: + - heading "Wide Leg Trousers" [level=2] [ref=e118] + - generic [ref=e119]: + - generic [ref=e120]: €49.99 + - generic [ref=e121]: €69.99 + - generic [ref=e122]: Sale price + - contentinfo [ref=e123]: + - generic [ref=e124]: + - generic [ref=e125]: + - paragraph [ref=e126]: Acme Fashion + - paragraph [ref=e127]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e128]: + - link "Shop all collections" [ref=e129] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e130] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e131]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-02-54-726Z.yml b/.playwright-mcp/page-2026-07-11T11-02-54-726Z.yml new file mode 100644 index 00000000..d9950a43 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-02-54-726Z.yml @@ -0,0 +1,98 @@ +- generic [active] [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e3]: Free shipping available with code FREESHIP + - banner [ref=f1e4]: + - generic [ref=f1e5]: + - link "Acme Fashion" [ref=f1e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e7]: + - link "Home" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f1e12]: + - button "Open product search" [ref=f1e14]: + - generic [ref=f1e19]: Search + - button "Open shopping cart" [ref=f1e21]: + - generic [ref=f1e26]: Cart + - main [ref=f1e27]: + - generic [ref=f1e28]: + - navigation "Breadcrumb" [ref=f1e29]: + - list [ref=f1e30]: + - listitem [ref=f1e31]: + - link "Home" [ref=f1e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=f1e33]: / + - listitem [ref=f1e34]: + - link "Products" [ref=f1e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=f1e36]: / + - listitem [ref=f1e37]: + - generic [ref=f1e38]: Classic Cotton T-Shirt + - generic [ref=f1e39]: + - img "Classic Cotton T-Shirt" [ref=f1e40] + - generic [ref=f1e41]: + - generic [ref=f1e42]: + - paragraph [ref=f1e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=f1e44] + - generic [ref=f1e45]: €24.99 + - group "Choose a variant" [ref=f1e48]: + - generic [ref=f1e50]: + - generic [ref=f1e51] [cursor=pointer]: + - generic [ref=f1e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=f1e53] + - generic [ref=f1e54] [cursor=pointer]: + - generic [ref=f1e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=f1e56] + - generic [ref=f1e57] [cursor=pointer]: + - generic [ref=f1e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=f1e59] + - generic [ref=f1e60] [cursor=pointer]: + - generic [ref=f1e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=f1e62] + - generic [ref=f1e63] [cursor=pointer]: + - generic [ref=f1e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=f1e65] + - generic [ref=f1e66] [cursor=pointer]: + - generic [ref=f1e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=f1e68] + - generic [ref=f1e69] [cursor=pointer]: + - generic [ref=f1e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=f1e71] + - generic [ref=f1e72] [cursor=pointer]: + - generic [ref=f1e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=f1e74] + - generic [ref=f1e75] [cursor=pointer]: + - generic [ref=f1e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=f1e77] + - generic [ref=f1e78] [cursor=pointer]: + - generic [ref=f1e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=f1e80] + - generic [ref=f1e81] [cursor=pointer]: + - generic [ref=f1e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=f1e83] + - generic [ref=f1e84] [cursor=pointer]: + - generic [ref=f1e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=f1e86] + - paragraph [ref=f1e87]: In stock + - generic [ref=f1e88]: + - generic [ref=f1e89]: Quantity + - spinbutton "Quantity" [ref=f1e91]: "1" + - button "Add to cart" [ref=f1e92] + - paragraph [ref=f1e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=f1e100]: + - generic [ref=f1e101]: + - generic [ref=f1e102]: + - paragraph [ref=f1e103]: Acme Fashion + - paragraph [ref=f1e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f1e105]: + - link "Shop all collections" [ref=f1e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f1e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-03-03-373Z.yml b/.playwright-mcp/page-2026-07-11T11-03-03-373Z.yml new file mode 100644 index 00000000..2f650104 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-03-03-373Z.yml @@ -0,0 +1,260 @@ +- generic [active] [ref=f1e1]: + - dialog [ref=f1e109]: + - iframe [ref=f1e110]: + - generic [ref=f2e2]: + - generic [ref=f2e4]: + - generic [ref=f2e5]: Internal Server Error + - button "Copy as Markdown" [ref=f2e11] [cursor=pointer] + - generic [ref=f2e18]: + - generic [ref=f2e19]: + - heading "Illuminate\\Contracts\\Container\\BindingResolutionException" [level=1] [ref=f2e20] + - generic [ref=f2e21]: vendor/laravel/framework/src/Illuminate/Container/Container.php:1124 + - paragraph [ref=f2e23]: Target class [current_store] does not exist. + - generic [ref=f2e24]: + - generic [ref=f2e25]: + - generic [ref=f2e26]: + - generic [ref=f2e27]: LARAVEL + - generic [ref=f2e28]: 12.51.0 + - generic [ref=f2e29]: + - generic [ref=f2e30]: PHP + - generic [ref=f2e31]: 8.4.17 + - generic [ref=f2e32]: UNHANDLED + - generic [ref=f2e36]: CODE 0 + - generic [ref=f2e38]: + - generic [ref=f2e39]: "500" + - generic [ref=f2e43]: POST + - generic [ref=f2e47]: http://acme-fashion.test/livewire-6701cc17/update + - button [ref=f2e48] [cursor=pointer] + - generic [ref=f2e53]: + - generic [ref=f2e54]: + - heading "Exception trace" [level=3] [ref=f2e60] + - generic [ref=f2e61]: + - generic [ref=f2e63] [cursor=pointer]: + - generic [ref=f2e68]: 6 vendor frames + - button [ref=f2e69] + - generic [ref=f2e74]: + - generic [ref=f2e75] [cursor=pointer]: + - generic [ref=f2e78]: + - code [ref=f2e82]: + - generic [ref=f2e83]: app/Livewire/Storefront/Products/Show.php + - generic [ref=f2e84]: app/Livewire/Storefront/Products/Show.php:34 + - button [ref=f2e87] + - code [ref=f2e96]: + - generic [ref=f2e97]: "29 {" + - generic [ref=f2e98]: 30 $this->validate(['selectedVariantId' => ['required', 'integer'], 'quantity' => ['required', 'integer', 'min:1']]); + - generic [ref=f2e99]: 31 $variant = $this->product->variants->firstWhere('id', $this->selectedVariantId); + - generic [ref=f2e100]: 32 abort_unless($variant instanceof ProductVariant, 404); + - generic [ref=f2e101]: 33 $customer = auth('customer')->user(); + - generic [ref=f2e102]: 34 $cart = $carts->getOrCreateForSession(app('current_store'), $customer); + - generic [ref=f2e103]: 35 $carts->addLine($cart, $variant->id, $this->quantity); + - generic [ref=f2e104]: 36 session()->flash('storefront_status', 'Added to cart'); + - generic [ref=f2e105]: 37 $this->dispatch('cart-updated'); + - generic [ref=f2e106]: "38 }" + - generic [ref=f2e107]: "39" + - generic [ref=f2e108]: "40 public function render(): View" + - generic [ref=f2e109]: "41 {" + - generic [ref=f2e110]: 42 return view('livewire.storefront.products.show', [ + - generic [ref=f2e111]: 43 'selectedVariant' => $this->product->variants->firstWhere('id', $this->selectedVariantId), + - generic [ref=f2e112]: 44 ])->layout('layouts.storefront', ['title' => $this->product->title.' - '.app('current_store')->name]); + - generic [ref=f2e113]: "45 }" + - generic [ref=f2e114]: "46" + - generic [ref=f2e116] [cursor=pointer]: + - generic [ref=f2e121]: 58 vendor frames + - button [ref=f2e122] + - generic [ref=f2e128] [cursor=pointer]: + - generic [ref=f2e131]: + - code [ref=f2e135]: + - generic [ref=f2e136]: public/index.php + - generic [ref=f2e137]: public/index.php:20 + - button [ref=f2e140] + - generic [ref=f2e146] [cursor=pointer]: + - generic [ref=f2e151]: 1 vendor frame + - button [ref=f2e152] + - generic [ref=f2e157]: + - generic [ref=f2e158]: + - heading "Queries" [level=3] [ref=f2e163] + - generic [ref=f2e164]: 1-2 of 2 + - generic [ref=f2e166]: + - generic [ref=f2e167]: + - generic [ref=f2e168]: + - generic [ref=f2e169]: sqlite + - code [ref=f2e176]: + - generic [ref=f2e177]: select * from "products" where "products"."id" = 1 limit 1 + - generic [ref=f2e178]: 2.29ms + - generic [ref=f2e179]: + - generic [ref=f2e180]: + - generic [ref=f2e181]: sqlite + - code [ref=f2e188]: + - generic [ref=f2e189]: select * from "product_variants" where "product_variants"."product_id" = 1 and "product_variants"."product_id" is not null order by "position" asc + - generic [ref=f2e190]: 0.34ms + - generic [ref=f2e192]: + - generic [ref=f2e193]: + - heading "Headers" [level=2] [ref=f2e194] + - generic [ref=f2e195]: + - generic [ref=f2e196]: + - generic [ref=f2e197]: cookie + - generic [ref=f2e199]: XSRF-TOKEN=eyJpdiI6IkxDU3lEMmg4KzhRNm42U1k0RGxIZnc9PSIsInZhbHVlIjoiZGwzMmlkK3MwekJWVDI3RmhtNG1ud2lWVW85TzZtT1RHV2NEMGt0Ry9vQ2graVAzUkN0VXhRemZoc0F1MEZZOXZucG1iMmxFZ1Y3YXRIcFl2eG43Q2JxYUkyUXdlaHc5Z3cxeWVJS2tuQm9lbG1UTy9KMmVYeGgycks5MDNEejciLCJtYWMiOiI1YTg5NWNmYTE0NGZmNGUyOTQ2OTRjMzBjZTJmMjI5OWRlOTMxNWExYTZlMGZiZDFlZThjZjA0ZGU4YjMxNzJkIiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6IjhzeldZb2tJeU9yODNkN3lUU1V1WXc9PSIsInZhbHVlIjoiWVdNUkpFQTNSSEVYa25ZTlc5VUc1NXJieVJLMTh5VDg5RzZhaHhwRFJtWmFmT0M2MzJZbDM2dHV5OTJBek9NTXdvTjBNLzdhdTdPWlZkT0NkNTBQdXFTTGNuK3J2ejJkWHVhZ1ZEM2k4S0xpNWE4RGg4eXR3RldZbGpza2FvVmMiLCJtYWMiOiI5MWNmNWMwZmY5NjQ1ZWY3ZDg4MDdlZTkxZGNhNzdmNTdlYjIyYjg5ODg3Y2I4YWY0ZmI1ZTcwOTVjMjE2NTIxIiwidGFnIjoiIn0%3D + - generic [ref=f2e200]: + - generic [ref=f2e201]: accept-language + - generic [ref=f2e203]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=f2e204]: + - generic [ref=f2e205]: accept-encoding + - generic [ref=f2e207]: gzip, deflate + - generic [ref=f2e208]: + - generic [ref=f2e209]: referer + - generic [ref=f2e211]: http://acme-fashion.test/products/classic-cotton-t-shirt + - generic [ref=f2e212]: + - generic [ref=f2e213]: origin + - generic [ref=f2e215]: http://acme-fashion.test + - generic [ref=f2e216]: + - generic [ref=f2e217]: accept + - generic [ref=f2e219]: "*/*" + - generic [ref=f2e220]: + - generic [ref=f2e221]: x-livewire + - generic [ref=f2e223]: "1" + - generic [ref=f2e224]: + - generic [ref=f2e225]: content-type + - generic [ref=f2e227]: application/json + - generic [ref=f2e228]: + - generic [ref=f2e229]: user-agent + - generic [ref=f2e231]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 + - generic [ref=f2e232]: + - generic [ref=f2e233]: content-length + - generic [ref=f2e235]: "634" + - generic [ref=f2e236]: + - generic [ref=f2e237]: connection + - generic [ref=f2e239]: keep-alive + - generic [ref=f2e240]: + - generic [ref=f2e241]: host + - generic [ref=f2e243]: acme-fashion.test + - generic [ref=f2e244]: + - heading "Body" [level=2] [ref=f2e245] + - code [ref=f2e250]: + - generic [ref=f2e251]: "{" + - generic [ref=f2e252]: "\"_token\": \"BLvnxGmt1x5SpbfmG3dDyA7evVNRFS6OHjX7PUMY\"," + - generic [ref=f2e253]: "\"components\": [" + - generic [ref=f2e254]: "{" + - generic [ref=f2e255]: "\"snapshot\": \"{\"data\":{\"product\":[null,{\"class\":\"AppModelsProduct\",\"key\":1,\"s\":\"mdl\"}],\"selectedVariantId\":1,\"quantity\":1},\"memo\":{\"id\":\"QOGOz2vMLHz5tBvp2JxQ\",\"name\":\"storefront.products.show\",\"path\":\"products/classic-cotton-t-shirt\",\"method\":\"GET\",\"release\":\"a-a-a\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\",\"islands\":[]},\"checksum\":\"afd9c7c4f72b2530e8844a6f625f168e6e1baa8d50bfd65eace590a5fb0887ca\"}\"," + - generic [ref=f2e256]: "\"updates\": []," + - generic [ref=f2e257]: "\"calls\": [" + - generic [ref=f2e258]: "{" + - generic [ref=f2e259]: "\"method\": \"addToCart\"," + - generic [ref=f2e260]: "\"params\": []," + - generic [ref=f2e261]: "\"metadata\": []" + - generic [ref=f2e262]: "}" + - generic [ref=f2e263]: "]" + - generic [ref=f2e264]: "}" + - generic [ref=f2e265]: "]" + - generic [ref=f2e266]: "}" + - generic [ref=f2e267]: + - heading "Routing" [level=2] [ref=f2e268] + - generic [ref=f2e269]: + - generic [ref=f2e270]: + - generic [ref=f2e271]: controller + - generic [ref=f2e273]: Livewire\Mechanisms\HandleRequests\HandleRequests@handleUpdate + - generic [ref=f2e274]: + - generic [ref=f2e275]: route name + - generic [ref=f2e277]: default-livewire.update + - generic [ref=f2e278]: + - generic [ref=f2e279]: middleware + - generic [ref=f2e281]: web + - generic [ref=f2e282]: + - heading "Routing parameters" [level=2] [ref=f2e283] + - generic [ref=f2e284]: // No routing parameters + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e3]: Free shipping available with code FREESHIP + - banner [ref=f1e4]: + - generic [ref=f1e5]: + - link "Acme Fashion" [ref=f1e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e7]: + - link "Home" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f1e12]: + - button "Open product search" [ref=f1e14]: + - generic [ref=f1e19]: Search + - button "Open shopping cart" [ref=f1e21]: + - generic [ref=f1e26]: Cart + - main [ref=f1e27]: + - generic [ref=f1e28]: + - navigation "Breadcrumb" [ref=f1e29]: + - list [ref=f1e30]: + - listitem [ref=f1e31]: + - link "Home" [ref=f1e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=f1e33]: / + - listitem [ref=f1e34]: + - link "Products" [ref=f1e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=f1e36]: / + - listitem [ref=f1e37]: + - generic [ref=f1e38]: Classic Cotton T-Shirt + - generic [ref=f1e39]: + - img "Classic Cotton T-Shirt" [ref=f1e40] + - generic [ref=f1e41]: + - generic [ref=f1e42]: + - paragraph [ref=f1e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=f1e44] + - generic [ref=f1e45]: €24.99 + - group "Choose a variant" [ref=f1e48]: + - generic [ref=f1e50]: + - generic [ref=f1e51] [cursor=pointer]: + - generic [ref=f1e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=f1e53] + - generic [ref=f1e54] [cursor=pointer]: + - generic [ref=f1e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=f1e56] + - generic [ref=f1e57] [cursor=pointer]: + - generic [ref=f1e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=f1e59] + - generic [ref=f1e60] [cursor=pointer]: + - generic [ref=f1e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=f1e62] + - generic [ref=f1e63] [cursor=pointer]: + - generic [ref=f1e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=f1e65] + - generic [ref=f1e66] [cursor=pointer]: + - generic [ref=f1e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=f1e68] + - generic [ref=f1e69] [cursor=pointer]: + - generic [ref=f1e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=f1e71] + - generic [ref=f1e72] [cursor=pointer]: + - generic [ref=f1e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=f1e74] + - generic [ref=f1e75] [cursor=pointer]: + - generic [ref=f1e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=f1e77] + - generic [ref=f1e78] [cursor=pointer]: + - generic [ref=f1e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=f1e80] + - generic [ref=f1e81] [cursor=pointer]: + - generic [ref=f1e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=f1e83] + - generic [ref=f1e84] [cursor=pointer]: + - generic [ref=f1e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=f1e86] + - paragraph [ref=f1e87]: In stock + - generic [ref=f1e88]: + - generic [ref=f1e89]: Quantity + - spinbutton "Quantity" [ref=f1e91]: "1" + - button "Add to cart" [ref=f1e92] + - paragraph [ref=f1e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=f1e100]: + - generic [ref=f1e101]: + - generic [ref=f1e102]: + - paragraph [ref=f1e103]: Acme Fashion + - paragraph [ref=f1e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f1e105]: + - link "Shop all collections" [ref=f1e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f1e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-04-12-885Z.yml b/.playwright-mcp/page-2026-07-11T11-04-12-885Z.yml new file mode 100644 index 00000000..702c3446 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-04-12-885Z.yml @@ -0,0 +1,98 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart + - main [ref=e27]: + - generic [ref=e28]: + - navigation "Breadcrumb" [ref=e29]: + - list [ref=e30]: + - listitem [ref=e31]: + - link "Home" [ref=e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=e33]: / + - listitem [ref=e34]: + - link "Products" [ref=e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e36]: / + - listitem [ref=e37]: + - generic [ref=e38]: Classic Cotton T-Shirt + - generic [ref=e39]: + - img "Classic Cotton T-Shirt" [ref=e40] + - generic [ref=e41]: + - generic [ref=e42]: + - paragraph [ref=e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=e44] + - generic [ref=e45]: €24.99 + - group "Choose a variant" [ref=e48]: + - generic [ref=e50]: + - generic [ref=e51] [cursor=pointer]: + - generic [ref=e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=e53] + - generic [ref=e54] [cursor=pointer]: + - generic [ref=e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=e56] + - generic [ref=e57] [cursor=pointer]: + - generic [ref=e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=e59] + - generic [ref=e60] [cursor=pointer]: + - generic [ref=e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=e62] + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=e65] + - generic [ref=e66] [cursor=pointer]: + - generic [ref=e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=e68] + - generic [ref=e69] [cursor=pointer]: + - generic [ref=e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=e71] + - generic [ref=e72] [cursor=pointer]: + - generic [ref=e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=e74] + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=e77] + - generic [ref=e78] [cursor=pointer]: + - generic [ref=e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=e80] + - generic [ref=e81] [cursor=pointer]: + - generic [ref=e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=e83] + - generic [ref=e84] [cursor=pointer]: + - generic [ref=e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=e86] + - paragraph [ref=e87]: In stock + - generic [ref=e88]: + - generic [ref=e89]: Quantity + - spinbutton "Quantity" [ref=e91]: "1" + - button "Add to cart" [ref=e92] + - paragraph [ref=e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=e100]: + - generic [ref=e101]: + - generic [ref=e102]: + - paragraph [ref=e103]: Acme Fashion + - paragraph [ref=e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e105]: + - link "Shop all collections" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-04-18-086Z.yml b/.playwright-mcp/page-2026-07-11T11-04-18-086Z.yml new file mode 100644 index 00000000..affd4429 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-04-18-086Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - generic [ref=e20]: + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart (1) + - dialog [ref=e109]: + - generic [ref=e110]: + - generic [ref=e111]: Shopping cart + - generic [ref=e112]: + - generic [ref=e113]: + - paragraph [ref=e114]: Classic Cotton T-Shirt + - paragraph [ref=e115]: Qty 1 + - button "Remove" [ref=e116] + - link "View cart" [ref=e122] [cursor=pointer]: + - /url: http://acme-fashion.test/cart + - button "Close modal" [ref=e125] + - main [ref=e27]: + - generic [ref=e28]: + - navigation "Breadcrumb" [ref=e29]: + - list [ref=e30]: + - listitem [ref=e31]: + - link "Home" [ref=e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=e33]: / + - listitem [ref=e34]: + - link "Products" [ref=e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e36]: / + - listitem [ref=e37]: + - generic [ref=e38]: Classic Cotton T-Shirt + - generic [ref=e39]: + - img "Classic Cotton T-Shirt" [ref=e40] + - generic [ref=e41]: + - generic [ref=e42]: + - paragraph [ref=e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=e44] + - generic [ref=e45]: €24.99 + - group "Choose a variant" [ref=e48]: + - generic [ref=e50]: + - generic [ref=e51] [cursor=pointer]: + - generic [ref=e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=e53] + - generic [ref=e54] [cursor=pointer]: + - generic [ref=e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=e56] + - generic [ref=e57] [cursor=pointer]: + - generic [ref=e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=e59] + - generic [ref=e60] [cursor=pointer]: + - generic [ref=e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=e62] + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=e65] + - generic [ref=e66] [cursor=pointer]: + - generic [ref=e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=e68] + - generic [ref=e69] [cursor=pointer]: + - generic [ref=e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=e71] + - generic [ref=e72] [cursor=pointer]: + - generic [ref=e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=e74] + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=e77] + - generic [ref=e78] [cursor=pointer]: + - generic [ref=e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=e80] + - generic [ref=e81] [cursor=pointer]: + - generic [ref=e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=e83] + - generic [ref=e84] [cursor=pointer]: + - generic [ref=e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=e86] + - paragraph [ref=e87]: In stock + - generic [ref=e88]: + - generic [ref=e89]: Quantity + - spinbutton "Quantity" [ref=e91]: "1" + - button "Add to cart" [ref=e92] + - paragraph [ref=e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=e100]: + - generic [ref=e101]: + - generic [ref=e102]: + - paragraph [ref=e103]: Acme Fashion + - paragraph [ref=e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e105]: + - link "Shop all collections" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-04-45-784Z.yml b/.playwright-mcp/page-2026-07-11T11-04-45-784Z.yml new file mode 100644 index 00000000..941753d2 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-04-45-784Z.yml @@ -0,0 +1,54 @@ +- generic [active] [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e3]: Free shipping available with code FREESHIP + - banner [ref=f1e4]: + - generic [ref=f1e5]: + - link "Acme Fashion" [ref=f1e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e7]: + - link "Home" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f1e12]: + - button "Open product search" [ref=f1e14]: + - generic [ref=f1e19]: Search + - button "Open shopping cart" [ref=f1e21]: + - generic [ref=f1e26]: Cart + - main [ref=f1e27]: + - generic [ref=f1e28]: + - heading "Your cart" [level=1] [ref=f1e29] + - generic [ref=f1e30]: + - article [ref=f1e32]: + - generic [ref=f1e34]: + - heading "Classic Cotton T-Shirt" [level=2] [ref=f1e35] + - generic [ref=f1e36]: €24.99 + - generic [ref=f1e38]: + - button "Decrease quantity" [ref=f1e39]: + - generic [ref=f1e44]: βˆ’ + - generic [ref=f1e45]: "1" + - button "Increase quantity" [ref=f1e46]: + - generic [ref=f1e51]: + + - button "Remove" [ref=f1e52] + - complementary [ref=f1e58]: + - heading "Order summary" [level=2] [ref=f1e59] + - generic [ref=f1e60]: + - generic [ref=f1e61]: Subtotal + - generic [ref=f1e62]: €24.99 + - button "Checkout" [ref=f1e64] + - contentinfo [ref=f1e70]: + - generic [ref=f1e71]: + - generic [ref=f1e72]: + - paragraph [ref=f1e73]: Acme Fashion + - paragraph [ref=f1e74]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f1e75]: + - link "Shop all collections" [ref=f1e76] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e77] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f1e78]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-04-59-720Z.yml b/.playwright-mcp/page-2026-07-11T11-04-59-720Z.yml new file mode 100644 index 00000000..26918e3b --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-04-59-720Z.yml @@ -0,0 +1,249 @@ +- generic [active] [ref=f1e1]: + - dialog [ref=f1e79]: + - iframe [ref=f1e80]: + - generic [ref=f2e2]: + - generic [ref=f2e4]: + - generic [ref=f2e5]: Internal Server Error + - button "Copy as Markdown" [ref=f2e11] [cursor=pointer] + - generic [ref=f2e18]: + - generic [ref=f2e19]: + - heading "TypeError" [level=1] [ref=f2e20] + - generic [ref=f2e21]: app/Livewire/Storefront/Cart/Show.php:38 + - paragraph [ref=f2e23]: "App\\Livewire\\Storefront\\Cart\\Show::checkout(): Return value must be of type Illuminate\\Http\\RedirectResponse, Livewire\\Features\\SupportRedirects\\Redirector returned" + - generic [ref=f2e24]: + - generic [ref=f2e25]: + - generic [ref=f2e26]: + - generic [ref=f2e27]: LARAVEL + - generic [ref=f2e28]: 12.51.0 + - generic [ref=f2e29]: + - generic [ref=f2e30]: PHP + - generic [ref=f2e31]: 8.4.17 + - generic [ref=f2e32]: UNHANDLED + - generic [ref=f2e36]: CODE 0 + - generic [ref=f2e38]: + - generic [ref=f2e39]: "500" + - generic [ref=f2e43]: POST + - generic [ref=f2e47]: http://acme-fashion.test/livewire-6701cc17/update + - button [ref=f2e48] [cursor=pointer] + - generic [ref=f2e53]: + - generic [ref=f2e54]: + - heading "Exception trace" [level=3] [ref=f2e60] + - generic [ref=f2e61]: + - generic [ref=f2e62]: + - generic [ref=f2e63] [cursor=pointer]: + - generic [ref=f2e66]: + - code [ref=f2e70]: + - generic [ref=f2e71]: app/Livewire/Storefront/Cart/Show.php + - generic [ref=f2e72]: app/Livewire/Storefront/Cart/Show.php:38 + - button [ref=f2e75] + - code [ref=f2e84]: + - generic [ref=f2e85]: "33" + - generic [ref=f2e86]: "34 public function checkout(CheckoutService $checkouts): RedirectResponse" + - generic [ref=f2e87]: "35 {" + - generic [ref=f2e88]: 36 $checkout = $checkouts->create($this->cart()); + - generic [ref=f2e89]: "37" + - generic [ref=f2e90]: 38 return redirect()->route('storefront.checkout.show', $checkout->id); + - generic [ref=f2e91]: "39 }" + - generic [ref=f2e92]: "40" + - generic [ref=f2e93]: "41 public function render(): View" + - generic [ref=f2e94]: "42 {" + - generic [ref=f2e95]: 43 return view('livewire.storefront.cart.show', ['cart' => $this->cart()]) + - generic [ref=f2e96]: 44 ->layout('layouts.storefront', ['title' => 'Cart - '.app('current_store')->name]); + - generic [ref=f2e97]: "45 }" + - generic [ref=f2e98]: "46" + - generic [ref=f2e99]: "47 private function cart(): Cart" + - generic [ref=f2e100]: "48 {" + - generic [ref=f2e101]: 49 return Cart::query()->with(['lines.variant.product', 'lines.variant.inventoryItem'])->findOrFail($this->cartId); + - generic [ref=f2e102]: "50" + - generic [ref=f2e104] [cursor=pointer]: + - generic [ref=f2e109]: 58 vendor frames + - button [ref=f2e110] + - generic [ref=f2e116] [cursor=pointer]: + - generic [ref=f2e119]: + - code [ref=f2e123]: + - generic [ref=f2e124]: public/index.php + - generic [ref=f2e125]: public/index.php:20 + - button [ref=f2e128] + - generic [ref=f2e134] [cursor=pointer]: + - generic [ref=f2e139]: 1 vendor frame + - button [ref=f2e140] + - generic [ref=f2e145]: + - generic [ref=f2e146]: + - heading "Queries" [level=3] [ref=f2e151] + - generic [ref=f2e152]: 1-8 of 8 + - generic [ref=f2e154]: + - generic [ref=f2e155]: + - generic [ref=f2e156]: + - generic [ref=f2e157]: sqlite + - code [ref=f2e164]: + - generic [ref=f2e165]: select * from "stores" where "stores"."id" = 1 limit 1 + - generic [ref=f2e166]: 2.79ms + - generic [ref=f2e167]: + - generic [ref=f2e168]: + - generic [ref=f2e169]: sqlite + - code [ref=f2e176]: + - generic [ref=f2e177]: select * from "carts" where "carts"."id" = 1 and "carts"."store_id" = 1 limit 1 + - generic [ref=f2e178]: 0.12ms + - generic [ref=f2e179]: + - generic [ref=f2e180]: + - generic [ref=f2e181]: sqlite + - code [ref=f2e188]: + - generic [ref=f2e189]: select * from "cart_lines" where "cart_lines"."cart_id" in (1) + - generic [ref=f2e190]: 0.04ms + - generic [ref=f2e191]: + - generic [ref=f2e192]: + - generic [ref=f2e193]: sqlite + - code [ref=f2e200]: + - generic [ref=f2e201]: select * from "product_variants" where "product_variants"."id" in (1) + - generic [ref=f2e202]: 0.07ms + - generic [ref=f2e203]: + - generic [ref=f2e204]: + - generic [ref=f2e205]: sqlite + - code [ref=f2e212]: + - generic [ref=f2e213]: select * from "products" where "products"."id" in (1) and "products"."store_id" = 1 + - generic [ref=f2e214]: 0.19ms + - generic [ref=f2e215]: + - generic [ref=f2e216]: + - generic [ref=f2e217]: sqlite + - code [ref=f2e224]: + - generic [ref=f2e225]: select * from "inventory_items" where "inventory_items"."variant_id" in (1) and "inventory_items"."store_id" = 1 + - generic [ref=f2e226]: 0.06ms + - generic [ref=f2e227]: + - generic [ref=f2e228]: + - generic [ref=f2e229]: sqlite + - code [ref=f2e236]: + - generic [ref=f2e237]: select exists(select * from "cart_lines" where "cart_lines"."cart_id" = 1 and "cart_lines"."cart_id" is not null) as "exists" + - generic [ref=f2e238]: 0.02ms + - generic [ref=f2e239]: + - generic [ref=f2e240]: + - generic [ref=f2e241]: sqlite + - code [ref=f2e248]: + - generic [ref=f2e249]: insert into "checkouts" ("status", "store_id", "cart_id", "customer_id", "updated_at", "created_at") values ('started', 1, 1, NULL, '2026-07-11 11:04:58', '2026-07-11 11:04:58') + - generic [ref=f2e250]: 0.38ms + - generic [ref=f2e252]: + - generic [ref=f2e253]: + - heading "Headers" [level=2] [ref=f2e254] + - generic [ref=f2e255]: + - generic [ref=f2e256]: + - generic [ref=f2e257]: cookie + - generic [ref=f2e259]: XSRF-TOKEN=eyJpdiI6IlFTcEhjR2ZBZHZqL3Z1UTQzdkJtdlE9PSIsInZhbHVlIjoiZDQwWkVsbWJCcFE4Y1h2OExYNjlQdTlqVFFBbEs4TnVaN1g1Ykc4RTB4QVBuWGlwS29odUh0bFF1Wk92RGFQbmJhZjR4dzFoK2g1VDdGaXd1b3F3SUQxWkhCNXdaZWFDNzNmb3ppeFFqcGRRYUFhRDN0YWk3S2U4TlFEZjVZeWgiLCJtYWMiOiI5MzliOGIzZmYwMDViODlkN2QyMTY4YWU5YWNkZTg3ZjI0ODQzMWFiMzZkMzc1NDgwNjllZTMxYTQxNGE4NjkwIiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6IkpPSU5QdENWTkpmT1dmenhMWUlUeXc9PSIsInZhbHVlIjoiVVZIWmVGSUlXUjRMUU9HM1B4K2lSRUZkUzdBbFVUVUE1Mk1SQXZkc1NyMmN3L1h5RXVTNHUvSC9BcWxteGpXYUNYbCtxSEVCaUNvN2VFZDRpejlyeTJJYTI3bXpmQk9nSmlTSmYxZ2p3ZmVGLzE1OEN5MVlXelhZZm1abXRZTnQiLCJtYWMiOiJjOGU5Y2RhYWRjN2FjODY0NjA1Y2I4M2E2NDcyNDkwNGFiODkwYjE1NDEzOTNiM2JhODYzZWE4ZmQ4YWZiMzBhIiwidGFnIjoiIn0%3D + - generic [ref=f2e260]: + - generic [ref=f2e261]: accept-language + - generic [ref=f2e263]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=f2e264]: + - generic [ref=f2e265]: accept-encoding + - generic [ref=f2e267]: gzip, deflate + - generic [ref=f2e268]: + - generic [ref=f2e269]: referer + - generic [ref=f2e271]: http://acme-fashion.test/cart + - generic [ref=f2e272]: + - generic [ref=f2e273]: origin + - generic [ref=f2e275]: http://acme-fashion.test + - generic [ref=f2e276]: + - generic [ref=f2e277]: accept + - generic [ref=f2e279]: "*/*" + - generic [ref=f2e280]: + - generic [ref=f2e281]: x-livewire + - generic [ref=f2e283]: "1" + - generic [ref=f2e284]: + - generic [ref=f2e285]: content-type + - generic [ref=f2e287]: application/json + - generic [ref=f2e288]: + - generic [ref=f2e289]: user-agent + - generic [ref=f2e291]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 + - generic [ref=f2e292]: + - generic [ref=f2e293]: content-length + - generic [ref=f2e295]: "490" + - generic [ref=f2e296]: + - generic [ref=f2e297]: connection + - generic [ref=f2e299]: keep-alive + - generic [ref=f2e300]: + - generic [ref=f2e301]: host + - generic [ref=f2e303]: acme-fashion.test + - generic [ref=f2e304]: + - heading "Body" [level=2] [ref=f2e305] + - code [ref=f2e310]: + - generic [ref=f2e311]: "{" + - generic [ref=f2e312]: "\"_token\": \"BLvnxGmt1x5SpbfmG3dDyA7evVNRFS6OHjX7PUMY\"," + - generic [ref=f2e313]: "\"components\": [" + - generic [ref=f2e314]: "{" + - generic [ref=f2e315]: "\"snapshot\": \"{\"data\":{\"cartId\":1},\"memo\":{\"id\":\"zDXuALWmt4P62je0agSV\",\"name\":\"storefront.cart.show\",\"path\":\"cart\",\"method\":\"GET\",\"release\":\"a-a-a\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\",\"islands\":[]},\"checksum\":\"ff7e7b024d96314eeb12025b6375d8dcff76f6001dc4b10afc22518fbd8c9423\"}\"," + - generic [ref=f2e316]: "\"updates\": []," + - generic [ref=f2e317]: "\"calls\": [" + - generic [ref=f2e318]: "{" + - generic [ref=f2e319]: "\"method\": \"checkout\"," + - generic [ref=f2e320]: "\"params\": []," + - generic [ref=f2e321]: "\"metadata\": []" + - generic [ref=f2e322]: "}" + - generic [ref=f2e323]: "]" + - generic [ref=f2e324]: "}" + - generic [ref=f2e325]: "]" + - generic [ref=f2e326]: "}" + - generic [ref=f2e327]: + - heading "Routing" [level=2] [ref=f2e328] + - generic [ref=f2e329]: + - generic [ref=f2e330]: + - generic [ref=f2e331]: controller + - generic [ref=f2e333]: Livewire\Mechanisms\HandleRequests\HandleRequests@handleUpdate + - generic [ref=f2e334]: + - generic [ref=f2e335]: route name + - generic [ref=f2e337]: default-livewire.update + - generic [ref=f2e338]: + - generic [ref=f2e339]: middleware + - generic [ref=f2e341]: web + - generic [ref=f2e342]: + - heading "Routing parameters" [level=2] [ref=f2e343] + - generic [ref=f2e344]: // No routing parameters + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e3]: Free shipping available with code FREESHIP + - banner [ref=f1e4]: + - generic [ref=f1e5]: + - link "Acme Fashion" [ref=f1e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e7]: + - link "Home" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f1e12]: + - button "Open product search" [ref=f1e14]: + - generic [ref=f1e19]: Search + - button "Open shopping cart" [ref=f1e21]: + - generic [ref=f1e26]: Cart + - main [ref=f1e27]: + - generic [ref=f1e28]: + - heading "Your cart" [level=1] [ref=f1e29] + - generic [ref=f1e30]: + - article [ref=f1e32]: + - generic [ref=f1e34]: + - heading "Classic Cotton T-Shirt" [level=2] [ref=f1e35] + - generic [ref=f1e36]: €24.99 + - generic [ref=f1e38]: + - button "Decrease quantity" [ref=f1e39]: + - generic [ref=f1e44]: βˆ’ + - generic [ref=f1e45]: "1" + - button "Increase quantity" [ref=f1e46]: + - generic [ref=f1e51]: + + - button "Remove" [ref=f1e52] + - complementary [ref=f1e58]: + - heading "Order summary" [level=2] [ref=f1e59] + - generic [ref=f1e60]: + - generic [ref=f1e61]: Subtotal + - generic [ref=f1e62]: €24.99 + - button "Checkout" [ref=f1e64] + - contentinfo [ref=f1e70]: + - generic [ref=f1e71]: + - generic [ref=f1e72]: + - paragraph [ref=f1e73]: Acme Fashion + - paragraph [ref=f1e74]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f1e75]: + - link "Shop all collections" [ref=f1e76] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e77] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f1e78]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-05-51-648Z.yml b/.playwright-mcp/page-2026-07-11T11-05-51-648Z.yml new file mode 100644 index 00000000..702c3446 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-05-51-648Z.yml @@ -0,0 +1,98 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart + - main [ref=e27]: + - generic [ref=e28]: + - navigation "Breadcrumb" [ref=e29]: + - list [ref=e30]: + - listitem [ref=e31]: + - link "Home" [ref=e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=e33]: / + - listitem [ref=e34]: + - link "Products" [ref=e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e36]: / + - listitem [ref=e37]: + - generic [ref=e38]: Classic Cotton T-Shirt + - generic [ref=e39]: + - img "Classic Cotton T-Shirt" [ref=e40] + - generic [ref=e41]: + - generic [ref=e42]: + - paragraph [ref=e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=e44] + - generic [ref=e45]: €24.99 + - group "Choose a variant" [ref=e48]: + - generic [ref=e50]: + - generic [ref=e51] [cursor=pointer]: + - generic [ref=e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=e53] + - generic [ref=e54] [cursor=pointer]: + - generic [ref=e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=e56] + - generic [ref=e57] [cursor=pointer]: + - generic [ref=e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=e59] + - generic [ref=e60] [cursor=pointer]: + - generic [ref=e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=e62] + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=e65] + - generic [ref=e66] [cursor=pointer]: + - generic [ref=e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=e68] + - generic [ref=e69] [cursor=pointer]: + - generic [ref=e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=e71] + - generic [ref=e72] [cursor=pointer]: + - generic [ref=e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=e74] + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=e77] + - generic [ref=e78] [cursor=pointer]: + - generic [ref=e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=e80] + - generic [ref=e81] [cursor=pointer]: + - generic [ref=e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=e83] + - generic [ref=e84] [cursor=pointer]: + - generic [ref=e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=e86] + - paragraph [ref=e87]: In stock + - generic [ref=e88]: + - generic [ref=e89]: Quantity + - spinbutton "Quantity" [ref=e91]: "1" + - button "Add to cart" [ref=e92] + - paragraph [ref=e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=e100]: + - generic [ref=e101]: + - generic [ref=e102]: + - paragraph [ref=e103]: Acme Fashion + - paragraph [ref=e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e105]: + - link "Shop all collections" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-05-52-725Z.yml b/.playwright-mcp/page-2026-07-11T11-05-52-725Z.yml new file mode 100644 index 00000000..dfd7502c --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-05-52-725Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - generic [ref=e20]: + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart (2) + - dialog [ref=e109]: + - generic [ref=e110]: + - generic [ref=e111]: Shopping cart + - generic [ref=e112]: + - generic [ref=e113]: + - paragraph [ref=e114]: Classic Cotton T-Shirt + - paragraph [ref=e115]: Qty 2 + - button "Remove" [ref=e116] + - link "View cart" [ref=e122] [cursor=pointer]: + - /url: http://acme-fashion.test/cart + - button "Close modal" [ref=e125] + - main [ref=e27]: + - generic [ref=e28]: + - navigation "Breadcrumb" [ref=e29]: + - list [ref=e30]: + - listitem [ref=e31]: + - link "Home" [ref=e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=e33]: / + - listitem [ref=e34]: + - link "Products" [ref=e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e36]: / + - listitem [ref=e37]: + - generic [ref=e38]: Classic Cotton T-Shirt + - generic [ref=e39]: + - img "Classic Cotton T-Shirt" [ref=e40] + - generic [ref=e41]: + - generic [ref=e42]: + - paragraph [ref=e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=e44] + - generic [ref=e45]: €24.99 + - group "Choose a variant" [ref=e48]: + - generic [ref=e50]: + - generic [ref=e51] [cursor=pointer]: + - generic [ref=e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=e53] + - generic [ref=e54] [cursor=pointer]: + - generic [ref=e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=e56] + - generic [ref=e57] [cursor=pointer]: + - generic [ref=e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=e59] + - generic [ref=e60] [cursor=pointer]: + - generic [ref=e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=e62] + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=e65] + - generic [ref=e66] [cursor=pointer]: + - generic [ref=e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=e68] + - generic [ref=e69] [cursor=pointer]: + - generic [ref=e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=e71] + - generic [ref=e72] [cursor=pointer]: + - generic [ref=e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=e74] + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=e77] + - generic [ref=e78] [cursor=pointer]: + - generic [ref=e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=e80] + - generic [ref=e81] [cursor=pointer]: + - generic [ref=e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=e83] + - generic [ref=e84] [cursor=pointer]: + - generic [ref=e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=e86] + - paragraph [ref=e87]: In stock + - generic [ref=e88]: + - generic [ref=e89]: Quantity + - spinbutton "Quantity" [ref=e91]: "1" + - button "Add to cart" [ref=e92] + - paragraph [ref=e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=e100]: + - generic [ref=e101]: + - generic [ref=e102]: + - paragraph [ref=e103]: Acme Fashion + - paragraph [ref=e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e105]: + - link "Shop all collections" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-05-53-349Z.yml b/.playwright-mcp/page-2026-07-11T11-05-53-349Z.yml new file mode 100644 index 00000000..929a1943 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-05-53-349Z.yml @@ -0,0 +1,54 @@ +- generic [active] [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e3]: Free shipping available with code FREESHIP + - banner [ref=f1e4]: + - generic [ref=f1e5]: + - link "Acme Fashion" [ref=f1e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e7]: + - link "Home" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f1e12]: + - button "Open product search" [ref=f1e14]: + - generic [ref=f1e19]: Search + - button "Open shopping cart" [ref=f1e21]: + - generic [ref=f1e26]: Cart + - main [ref=f1e27]: + - generic [ref=f1e28]: + - heading "Your cart" [level=1] [ref=f1e29] + - generic [ref=f1e30]: + - article [ref=f1e32]: + - generic [ref=f1e34]: + - heading "Classic Cotton T-Shirt" [level=2] [ref=f1e35] + - generic [ref=f1e36]: €49.98 + - generic [ref=f1e38]: + - button "Decrease quantity" [ref=f1e39]: + - generic [ref=f1e44]: βˆ’ + - generic [ref=f1e45]: "2" + - button "Increase quantity" [ref=f1e46]: + - generic [ref=f1e51]: + + - button "Remove" [ref=f1e52] + - complementary [ref=f1e58]: + - heading "Order summary" [level=2] [ref=f1e59] + - generic [ref=f1e60]: + - generic [ref=f1e61]: Subtotal + - generic [ref=f1e62]: €49.98 + - button "Checkout" [ref=f1e64] + - contentinfo [ref=f1e70]: + - generic [ref=f1e71]: + - generic [ref=f1e72]: + - paragraph [ref=f1e73]: Acme Fashion + - paragraph [ref=f1e74]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f1e75]: + - link "Shop all collections" [ref=f1e76] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e77] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f1e78]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-05-53-490Z.yml b/.playwright-mcp/page-2026-07-11T11-05-53-490Z.yml new file mode 100644 index 00000000..5304bc85 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-05-53-490Z.yml @@ -0,0 +1,78 @@ +- generic [active] [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38] + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42] + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46] + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50] + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54] + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58] + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 2 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €49.98 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €49.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-06-10-106Z.yml b/.playwright-mcp/page-2026-07-11T11-06-10-106Z.yml new file mode 100644 index 00000000..313a7505 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-06-10-106Z.yml @@ -0,0 +1,89 @@ +- generic [active] [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: browser-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Browser + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 2 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €49.98 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €49.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-06-22-031Z.yml b/.playwright-mcp/page-2026-07-11T11-06-22-031Z.yml new file mode 100644 index 00000000..156ef3ad --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-06-22-031Z.yml @@ -0,0 +1,89 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: browser-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Browser + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [active] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 2 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €49.98 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €49.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-06-23-086Z.yml b/.playwright-mcp/page-2026-07-11T11-06-23-086Z.yml new file mode 100644 index 00000000..3459b1b9 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-06-23-086Z.yml @@ -0,0 +1,105 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: browser-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Browser + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [active] [ref=f2e115] + - generic [ref=f2e121]: + - generic [ref=f2e122]: 3. Payment + - group "Payment method" [ref=f2e123]: + - generic [ref=f2e125] [cursor=pointer]: + - radio "Credit card" [checked] [ref=f2e126] + - generic [ref=f2e127]: Credit card + - generic [ref=f2e128] [cursor=pointer]: + - radio "PayPal" [ref=f2e129] + - generic [ref=f2e130]: PayPal + - generic [ref=f2e131] [cursor=pointer]: + - radio "Bank transfer" [ref=f2e132] + - generic [ref=f2e133]: Bank transfer + - generic [ref=f2e134]: + - generic [ref=f2e135]: Card number + - textbox "Card number" [ref=f2e137]: 4242 4242 4242 4242 + - button "Review payment" [ref=f2e138] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 2 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €49.98 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €54.97 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-06-31-119Z.yml b/.playwright-mcp/page-2026-07-11T11-06-31-119Z.yml new file mode 100644 index 00000000..508b1c97 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-06-31-119Z.yml @@ -0,0 +1,105 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: browser-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Browser + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - generic [ref=f2e121]: + - generic [ref=f2e122]: 3. Payment + - group "Payment method" [ref=f2e123]: + - generic [ref=f2e125] [cursor=pointer]: + - radio "Credit card" [checked] [ref=f2e126] + - generic [ref=f2e127]: Credit card + - generic [ref=f2e128] [cursor=pointer]: + - radio "PayPal" [ref=f2e129] + - generic [ref=f2e130]: PayPal + - generic [ref=f2e131] [cursor=pointer]: + - radio "Bank transfer" [ref=f2e132] + - generic [ref=f2e133]: Bank transfer + - generic [ref=f2e134]: + - generic [ref=f2e135]: Card number + - textbox "Card number" [ref=f2e137]: 4242 4242 4242 4242 + - button "Pay now" [active] [ref=f2e144] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 2 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €49.98 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €54.97 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-06-40-194Z.yml b/.playwright-mcp/page-2026-07-11T11-06-40-194Z.yml new file mode 100644 index 00000000..e73b14e7 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-06-40-194Z.yml @@ -0,0 +1,48 @@ +- generic [active] [ref=f3e1]: + - link "Skip to main content" [ref=f3e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f3e3]: Free shipping available with code FREESHIP + - banner [ref=f3e4]: + - generic [ref=f3e5]: + - link "Acme Fashion" [ref=f3e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f3e7]: + - link "Home" [ref=f3e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f3e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f3e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f3e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f3e12]: + - button "Open product search" [ref=f3e14]: + - generic [ref=f3e19]: Search + - button "Open shopping cart" [ref=f3e21]: + - generic [ref=f3e26]: Cart + - main [ref=f3e27]: + - generic [ref=f3e28]: + - generic [ref=f3e29]: βœ“ + - heading "Thank you for your order!" [level=1] [ref=f3e30] + - paragraph [ref=f3e31]: "Order #1016 has been placed." + - generic [ref=f3e32]: + - heading "Order details" [level=2] [ref=f3e33] + - generic [ref=f3e34]: + - generic [ref=f3e35]: 2 Γ— Classic Cotton T-Shirt + - generic [ref=f3e36]: €49.98 + - generic [ref=f3e38]: + - generic [ref=f3e39]: Total + - generic [ref=f3e40]: €54.97 + - link "Continue shopping" [ref=f3e42] [cursor=pointer]: + - /url: http://acme-fashion.test + - contentinfo [ref=f3e43]: + - generic [ref=f3e44]: + - generic [ref=f3e45]: + - paragraph [ref=f3e46]: Acme Fashion + - paragraph [ref=f3e47]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f3e48]: + - link "Shop all collections" [ref=f3e49] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f3e50] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f3e51]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-07-38-774Z.yml b/.playwright-mcp/page-2026-07-11T11-07-38-774Z.yml new file mode 100644 index 00000000..702c3446 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-07-38-774Z.yml @@ -0,0 +1,98 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart + - main [ref=e27]: + - generic [ref=e28]: + - navigation "Breadcrumb" [ref=e29]: + - list [ref=e30]: + - listitem [ref=e31]: + - link "Home" [ref=e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=e33]: / + - listitem [ref=e34]: + - link "Products" [ref=e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e36]: / + - listitem [ref=e37]: + - generic [ref=e38]: Classic Cotton T-Shirt + - generic [ref=e39]: + - img "Classic Cotton T-Shirt" [ref=e40] + - generic [ref=e41]: + - generic [ref=e42]: + - paragraph [ref=e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=e44] + - generic [ref=e45]: €24.99 + - group "Choose a variant" [ref=e48]: + - generic [ref=e50]: + - generic [ref=e51] [cursor=pointer]: + - generic [ref=e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=e53] + - generic [ref=e54] [cursor=pointer]: + - generic [ref=e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=e56] + - generic [ref=e57] [cursor=pointer]: + - generic [ref=e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=e59] + - generic [ref=e60] [cursor=pointer]: + - generic [ref=e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=e62] + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=e65] + - generic [ref=e66] [cursor=pointer]: + - generic [ref=e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=e68] + - generic [ref=e69] [cursor=pointer]: + - generic [ref=e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=e71] + - generic [ref=e72] [cursor=pointer]: + - generic [ref=e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=e74] + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=e77] + - generic [ref=e78] [cursor=pointer]: + - generic [ref=e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=e80] + - generic [ref=e81] [cursor=pointer]: + - generic [ref=e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=e83] + - generic [ref=e84] [cursor=pointer]: + - generic [ref=e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=e86] + - paragraph [ref=e87]: In stock + - generic [ref=e88]: + - generic [ref=e89]: Quantity + - spinbutton "Quantity" [ref=e91]: "1" + - button "Add to cart" [ref=e92] + - paragraph [ref=e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=e100]: + - generic [ref=e101]: + - generic [ref=e102]: + - paragraph [ref=e103]: Acme Fashion + - paragraph [ref=e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e105]: + - link "Shop all collections" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-07-39-867Z.yml b/.playwright-mcp/page-2026-07-11T11-07-39-867Z.yml new file mode 100644 index 00000000..affd4429 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-07-39-867Z.yml @@ -0,0 +1,110 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - generic [ref=e20]: + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart (1) + - dialog [ref=e109]: + - generic [ref=e110]: + - generic [ref=e111]: Shopping cart + - generic [ref=e112]: + - generic [ref=e113]: + - paragraph [ref=e114]: Classic Cotton T-Shirt + - paragraph [ref=e115]: Qty 1 + - button "Remove" [ref=e116] + - link "View cart" [ref=e122] [cursor=pointer]: + - /url: http://acme-fashion.test/cart + - button "Close modal" [ref=e125] + - main [ref=e27]: + - generic [ref=e28]: + - navigation "Breadcrumb" [ref=e29]: + - list [ref=e30]: + - listitem [ref=e31]: + - link "Home" [ref=e32] [cursor=pointer]: + - /url: http://acme-fashion.test + - generic [ref=e33]: / + - listitem [ref=e34]: + - link "Products" [ref=e35] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=e36]: / + - listitem [ref=e37]: + - generic [ref=e38]: Classic Cotton T-Shirt + - generic [ref=e39]: + - img "Classic Cotton T-Shirt" [ref=e40] + - generic [ref=e41]: + - generic [ref=e42]: + - paragraph [ref=e43]: Acme Basics + - heading "Classic Cotton T-Shirt" [level=1] [ref=e44] + - generic [ref=e45]: €24.99 + - group "Choose a variant" [ref=e48]: + - generic [ref=e50]: + - generic [ref=e51] [cursor=pointer]: + - generic [ref=e52]: ACME-CLASSICC-S-WHITE-1 + - radio "Select variant ACME-CLASSICC-S-WHITE-1" [checked] [ref=e53] + - generic [ref=e54] [cursor=pointer]: + - generic [ref=e55]: ACME-CLASSICC-S-BLACK-2 + - radio "Select variant ACME-CLASSICC-S-BLACK-2" [ref=e56] + - generic [ref=e57] [cursor=pointer]: + - generic [ref=e58]: ACME-CLASSICC-S-NAVY-3 + - radio "Select variant ACME-CLASSICC-S-NAVY-3" [ref=e59] + - generic [ref=e60] [cursor=pointer]: + - generic [ref=e61]: ACME-CLASSICC-M-WHITE-4 + - radio "Select variant ACME-CLASSICC-M-WHITE-4" [ref=e62] + - generic [ref=e63] [cursor=pointer]: + - generic [ref=e64]: ACME-CLASSICC-M-BLACK-5 + - radio "Select variant ACME-CLASSICC-M-BLACK-5" [ref=e65] + - generic [ref=e66] [cursor=pointer]: + - generic [ref=e67]: ACME-CLASSICC-M-NAVY-6 + - radio "Select variant ACME-CLASSICC-M-NAVY-6" [ref=e68] + - generic [ref=e69] [cursor=pointer]: + - generic [ref=e70]: ACME-CLASSICC-L-WHITE-7 + - radio "Select variant ACME-CLASSICC-L-WHITE-7" [ref=e71] + - generic [ref=e72] [cursor=pointer]: + - generic [ref=e73]: ACME-CLASSICC-L-BLACK-8 + - radio "Select variant ACME-CLASSICC-L-BLACK-8" [ref=e74] + - generic [ref=e75] [cursor=pointer]: + - generic [ref=e76]: ACME-CLASSICC-L-NAVY-9 + - radio "Select variant ACME-CLASSICC-L-NAVY-9" [ref=e77] + - generic [ref=e78] [cursor=pointer]: + - generic [ref=e79]: ACME-CLASSICC-XL-WHITE-10 + - radio "Select variant ACME-CLASSICC-XL-WHITE-10" [ref=e80] + - generic [ref=e81] [cursor=pointer]: + - generic [ref=e82]: ACME-CLASSICC-XL-BLACK-11 + - radio "Select variant ACME-CLASSICC-XL-BLACK-11" [ref=e83] + - generic [ref=e84] [cursor=pointer]: + - generic [ref=e85]: ACME-CLASSICC-XL-NAVY-12 + - radio "Select variant ACME-CLASSICC-XL-NAVY-12" [ref=e86] + - paragraph [ref=e87]: In stock + - generic [ref=e88]: + - generic [ref=e89]: Quantity + - spinbutton "Quantity" [ref=e91]: "1" + - button "Add to cart" [ref=e92] + - paragraph [ref=e99]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear. + - contentinfo [ref=e100]: + - generic [ref=e101]: + - generic [ref=e102]: + - paragraph [ref=e103]: Acme Fashion + - paragraph [ref=e104]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e105]: + - link "Shop all collections" [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e107] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e108]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-07-40-483Z.yml b/.playwright-mcp/page-2026-07-11T11-07-40-483Z.yml new file mode 100644 index 00000000..941753d2 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-07-40-483Z.yml @@ -0,0 +1,54 @@ +- generic [active] [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e3]: Free shipping available with code FREESHIP + - banner [ref=f1e4]: + - generic [ref=f1e5]: + - link "Acme Fashion" [ref=f1e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e7]: + - link "Home" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f1e12]: + - button "Open product search" [ref=f1e14]: + - generic [ref=f1e19]: Search + - button "Open shopping cart" [ref=f1e21]: + - generic [ref=f1e26]: Cart + - main [ref=f1e27]: + - generic [ref=f1e28]: + - heading "Your cart" [level=1] [ref=f1e29] + - generic [ref=f1e30]: + - article [ref=f1e32]: + - generic [ref=f1e34]: + - heading "Classic Cotton T-Shirt" [level=2] [ref=f1e35] + - generic [ref=f1e36]: €24.99 + - generic [ref=f1e38]: + - button "Decrease quantity" [ref=f1e39]: + - generic [ref=f1e44]: βˆ’ + - generic [ref=f1e45]: "1" + - button "Increase quantity" [ref=f1e46]: + - generic [ref=f1e51]: + + - button "Remove" [ref=f1e52] + - complementary [ref=f1e58]: + - heading "Order summary" [level=2] [ref=f1e59] + - generic [ref=f1e60]: + - generic [ref=f1e61]: Subtotal + - generic [ref=f1e62]: €24.99 + - button "Checkout" [ref=f1e64] + - contentinfo [ref=f1e70]: + - generic [ref=f1e71]: + - generic [ref=f1e72]: + - paragraph [ref=f1e73]: Acme Fashion + - paragraph [ref=f1e74]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f1e75]: + - link "Shop all collections" [ref=f1e76] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e77] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f1e78]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-07-41-607Z.yml b/.playwright-mcp/page-2026-07-11T11-07-41-607Z.yml new file mode 100644 index 00000000..a321819a --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-07-41-607Z.yml @@ -0,0 +1,78 @@ +- generic [active] [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38] + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42] + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46] + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50] + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54] + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58] + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €24.99 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-07-51-525Z.yml b/.playwright-mcp/page-2026-07-11T11-07-51-525Z.yml new file mode 100644 index 00000000..adc0f522 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-07-51-525Z.yml @@ -0,0 +1,89 @@ +- generic [active] [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: decline-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Decline + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €24.99 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-07-59-019Z.yml b/.playwright-mcp/page-2026-07-11T11-07-59-019Z.yml new file mode 100644 index 00000000..11c59541 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-07-59-019Z.yml @@ -0,0 +1,89 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: decline-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Decline + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [active] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €24.99 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-00-072Z.yml b/.playwright-mcp/page-2026-07-11T11-08-00-072Z.yml new file mode 100644 index 00000000..2c646cad --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-00-072Z.yml @@ -0,0 +1,105 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: decline-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Decline + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [active] [ref=f2e115] + - generic [ref=f2e121]: + - generic [ref=f2e122]: 3. Payment + - group "Payment method" [ref=f2e123]: + - generic [ref=f2e125] [cursor=pointer]: + - radio "Credit card" [checked] [ref=f2e126] + - generic [ref=f2e127]: Credit card + - generic [ref=f2e128] [cursor=pointer]: + - radio "PayPal" [ref=f2e129] + - generic [ref=f2e130]: PayPal + - generic [ref=f2e131] [cursor=pointer]: + - radio "Bank transfer" [ref=f2e132] + - generic [ref=f2e133]: Bank transfer + - generic [ref=f2e134]: + - generic [ref=f2e135]: Card number + - textbox "Card number" [ref=f2e137]: 4242 4242 4242 4242 + - button "Review payment" [ref=f2e138] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €29.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-01-120Z.yml b/.playwright-mcp/page-2026-07-11T11-08-01-120Z.yml new file mode 100644 index 00000000..bd4af3cd --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-01-120Z.yml @@ -0,0 +1,105 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: decline-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Decline + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - generic [ref=f2e121]: + - generic [ref=f2e122]: 3. Payment + - group "Payment method" [ref=f2e123]: + - generic [ref=f2e125] [cursor=pointer]: + - radio "Credit card" [checked] [ref=f2e126] + - generic [ref=f2e127]: Credit card + - generic [ref=f2e128] [cursor=pointer]: + - radio "PayPal" [ref=f2e129] + - generic [ref=f2e130]: PayPal + - generic [ref=f2e131] [cursor=pointer]: + - radio "Bank transfer" [ref=f2e132] + - generic [ref=f2e133]: Bank transfer + - generic [ref=f2e134]: + - generic [ref=f2e135]: Card number + - textbox "Card number" [ref=f2e137]: 4242 4242 4242 4242 + - button "Pay now" [active] [ref=f2e144] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €29.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-11-001Z.yml b/.playwright-mcp/page-2026-07-11T11-08-11-001Z.yml new file mode 100644 index 00000000..ee6a3037 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-11-001Z.yml @@ -0,0 +1,106 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: decline-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Decline + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - generic [ref=f2e121]: + - generic [ref=f2e122]: 3. Payment + - group "Payment method" [ref=f2e123]: + - generic [ref=f2e125] [cursor=pointer]: + - radio "Credit card" [checked] [ref=f2e126] + - generic [ref=f2e127]: Credit card + - generic [ref=f2e128] [cursor=pointer]: + - radio "PayPal" [ref=f2e129] + - generic [ref=f2e130]: PayPal + - generic [ref=f2e131] [cursor=pointer]: + - radio "Bank transfer" [ref=f2e132] + - generic [ref=f2e133]: Bank transfer + - generic [ref=f2e134]: + - generic [ref=f2e135]: Card number + - textbox "Card number" [invalid] [ref=f2e137]: 4000 0000 0000 0002 + - alert [ref=f2e145]: The payment was declined. + - button "Review payment" [active] [ref=f2e148] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €29.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-26-207Z.yml b/.playwright-mcp/page-2026-07-11T11-08-26-207Z.yml new file mode 100644 index 00000000..18b7912f --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-26-207Z.yml @@ -0,0 +1,102 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: decline-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Decline + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - generic [ref=f2e121]: + - generic [ref=f2e122]: 3. Payment + - group "Payment method" [ref=f2e123]: + - generic [ref=f2e125] [cursor=pointer]: + - radio "Credit card" [ref=f2e126] + - generic [ref=f2e127]: Credit card + - generic [ref=f2e128] [cursor=pointer]: + - radio "PayPal" [ref=f2e129] + - generic [ref=f2e130]: PayPal + - generic [ref=f2e131] [cursor=pointer]: + - radio "Bank transfer" [checked] [active] [ref=f2e132] + - generic [ref=f2e133]: Bank transfer + - button "Review payment" [ref=f2e148] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €29.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-27-255Z.yml b/.playwright-mcp/page-2026-07-11T11-08-27-255Z.yml new file mode 100644 index 00000000..845afbef --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-27-255Z.yml @@ -0,0 +1,102 @@ +- generic [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Fashion" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - heading "Checkout" [level=1] [ref=f2e29] + - generic [ref=f2e30]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: + - generic [ref=f2e33]: 1. Contact and shipping address + - generic [ref=f2e34]: + - generic [ref=f2e35]: + - generic [ref=f2e36]: Email + - textbox "Email" [ref=f2e38]: decline-review@example.test + - generic [ref=f2e39]: + - generic [ref=f2e40]: First name + - textbox "First name" [ref=f2e42]: Decline + - generic [ref=f2e43]: + - generic [ref=f2e44]: Last name + - textbox "Last name" [ref=f2e46]: Reviewer + - generic [ref=f2e47]: + - generic [ref=f2e48]: Address + - textbox "Address" [ref=f2e50]: Teststrasse 42 + - generic [ref=f2e51]: + - generic [ref=f2e52]: City + - textbox "City" [ref=f2e54]: Berlin + - generic [ref=f2e55]: + - generic [ref=f2e56]: Postal code + - textbox "Postal code" [ref=f2e58]: "10115" + - generic [ref=f2e59]: + - generic [ref=f2e60]: Country code + - textbox "Country code" [ref=f2e62]: DE + - generic [ref=f2e63]: + - generic [ref=f2e64]: Phone (optional) + - textbox "Phone (optional)" [ref=f2e66] + - button "Continue to shipping" [ref=f2e67] + - generic [ref=f2e105]: + - generic [ref=f2e106]: 2. Shipping method + - group "Choose a shipping method" [ref=f2e107]: + - generic [ref=f2e109] [cursor=pointer]: + - generic [ref=f2e110]: Standard Shipping + - radio "Standard Shipping" [checked] [ref=f2e111] + - generic [ref=f2e112] [cursor=pointer]: + - generic [ref=f2e113]: Express Shipping + - radio "Express Shipping" [ref=f2e114] + - button "Continue to payment" [ref=f2e115] + - generic [ref=f2e121]: + - generic [ref=f2e122]: 3. Payment + - group "Payment method" [ref=f2e123]: + - generic [ref=f2e125] [cursor=pointer]: + - radio "Credit card" [ref=f2e126] + - generic [ref=f2e127]: Credit card + - generic [ref=f2e128] [cursor=pointer]: + - radio "PayPal" [ref=f2e129] + - generic [ref=f2e130]: PayPal + - generic [ref=f2e131] [cursor=pointer]: + - radio "Bank transfer" [checked] [ref=f2e132] + - generic [ref=f2e133]: Bank transfer + - button "Pay now" [active] [ref=f2e149] + - complementary [ref=f2e73]: + - heading "Order summary" [level=2] [ref=f2e74] + - generic [ref=f2e76]: + - generic [ref=f2e77]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f2e78]: €24.99 + - generic [ref=f2e81]: + - generic [ref=f2e82]: Total + - generic [ref=f2e83]: €29.98 + - generic [ref=f2e85]: + - generic [ref=f2e86]: + - generic [ref=f2e87]: Discount code + - textbox "Discount code" [ref=f2e89] + - button "Apply discount" [ref=f2e90] + - contentinfo [ref=f2e96]: + - generic [ref=f2e97]: + - generic [ref=f2e98]: + - paragraph [ref=f2e99]: Acme Fashion + - paragraph [ref=f2e100]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e101]: + - link "Shop all collections" [ref=f2e102] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f2e103] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f2e104]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-28-388Z.yml b/.playwright-mcp/page-2026-07-11T11-08-28-388Z.yml new file mode 100644 index 00000000..37ad2647 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-28-388Z.yml @@ -0,0 +1,52 @@ +- generic [active] [ref=f3e1]: + - link "Skip to main content" [ref=f3e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f3e3]: Free shipping available with code FREESHIP + - banner [ref=f3e4]: + - generic [ref=f3e5]: + - link "Acme Fashion" [ref=f3e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f3e7]: + - link "Home" [ref=f3e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f3e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f3e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=f3e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=f3e12]: + - button "Open product search" [ref=f3e14]: + - generic [ref=f3e19]: Search + - button "Open shopping cart" [ref=f3e21]: + - generic [ref=f3e26]: Cart + - main [ref=f3e27]: + - generic [ref=f3e28]: + - generic [ref=f3e29]: βœ“ + - heading "Thank you for your order!" [level=1] [ref=f3e30] + - paragraph [ref=f3e31]: "Order #1017 has been placed." + - generic [ref=f3e34]: + - generic [ref=f3e35]: Bank transfer instructions + - paragraph [ref=f3e36]: Mock Bank AG Β· IBAN DE89 3704 0044 0532 0130 00 Β· BIC COBADEFFXXX + - paragraph [ref=f3e37]: "Use #1017 as the payment reference." + - generic [ref=f3e38]: + - heading "Order details" [level=2] [ref=f3e39] + - generic [ref=f3e40]: + - generic [ref=f3e41]: 1 Γ— Classic Cotton T-Shirt + - generic [ref=f3e42]: €24.99 + - generic [ref=f3e44]: + - generic [ref=f3e45]: Total + - generic [ref=f3e46]: €29.98 + - link "Continue shopping" [ref=f3e48] [cursor=pointer]: + - /url: http://acme-fashion.test + - contentinfo [ref=f3e49]: + - generic [ref=f3e50]: + - generic [ref=f3e51]: + - paragraph [ref=f3e52]: Acme Fashion + - paragraph [ref=f3e53]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f3e54]: + - link "Shop all collections" [ref=f3e55] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f3e56] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f3e57]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-37-037Z.yml b/.playwright-mcp/page-2026-07-11T11-08-37-037Z.yml new file mode 100644 index 00000000..999e0160 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-37-037Z.yml @@ -0,0 +1,20 @@ +- generic [ref=e3]: + - link "Shop" [ref=e4] [cursor=pointer]: + - /url: http://acme-fashion.test/home + - main [ref=e11]: + - generic [ref=e12]: + - heading "Sign in" [level=1] [ref=e16] + - paragraph [ref=e17]: Access your store administration. + - generic [ref=e18]: + - generic [ref=e19]: + - generic [ref=e20]: Email + - textbox "Email" [active] [ref=e22] + - generic [ref=e23]: + - generic [ref=e24]: Password + - generic [ref=e25]: + - textbox "Password" [ref=e26] + - button "Toggle password visibility" [ref=e28] + - generic [ref=e32]: + - checkbox "Remember me" [ref=e33] + - generic [ref=e35]: Remember me + - button "Sign in" [ref=e36] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-45-914Z.yml b/.playwright-mcp/page-2026-07-11T11-08-45-914Z.yml new file mode 100644 index 00000000..f891679f --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-45-914Z.yml @@ -0,0 +1,158 @@ +- generic [ref=e43]: + - complementary [ref=e44]: + - navigation "Admin navigation" [ref=e45]: + - generic [ref=e50]: + - generic [ref=e51]: Shop + - generic [ref=e52]: Commerce platform + - generic [ref=e53]: + - link "Dashboard" [ref=e55] [cursor=pointer]: + - /url: /admin + - generic [ref=e59]: Products + - generic [ref=e60]: + - link "Products" [ref=e61] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=e65] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=e69] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=e73]: Orders + - link "Orders" [ref=e75] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=e79]: Customers + - link "Customers" [ref=e81] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=e85]: Discounts + - link "Discounts" [ref=e87] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=e92]: Content + - generic [ref=e93]: + - link "Pages" [ref=e94] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=e98] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=e102] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=e106]: Insights + - generic [ref=e107]: + - link "Analytics" [ref=e108] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=e113] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=e117]: Platform + - generic [ref=e118]: + - link "Settings" [ref=e119] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=e124] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=e128] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=e132]: + - banner [ref=e133]: + - generic [ref=e134]: + - button "Acme Fashion" [ref=e137] + - generic [ref=e141]: + - button "Notifications" [ref=e143] + - button "AU Admin User" [ref=e147]: + - generic [ref=e148]: AU + - generic [ref=e151]: Admin User + - main [ref=e155]: + - generic [ref=e156]: + - generic [ref=e157]: + - link "Home" [ref=e159] [cursor=pointer]: + - /url: /admin + - generic [ref=e162]: Dashboard + - generic [ref=e164]: + - generic [ref=e165]: + - heading "Dashboard" [level=1] [ref=e166] + - paragraph [ref=e167]: A live view of store performance. + - combobox "Date range" [ref=e169]: + - option "Today" + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Custom range" + - generic [ref=e170]: + - generic [ref=e173]: + - paragraph [ref=e174]: Total sales + - generic [ref=e175]: €1,602.07 + - generic [ref=e180]: + - paragraph [ref=e181]: Orders + - generic [ref=e182]: "17" + - generic [ref=e187]: + - paragraph [ref=e188]: Average order value + - generic [ref=e189]: €94.23 + - generic [ref=e194]: + - paragraph [ref=e195]: Visitors + - generic [ref=e196]: 3,481 + - generic [ref=e199]: + - generic [ref=e200]: + - generic [ref=e201]: Orders over time + - generic "Orders chart" [ref=e203]: + - generic: + - 'generic "2026-06-16: 1"' + - generic: + - 'generic "2026-06-21: 1"' + - generic: + - 'generic "2026-06-26: 1"' + - generic: + - 'generic "2026-06-27: 1"' + - generic: + - 'generic "2026-06-29: 1"' + - generic: + - 'generic "2026-07-01: 1"' + - generic: + - 'generic "2026-07-06: 1"' + - generic: + - 'generic "2026-07-07: 1"' + - generic: + - 'generic "2026-07-08: 1"' + - generic: + - 'generic "2026-07-09: 1"' + - generic: + - 'generic "2026-07-10: 3"' + - generic: + - 'generic "2026-07-11: 4"' + - generic [ref=e204]: + - generic [ref=e205]: Conversion funnel + - generic [ref=e207]: + - generic [ref=e209]: + - generic [ref=e210]: Visits + - generic [ref=e211]: 3,481 + - generic [ref=e215]: + - generic [ref=e216]: Add To Cart + - generic [ref=e217]: "740" + - generic [ref=e221]: + - generic [ref=e222]: Checkout Started + - generic [ref=e223]: "352" + - generic [ref=e227]: + - generic [ref=e228]: Checkout Completed + - generic [ref=e229]: "152" + - generic [ref=e232]: + - generic [ref=e233]: Top products + - table [ref=e236]: + - rowgroup [ref=e237]: + - row [ref=e238]: + - columnheader "Product" [ref=e239] + - columnheader "Units sold" [ref=e240] + - columnheader "Revenue" [ref=e241] + - rowgroup [ref=e242]: + - row [ref=e243]: + - cell "Cashmere Overcoat" [ref=e244] + - cell "1" [ref=e245] + - cell "€499.99" [ref=e246] + - row [ref=e247]: + - cell "Classic Cotton T-Shirt" [ref=e248] + - cell "8" [ref=e249] + - cell "€199.92" [ref=e250] + - row [ref=e251]: + - cell "Running Sneakers" [ref=e252] + - cell "1" [ref=e253] + - cell "€119.99" [ref=e254] + - row [ref=e255]: + - cell "Premium Slim Fit Jeans" [ref=e256] + - cell "1" [ref=e257] + - cell "€79.99" [ref=e258] + - row [ref=e259]: + - cell "Chino Shorts" [ref=e260] + - cell "2" [ref=e261] + - cell "€79.98" [ref=e262] + - status \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-08-53-729Z.yml b/.playwright-mcp/page-2026-07-11T11-08-53-729Z.yml new file mode 100644 index 00000000..b71de6a4 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-08-53-729Z.yml @@ -0,0 +1,289 @@ +- generic [ref=e264]: + - complementary [ref=e265]: + - navigation "Admin navigation" [ref=e266]: + - generic [ref=e271]: + - generic [ref=e272]: Shop + - generic [ref=e273]: Commerce platform + - generic [ref=e274]: + - link "Dashboard" [ref=e276] [cursor=pointer]: + - /url: /admin + - generic [ref=e280]: Products + - generic [ref=e281]: + - link "Products" [ref=e282] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=e286] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=e290] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=e294]: Orders + - link "Orders" [ref=e296] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=e300]: Customers + - link "Customers" [ref=e302] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=e306]: Discounts + - link "Discounts" [ref=e308] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=e313]: Content + - generic [ref=e314]: + - link "Pages" [ref=e315] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=e319] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=e323] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=e327]: Insights + - generic [ref=e328]: + - link "Analytics" [ref=e329] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=e334] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=e338]: Platform + - generic [ref=e339]: + - link "Settings" [ref=e340] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=e345] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=e349] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=e353]: + - banner [ref=e354]: + - generic [ref=e355]: + - button "Acme Fashion" [ref=e358] + - generic [ref=e362]: + - button "Notifications" [ref=e364] + - button "AU Admin User" [ref=e368]: + - generic [ref=e369]: AU + - generic [ref=e372]: Admin User + - main [ref=e376]: + - generic [ref=e377]: + - generic [ref=e378]: + - link "Home" [ref=e380] [cursor=pointer]: + - /url: /admin + - generic [ref=e383]: Products + - generic [ref=e385]: + - generic [ref=e386]: + - heading "Products" [level=1] [ref=e387] + - paragraph [ref=e388]: Manage catalog products, variants, and publishing. + - link "Add product" [ref=e390] [cursor=pointer]: + - /url: /admin/products/create + - generic [ref=e395]: + - textbox "Search products" [ref=e397]: + - /placeholder: Search products… + - combobox "Filter status" [ref=e399]: + - option "All statuses" [selected] + - option "Active" + - option "Draft" + - option "Archived" + - combobox "Filter product type" [ref=e400]: + - option "All types" [selected] + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - generic [ref=e401]: + - table [ref=e403]: + - rowgroup [ref=e404]: + - row [ref=e405]: + - columnheader "Select" [ref=e406] + - columnheader [ref=e408]: + - button "Product" [ref=e409] + - columnheader "Status" [ref=e410] + - columnheader "Inventory" [ref=e411] + - columnheader "Type" [ref=e412] + - columnheader "Vendor" [ref=e413] + - columnheader [ref=e414]: + - button "Updated" [ref=e415] + - rowgroup [ref=e416]: + - row [ref=e417]: + - cell [ref=e418]: + - checkbox "Select Unreleased Winter Jacket" [ref=e419] + - cell "Unreleased Winter Jacket 4 variants" [ref=e421]: + - link "Unreleased Winter Jacket" [ref=e422] [cursor=pointer]: + - /url: /admin/products/15/edit + - generic [ref=e423]: 4 variants + - cell "Draft" [ref=e424] + - cell "0" [ref=e426] + - cell "Jackets" [ref=e427] + - cell "Acme Outerwear" [ref=e428] + - cell "8 minutes ago" [ref=e429] + - row [ref=e430]: + - cell [ref=e431]: + - checkbox "Select Discontinued Raincoat" [ref=e432] + - cell "Discontinued Raincoat 2 variants" [ref=e434]: + - link "Discontinued Raincoat" [ref=e435] [cursor=pointer]: + - /url: /admin/products/16/edit + - generic [ref=e436]: 2 variants + - cell "Archived" [ref=e437] + - cell "6" [ref=e439] + - cell "Jackets" [ref=e440] + - cell "Acme Outerwear" [ref=e441] + - cell "8 minutes ago" [ref=e442] + - row [ref=e443]: + - cell [ref=e444]: + - checkbox "Select Classic Cotton T-Shirt" [ref=e445] + - cell "Classic Cotton T-Shirt 12 variants" [ref=e447]: + - link "Classic Cotton T-Shirt" [ref=e448] [cursor=pointer]: + - /url: /admin/products/1/edit + - generic [ref=e449]: 12 variants + - cell "Active" [ref=e450] + - cell "178" [ref=e452] + - cell "T-Shirts" [ref=e453] + - cell "Acme Basics" [ref=e454] + - cell "8 minutes ago" [ref=e455] + - row [ref=e456]: + - cell [ref=e457]: + - checkbox "Select Premium Slim Fit Jeans" [ref=e458] + - cell "Premium Slim Fit Jeans 10 variants" [ref=e460]: + - link "Premium Slim Fit Jeans" [ref=e461] [cursor=pointer]: + - /url: /admin/products/2/edit + - generic [ref=e462]: 10 variants + - cell "Active" [ref=e463] + - cell "80" [ref=e465] + - cell "Pants" [ref=e466] + - cell "Acme Denim" [ref=e467] + - cell "8 minutes ago" [ref=e468] + - row [ref=e469]: + - cell [ref=e470]: + - checkbox "Select Organic Hoodie" [ref=e471] + - cell "Organic Hoodie 4 variants" [ref=e473]: + - link "Organic Hoodie" [ref=e474] [cursor=pointer]: + - /url: /admin/products/3/edit + - generic [ref=e475]: 4 variants + - cell "Active" [ref=e476] + - cell "80" [ref=e478] + - cell "Hoodies" [ref=e479] + - cell "Acme Basics" [ref=e480] + - cell "8 minutes ago" [ref=e481] + - row [ref=e482]: + - cell [ref=e483]: + - checkbox "Select Leather Belt" [ref=e484] + - cell "Leather Belt 4 variants" [ref=e486]: + - link "Leather Belt" [ref=e487] [cursor=pointer]: + - /url: /admin/products/4/edit + - generic [ref=e488]: 4 variants + - cell "Active" [ref=e489] + - cell "100" [ref=e491] + - cell "Accessories" [ref=e492] + - cell "Acme Accessories" [ref=e493] + - cell "8 minutes ago" [ref=e494] + - row [ref=e495]: + - cell [ref=e496]: + - checkbox "Select Running Sneakers" [ref=e497] + - cell "Running Sneakers 14 variants" [ref=e499]: + - link "Running Sneakers" [ref=e500] [cursor=pointer]: + - /url: /admin/products/5/edit + - generic [ref=e501]: 14 variants + - cell "Active" [ref=e502] + - cell "70" [ref=e504] + - cell "Shoes" [ref=e505] + - cell "Acme Sport" [ref=e506] + - cell "8 minutes ago" [ref=e507] + - row [ref=e508]: + - cell [ref=e509]: + - checkbox "Select Graphic Print Tee" [ref=e510] + - cell "Graphic Print Tee 4 variants" [ref=e512]: + - link "Graphic Print Tee" [ref=e513] [cursor=pointer]: + - /url: /admin/products/6/edit + - generic [ref=e514]: 4 variants + - cell "Active" [ref=e515] + - cell "72" [ref=e517] + - cell "T-Shirts" [ref=e518] + - cell "Acme Basics" [ref=e519] + - cell "8 minutes ago" [ref=e520] + - row [ref=e521]: + - cell [ref=e522]: + - checkbox "Select V-Neck Linen Tee" [ref=e523] + - cell "V-Neck Linen Tee 9 variants" [ref=e525]: + - link "V-Neck Linen Tee" [ref=e526] [cursor=pointer]: + - /url: /admin/products/7/edit + - generic [ref=e527]: 9 variants + - cell "Active" [ref=e528] + - cell "108" [ref=e530] + - cell "T-Shirts" [ref=e531] + - cell "Acme Basics" [ref=e532] + - cell "8 minutes ago" [ref=e533] + - row [ref=e534]: + - cell [ref=e535]: + - checkbox "Select Striped Polo Shirt" [ref=e536] + - cell "Striped Polo Shirt 4 variants" [ref=e538]: + - link "Striped Polo Shirt" [ref=e539] [cursor=pointer]: + - /url: /admin/products/8/edit + - generic [ref=e540]: 4 variants + - cell "Active" [ref=e541] + - cell "40" [ref=e543] + - cell "T-Shirts" [ref=e544] + - cell "Acme Basics" [ref=e545] + - cell "8 minutes ago" [ref=e546] + - row [ref=e547]: + - cell [ref=e548]: + - checkbox "Select Cargo Pants" [ref=e549] + - cell "Cargo Pants 12 variants" [ref=e551]: + - link "Cargo Pants" [ref=e552] [cursor=pointer]: + - /url: /admin/products/9/edit + - generic [ref=e553]: 12 variants + - cell "Active" [ref=e554] + - cell "168" [ref=e556] + - cell "Pants" [ref=e557] + - cell "Acme Workwear" [ref=e558] + - cell "8 minutes ago" [ref=e559] + - row [ref=e560]: + - cell [ref=e561]: + - checkbox "Select Chino Shorts" [ref=e562] + - cell "Chino Shorts 8 variants" [ref=e564]: + - link "Chino Shorts" [ref=e565] [cursor=pointer]: + - /url: /admin/products/10/edit + - generic [ref=e566]: 8 variants + - cell "Active" [ref=e567] + - cell "128" [ref=e569] + - cell "Pants" [ref=e570] + - cell "Acme Basics" [ref=e571] + - cell "8 minutes ago" [ref=e572] + - row [ref=e573]: + - cell [ref=e574]: + - checkbox "Select Wide Leg Trousers" [ref=e575] + - cell "Wide Leg Trousers 3 variants" [ref=e577]: + - link "Wide Leg Trousers" [ref=e578] [cursor=pointer]: + - /url: /admin/products/11/edit + - generic [ref=e579]: 3 variants + - cell "Active" [ref=e580] + - cell "21" [ref=e582] + - cell "Pants" [ref=e583] + - cell "Acme Denim" [ref=e584] + - cell "8 minutes ago" [ref=e585] + - row [ref=e586]: + - cell [ref=e587]: + - checkbox "Select Wool Scarf" [ref=e588] + - cell "Wool Scarf 3 variants" [ref=e590]: + - link "Wool Scarf" [ref=e591] [cursor=pointer]: + - /url: /admin/products/12/edit + - generic [ref=e592]: 3 variants + - cell "Active" [ref=e593] + - cell "90" [ref=e595] + - cell "Accessories" [ref=e596] + - cell "Acme Accessories" [ref=e597] + - cell "8 minutes ago" [ref=e598] + - row [ref=e599]: + - cell [ref=e600]: + - checkbox "Select Canvas Tote Bag" [ref=e601] + - cell "Canvas Tote Bag 2 variants" [ref=e603]: + - link "Canvas Tote Bag" [ref=e604] [cursor=pointer]: + - /url: /admin/products/13/edit + - generic [ref=e605]: 2 variants + - cell "Active" [ref=e606] + - cell "80" [ref=e608] + - cell "Accessories" [ref=e609] + - cell "Acme Accessories" [ref=e610] + - cell "8 minutes ago" [ref=e611] + - navigation "Pagination Navigation" [ref=e614]: + - generic [ref=e615]: + - paragraph [ref=e617]: Showing 1 to 15 of 20 results + - generic [ref=e619]: + - generic "« Previous" [ref=e621] + - generic [ref=e625]: "1" + - button "Go to page 2" [ref=e629]: "2" + - button "Next »" [ref=e631] + - status \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-09-06-483Z.yml b/.playwright-mcp/page-2026-07-11T11-09-06-483Z.yml new file mode 100644 index 00000000..68a52ce2 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-09-06-483Z.yml @@ -0,0 +1,113 @@ +- generic [ref=e264]: + - complementary [ref=e265]: + - navigation "Admin navigation" [ref=e266]: + - generic [ref=e271]: + - generic [ref=e272]: Shop + - generic [ref=e273]: Commerce platform + - generic [ref=e274]: + - link "Dashboard" [ref=e276] [cursor=pointer]: + - /url: /admin + - generic [ref=e280]: Products + - generic [ref=e281]: + - link "Products" [ref=e282] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=e286] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=e290] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=e294]: Orders + - link "Orders" [ref=e296] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=e300]: Customers + - link "Customers" [ref=e302] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=e306]: Discounts + - link "Discounts" [ref=e308] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=e313]: Content + - generic [ref=e314]: + - link "Pages" [ref=e315] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=e319] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=e323] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=e327]: Insights + - generic [ref=e328]: + - link "Analytics" [ref=e329] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=e334] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=e338]: Platform + - generic [ref=e339]: + - link "Settings" [ref=e340] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=e345] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=e349] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=e353]: + - banner [ref=e354]: + - generic [ref=e355]: + - button "Acme Fashion" [ref=e358] + - generic [ref=e362]: + - button "Notifications" [ref=e364] + - button "AU Admin User" [ref=e368]: + - generic [ref=e369]: AU + - generic [ref=e372]: Admin User + - main [ref=e376]: + - generic [ref=e377]: + - generic [ref=e378]: + - link "Home" [ref=e380] [cursor=pointer]: + - /url: /admin + - generic [ref=e383]: Products + - generic [ref=e385]: + - generic [ref=e386]: + - heading "Products" [level=1] [ref=e387] + - paragraph [ref=e388]: Manage catalog products, variants, and publishing. + - link "Add product" [ref=e390] [cursor=pointer]: + - /url: /admin/products/create + - generic [ref=e395]: + - textbox "Search products" [active] [ref=e397]: + - /placeholder: Search products… + - text: Classic + - combobox "Filter status" [ref=e399]: + - option "All statuses" [selected] + - option "Active" + - option "Draft" + - option "Archived" + - combobox "Filter product type" [ref=e400]: + - option "All types" [selected] + - option "Accessories" + - option "Gift Cards" + - option "Hoodies" + - option "Jackets" + - option "Pants" + - option "Shoes" + - option "T-Shirts" + - table [ref=e403]: + - rowgroup [ref=e404]: + - row [ref=e405]: + - columnheader "Select" [ref=e406] + - columnheader [ref=e408]: + - button "Product" [ref=e409] + - columnheader "Status" [ref=e410] + - columnheader "Inventory" [ref=e411] + - columnheader "Type" [ref=e412] + - columnheader "Vendor" [ref=e413] + - columnheader [ref=e414]: + - button "Updated" [ref=e415] + - rowgroup [ref=e416]: + - row [ref=e634]: + - cell [ref=e444]: + - checkbox "Select Classic Cotton T-Shirt" [ref=e445] + - cell "Classic Cotton T-Shirt 12 variants" [ref=e447]: + - link "Classic Cotton T-Shirt" [ref=e448] [cursor=pointer]: + - /url: /admin/products/1/edit + - generic [ref=e449]: 12 variants + - cell "Active" [ref=e450] + - cell "178" [ref=e452] + - cell "T-Shirts" [ref=e453] + - cell "Acme Basics" [ref=e454] + - cell "9 minutes ago" [ref=e635] + - status \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-09-15-668Z.yml b/.playwright-mcp/page-2026-07-11T11-09-15-668Z.yml new file mode 100644 index 00000000..1564bbce --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-09-15-668Z.yml @@ -0,0 +1,274 @@ +- generic [ref=e637]: + - complementary [ref=e638]: + - navigation "Admin navigation" [ref=e639]: + - generic [ref=e644]: + - generic [ref=e645]: Shop + - generic [ref=e646]: Commerce platform + - generic [ref=e647]: + - link "Dashboard" [ref=e649] [cursor=pointer]: + - /url: /admin + - generic [ref=e653]: Products + - generic [ref=e654]: + - link "Products" [ref=e655] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=e659] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=e663] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=e667]: Orders + - link "Orders" [ref=e669] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=e673]: Customers + - link "Customers" [ref=e675] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=e679]: Discounts + - link "Discounts" [ref=e681] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=e686]: Content + - generic [ref=e687]: + - link "Pages" [ref=e688] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=e692] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=e696] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=e700]: Insights + - generic [ref=e701]: + - link "Analytics" [ref=e702] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=e707] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=e711]: Platform + - generic [ref=e712]: + - link "Settings" [ref=e713] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=e718] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=e722] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=e726]: + - banner [ref=e727]: + - generic [ref=e728]: + - button "Acme Fashion" [ref=e731] + - generic [ref=e735]: + - button "Notifications" [ref=e737] + - button "AU Admin User" [ref=e741]: + - generic [ref=e742]: AU + - generic [ref=e745]: Admin User + - main [ref=e749]: + - generic [ref=e750]: + - generic [ref=e751]: + - link "Home" [ref=e753] [cursor=pointer]: + - /url: /admin + - generic [ref=e756]: Orders + - generic [ref=e759]: + - heading "Orders" [level=1] [ref=e760] + - paragraph [ref=e761]: Review payments, fulfillment, and customer orders. + - generic [ref=e763]: + - textbox "Search orders" [ref=e765]: + - /placeholder: Search order or email… + - combobox "Filter orders" [ref=e767]: + - option "All orders" [selected] + - option "Paid" + - option "Pending" + - option "Unfulfilled" + - option "Fulfilled" + - option "Refunded" + - table [ref=e770]: + - rowgroup [ref=e771]: + - row [ref=e772]: + - columnheader "Order" [ref=e773] + - columnheader "Date" [ref=e774] + - columnheader "Customer" [ref=e775] + - columnheader "Total" [ref=e776] + - columnheader "Payment" [ref=e777] + - columnheader "Fulfillment" [ref=e778] + - rowgroup [ref=e779]: + - row [ref=e780]: + - cell [ref=e781]: + - link "#1017" [ref=e782] [cursor=pointer]: + - /url: /admin/orders/20 + - cell "Jul 11, 2026 11:08" [ref=e783] + - cell "Guest decline-review@example.test" [ref=e784]: + - generic [ref=e785]: Guest + - generic [ref=e786]: decline-review@example.test + - cell "€29.98" [ref=e787] + - cell "Pending" [ref=e788] + - cell "Unfulfilled" [ref=e790] + - row [ref=e792]: + - cell [ref=e793]: + - link "#1016" [ref=e794] [cursor=pointer]: + - /url: /admin/orders/19 + - cell "Jul 11, 2026 11:06" [ref=e795] + - cell "Guest browser-review@example.test" [ref=e796]: + - generic [ref=e797]: Guest + - generic [ref=e798]: browser-review@example.test + - cell "€54.97" [ref=e799] + - cell "Paid" [ref=e800] + - cell "Unfulfilled" [ref=e802] + - row [ref=e804]: + - cell [ref=e805]: + - link "#1015" [ref=e806] [cursor=pointer]: + - /url: /admin/orders/15 + - cell "Jul 11, 2026 11:00" [ref=e807] + - cell "John Doe customer@acme.test" [ref=e808]: + - generic [ref=e809]: John Doe + - generic [ref=e810]: customer@acme.test + - cell "€54.47" [ref=e811] + - cell "Paid" [ref=e812] + - cell "Unfulfilled" [ref=e814] + - row [ref=e816]: + - cell [ref=e817]: + - link "#1005" [ref=e818] [cursor=pointer]: + - /url: /admin/orders/5 + - cell "Jul 11, 2026 09:00" [ref=e819] + - cell "Jane Smith jane@example.com" [ref=e820]: + - generic [ref=e821]: Jane Smith + - generic [ref=e822]: jane@example.com + - cell "€39.98" [ref=e823] + - cell "Pending" [ref=e824] + - cell "Unfulfilled" [ref=e826] + - row [ref=e828]: + - cell [ref=e829]: + - link "#1013" [ref=e830] [cursor=pointer]: + - /url: /admin/orders/13 + - cell "Jul 10, 2026 11:00" [ref=e831] + - cell "Robert Martinez robert@example.com" [ref=e832]: + - generic [ref=e833]: Robert Martinez + - generic [ref=e834]: robert@example.com + - cell "€84.97" [ref=e835] + - cell "Paid" [ref=e836] + - cell "Unfulfilled" [ref=e838] + - row [ref=e840]: + - cell [ref=e841]: + - link "#1010" [ref=e842] [cursor=pointer]: + - /url: /admin/orders/10 + - cell "Jul 10, 2026 11:00" [ref=e843] + - cell "John Doe customer@acme.test" [ref=e844]: + - generic [ref=e845]: John Doe + - generic [ref=e846]: customer@acme.test + - cell "€504.98" [ref=e847] + - cell "Paid" [ref=e848] + - cell "Unfulfilled" [ref=e850] + - row [ref=e852]: + - cell [ref=e853]: + - link "#1006" [ref=e854] [cursor=pointer]: + - /url: /admin/orders/6 + - cell "Jul 10, 2026 11:00" [ref=e855] + - cell "Michael Brown michael@example.com" [ref=e856]: + - generic [ref=e857]: Michael Brown + - generic [ref=e858]: michael@example.com + - cell "€124.98" [ref=e859] + - cell "Paid" [ref=e860] + - cell "Unfulfilled" [ref=e862] + - row [ref=e864]: + - cell [ref=e865]: + - link "#1001" [ref=e866] [cursor=pointer]: + - /url: /admin/orders/1 + - cell "Jul 9, 2026 11:00" [ref=e867] + - cell "John Doe customer@acme.test" [ref=e868]: + - generic [ref=e869]: John Doe + - generic [ref=e870]: customer@acme.test + - cell "€54.97" [ref=e871] + - cell "Paid" [ref=e872] + - cell "Unfulfilled" [ref=e874] + - row [ref=e876]: + - cell [ref=e877]: + - link "#1009" [ref=e878] [cursor=pointer]: + - /url: /admin/orders/9 + - cell "Jul 8, 2026 11:00" [ref=e879] + - cell "Emma Garcia emma@example.com" [ref=e880]: + - generic [ref=e881]: Emma Garcia + - generic [ref=e882]: emma@example.com + - cell "€49.97" [ref=e883] + - cell "Paid" [ref=e884] + - cell "Unfulfilled" [ref=e886] + - row [ref=e888]: + - cell [ref=e889]: + - link "#1012" [ref=e890] [cursor=pointer]: + - /url: /admin/orders/12 + - cell "Jul 7, 2026 11:00" [ref=e891] + - cell "Lisa Anderson lisa@example.com" [ref=e892]: + - generic [ref=e893]: Lisa Anderson + - generic [ref=e894]: lisa@example.com + - cell "€84.97" [ref=e895] + - cell "Paid" [ref=e896] + - cell "Unfulfilled" [ref=e898] + - row [ref=e900]: + - cell [ref=e901]: + - link "#1003" [ref=e902] [cursor=pointer]: + - /url: /admin/orders/3 + - cell "Jul 6, 2026 11:00" [ref=e903] + - cell "Jane Smith jane@example.com" [ref=e904]: + - generic [ref=e905]: Jane Smith + - generic [ref=e906]: jane@example.com + - cell "€119.97" [ref=e907] + - cell "Paid" [ref=e908] + - cell "Partial" [ref=e910] + - row [ref=e912]: + - cell [ref=e913]: + - link "#1002" [ref=e914] [cursor=pointer]: + - /url: /admin/orders/2 + - cell "Jul 1, 2026 11:00" [ref=e915] + - cell "John Doe customer@acme.test" [ref=e916]: + - generic [ref=e917]: John Doe + - generic [ref=e918]: customer@acme.test + - cell "€89.97" [ref=e919] + - cell "Paid" [ref=e920] + - cell "Fulfilled" [ref=e922] + - row [ref=e924]: + - cell [ref=e925]: + - link "#1008" [ref=e926] [cursor=pointer]: + - /url: /admin/orders/8 + - cell "Jun 29, 2026 11:00" [ref=e927] + - cell "David Lee david@example.com" [ref=e928]: + - generic [ref=e929]: David Lee + - generic [ref=e930]: david@example.com + - cell "€89.97" [ref=e931] + - cell "Partially Refunded" [ref=e932] + - cell "Fulfilled" [ref=e934] + - row [ref=e936]: + - cell [ref=e937]: + - link "#1014" [ref=e938] [cursor=pointer]: + - /url: /admin/orders/14 + - cell "Jun 27, 2026 11:00" [ref=e939] + - cell "Anna Thomas anna@example.com" [ref=e940]: + - generic [ref=e941]: Anna Thomas + - generic [ref=e942]: anna@example.com + - cell "€50.00" [ref=e943] + - cell "Paid" [ref=e944] + - cell "Fulfilled" [ref=e946] + - row [ref=e948]: + - cell [ref=e949]: + - link "#1004" [ref=e950] [cursor=pointer]: + - /url: /admin/orders/4 + - cell "Jun 26, 2026 11:00" [ref=e951] + - cell "John Doe customer@acme.test" [ref=e952]: + - generic [ref=e953]: John Doe + - generic [ref=e954]: customer@acme.test + - cell "€29.98" [ref=e955] + - cell "Refunded" [ref=e956] + - cell "Unfulfilled" [ref=e958] + - row [ref=e960]: + - cell [ref=e961]: + - link "#1007" [ref=e962] [cursor=pointer]: + - /url: /admin/orders/7 + - cell "Jun 21, 2026 11:00" [ref=e963] + - cell "Sarah Wilson sarah@example.com" [ref=e964]: + - generic [ref=e965]: Sarah Wilson + - generic [ref=e966]: sarah@example.com + - cell "€104.96" [ref=e967] + - cell "Paid" [ref=e968] + - cell "Fulfilled" [ref=e970] + - row [ref=e972]: + - cell [ref=e973]: + - link "#1011" [ref=e974] [cursor=pointer]: + - /url: /admin/orders/11 + - cell "Jun 16, 2026 11:00" [ref=e975] + - cell "James Taylor james@example.com" [ref=e976]: + - generic [ref=e977]: James Taylor + - generic [ref=e978]: james@example.com + - cell "€32.98" [ref=e979] + - cell "Paid" [ref=e980] + - cell "Fulfilled" [ref=e982] + - status \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-09-23-818Z.yml b/.playwright-mcp/page-2026-07-11T11-09-23-818Z.yml new file mode 100644 index 00000000..2f92832c --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-09-23-818Z.yml @@ -0,0 +1,127 @@ +- generic [ref=e986]: + - complementary [ref=e987]: + - navigation "Admin navigation" [ref=e988]: + - generic [ref=e993]: + - generic [ref=e994]: Shop + - generic [ref=e995]: Commerce platform + - generic [ref=e996]: + - link "Dashboard" [ref=e998] [cursor=pointer]: + - /url: /admin + - generic [ref=e1002]: Products + - generic [ref=e1003]: + - link "Products" [ref=e1004] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=e1008] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=e1012] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=e1016]: Orders + - link "Orders" [ref=e1018] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=e1022]: Customers + - link "Customers" [ref=e1024] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=e1028]: Discounts + - link "Discounts" [ref=e1030] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=e1035]: Content + - generic [ref=e1036]: + - link "Pages" [ref=e1037] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=e1041] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=e1045] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=e1049]: Insights + - generic [ref=e1050]: + - link "Analytics" [ref=e1051] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=e1056] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=e1060]: Platform + - generic [ref=e1061]: + - link "Settings" [ref=e1062] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=e1067] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=e1071] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=e1075]: + - banner [ref=e1076]: + - generic [ref=e1077]: + - button "Acme Fashion" [ref=e1080] + - generic [ref=e1084]: + - button "Notifications" [ref=e1086] + - button "AU Admin User" [ref=e1090]: + - generic [ref=e1091]: AU + - generic [ref=e1094]: Admin User + - main [ref=e1098]: + - generic [ref=e1099]: + - generic [ref=e1100]: + - link "Home" [ref=e1102] [cursor=pointer]: + - /url: /admin + - generic [ref=e1105]: "#1005" + - generic [ref=e1107]: + - generic [ref=e1108]: + - heading "#1005" [level=1] [ref=e1109] + - paragraph [ref=e1110]: Placed 2 hours ago + - generic [ref=e1111]: + - button "Confirm payment" [ref=e1112] + - button "Refund" [ref=e1120] + - generic [ref=e1133]: + - generic [ref=e1134]: Payment required + - text: This order must be paid before fulfillment can be created. + - generic [ref=e1135]: + - generic [ref=e1136]: + - generic [ref=e1137]: + - generic [ref=e1138]: Items + - generic [ref=e1141]: + - generic [ref=e1142]: + - generic [ref=e1143]: Leather Belt + - generic [ref=e1144]: SKU ACME-LEATHERB-SM-BLACK-2 Β· Qty 1 + - generic [ref=e1145]: €34.99 + - generic [ref=e1146]: + - generic [ref=e1147]: + - generic [ref=e1148]: Subtotal + - generic [ref=e1149]: €34.99 + - generic [ref=e1150]: + - generic [ref=e1151]: Discount + - generic [ref=e1152]: €0.00 + - generic [ref=e1153]: + - generic [ref=e1154]: Shipping + - generic [ref=e1155]: €4.99 + - generic [ref=e1156]: + - generic [ref=e1157]: Tax + - generic [ref=e1158]: €5.59 + - generic [ref=e1159]: + - generic [ref=e1160]: Total + - generic [ref=e1161]: €39.98 + - generic [ref=e1162]: + - generic [ref=e1163]: Fulfillment + - paragraph [ref=e1166]: No fulfillments yet. + - generic [ref=e1167]: + - generic [ref=e1168]: Timeline + - list [ref=e1170]: + - listitem [ref=e1171]: + - generic [ref=e1172]: Order created + - generic [ref=e1173]: Jul 11, 2026 09:00 + - generic [ref=e1174]: + - generic [ref=e1175]: + - generic [ref=e1176]: Status + - generic [ref=e1178]: + - generic [ref=e1179]: + - generic [ref=e1180]: Payment + - generic [ref=e1181]: Pending + - generic [ref=e1182]: + - generic [ref=e1183]: Fulfillment + - generic [ref=e1184]: Unfulfilled + - generic [ref=e1185]: + - generic [ref=e1186]: Customer + - generic [ref=e1188]: + - generic [ref=e1189]: Jane Smith + - link "jane@example.com" [ref=e1190] [cursor=pointer]: + - /url: mailto:jane@example.com + - generic [ref=e1191]: + - generic [ref=e1192]: Shipping address + - generic [ref=e1194]: Jane Smith Schillerstrasse 45 Munich Bavaria BY Germany DE 80336 + - status \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-09-37-169Z.yml b/.playwright-mcp/page-2026-07-11T11-09-37-169Z.yml new file mode 100644 index 00000000..aad1099d --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-09-37-169Z.yml @@ -0,0 +1,372 @@ +- generic [active] [ref=e985]: + - dialog [ref=e1195]: + - iframe [ref=e1196]: + - generic [ref=f1e2]: + - generic [ref=f1e4]: + - generic [ref=f1e5]: Internal Server Error + - button "Copy as Markdown" [ref=f1e11] [cursor=pointer] + - generic [ref=f1e18]: + - generic [ref=f1e19]: + - heading "App\\Exceptions\\InvalidInventoryOperationException" [level=1] [ref=f1e20] + - generic [ref=f1e21]: app/Services/InventoryService.php:62 + - paragraph [ref=f1e23]: Cannot commit more inventory than is reserved. + - generic [ref=f1e24]: + - generic [ref=f1e25]: + - generic [ref=f1e26]: + - generic [ref=f1e27]: LARAVEL + - generic [ref=f1e28]: 12.51.0 + - generic [ref=f1e29]: + - generic [ref=f1e30]: PHP + - generic [ref=f1e31]: 8.4.17 + - generic [ref=f1e32]: UNHANDLED + - generic [ref=f1e36]: CODE 0 + - generic [ref=f1e38]: + - generic [ref=f1e39]: "500" + - generic [ref=f1e43]: POST + - generic [ref=f1e47]: http://acme-fashion.test/livewire-6701cc17/update + - button [ref=f1e48] [cursor=pointer] + - generic [ref=f1e53]: + - generic [ref=f1e54]: + - heading "Exception trace" [level=3] [ref=f1e60] + - generic [ref=f1e61]: + - generic [ref=f1e62]: + - generic [ref=f1e63] [cursor=pointer]: + - generic [ref=f1e66]: + - code [ref=f1e70]: + - generic [ref=f1e71]: app/Services/InventoryService.php + - generic [ref=f1e72]: app/Services/InventoryService.php:62 + - button [ref=f1e75] + - code [ref=f1e84]: + - generic [ref=f1e85]: "57" + - generic [ref=f1e86]: "58 DB::transaction(function () use ($item, $quantity): void {" + - generic [ref=f1e87]: 59 $inventory = $this->lock($item); + - generic [ref=f1e88]: "60" + - generic [ref=f1e89]: "61 if ($inventory->quantity_reserved < $quantity) {" + - generic [ref=f1e90]: 62 throw new InvalidInventoryOperationException('Cannot commit more inventory than is reserved.'); + - generic [ref=f1e91]: "63 }" + - generic [ref=f1e92]: "64" + - generic [ref=f1e93]: 65 $inventory->update([ + - generic [ref=f1e94]: 66 'quantity_on_hand' => $inventory->quantity_on_hand - $quantity, + - generic [ref=f1e95]: 67 'quantity_reserved' => $inventory->quantity_reserved - $quantity, + - generic [ref=f1e96]: 68 ]); + - generic [ref=f1e97]: "69" + - generic [ref=f1e98]: 70 $this->syncModel($item, $inventory); + - generic [ref=f1e99]: "71 });" + - generic [ref=f1e100]: "72 }" + - generic [ref=f1e101]: "73" + - generic [ref=f1e102]: "74" + - generic [ref=f1e104] [cursor=pointer]: + - generic [ref=f1e109]: 3 vendor frames + - button [ref=f1e110] + - generic [ref=f1e116] [cursor=pointer]: + - generic [ref=f1e119]: + - code [ref=f1e123]: + - generic [ref=f1e124]: app/Services/InventoryService.php + - generic [ref=f1e125]: app/Services/InventoryService.php:58 + - button [ref=f1e128] + - generic [ref=f1e134] [cursor=pointer]: + - generic [ref=f1e137]: + - code [ref=f1e141]: + - generic [ref=f1e142]: app/Services/PaymentService.php + - generic [ref=f1e143]: app/Services/PaymentService.php:46 + - button [ref=f1e146] + - generic [ref=f1e152] [cursor=pointer]: + - generic [ref=f1e157]: 3 vendor frames + - button [ref=f1e158] + - generic [ref=f1e164] [cursor=pointer]: + - generic [ref=f1e167]: + - code [ref=f1e171]: + - generic [ref=f1e172]: app/Services/PaymentService.php + - generic [ref=f1e173]: app/Services/PaymentService.php:44 + - button [ref=f1e176] + - generic [ref=f1e182] [cursor=pointer]: + - generic [ref=f1e185]: + - code [ref=f1e189]: + - generic [ref=f1e190]: app/Livewire/Admin/Orders/Show.php + - generic [ref=f1e191]: app/Livewire/Admin/Orders/Show.php:48 + - button [ref=f1e194] + - generic [ref=f1e200] [cursor=pointer]: + - generic [ref=f1e205]: 58 vendor frames + - button [ref=f1e206] + - generic [ref=f1e212] [cursor=pointer]: + - generic [ref=f1e215]: + - code [ref=f1e219]: + - generic [ref=f1e220]: public/index.php + - generic [ref=f1e221]: public/index.php:20 + - button [ref=f1e224] + - generic [ref=f1e230] [cursor=pointer]: + - generic [ref=f1e235]: 1 vendor frame + - button [ref=f1e236] + - generic [ref=f1e241]: + - generic [ref=f1e242]: + - heading "Queries" [level=3] [ref=f1e247] + - generic [ref=f1e248]: 1-10 of 16 + - generic [ref=f1e250]: + - generic [ref=f1e251]: + - generic [ref=f1e252]: + - generic [ref=f1e253]: sqlite + - code [ref=f1e260]: + - generic [ref=f1e261]: select * from "users" where "id" = 1 limit 1 + - generic [ref=f1e262]: 4.7ms + - generic [ref=f1e263]: + - generic [ref=f1e264]: + - generic [ref=f1e265]: sqlite + - code [ref=f1e272]: + - generic [ref=f1e273]: select * from "orders" where "id" = '5' limit 1 + - generic [ref=f1e274]: 0.17ms + - generic [ref=f1e275]: + - generic [ref=f1e276]: + - generic [ref=f1e277]: sqlite + - code [ref=f1e284]: + - generic [ref=f1e285]: select * from "stores" where "stores"."id" = 1 limit 1 + - generic [ref=f1e286]: 0.06ms + - generic [ref=f1e287]: + - generic [ref=f1e288]: + - generic [ref=f1e289]: sqlite + - code [ref=f1e296]: + - generic [ref=f1e297]: select exists(select * from "store_users" where "store_users"."user_id" = 1 and "store_users"."user_id" is not null and "store_id" = 1) as "exists" + - generic [ref=f1e298]: 0.2ms + - generic [ref=f1e299]: + - generic [ref=f1e300]: + - generic [ref=f1e301]: sqlite + - code [ref=f1e308]: + - generic [ref=f1e309]: select * from "orders" where "store_id" = 1 and "orders"."id" = 5 and "orders"."store_id" = 1 limit 1 + - generic [ref=f1e310]: 0.06ms + - generic [ref=f1e311]: + - generic [ref=f1e312]: + - generic [ref=f1e313]: sqlite + - code [ref=f1e320]: + - generic [ref=f1e321]: select * from "customers" where "customers"."id" in (2) and "customers"."store_id" = 1 + - generic [ref=f1e322]: 0.14ms + - generic [ref=f1e323]: + - generic [ref=f1e324]: + - generic [ref=f1e325]: sqlite + - code [ref=f1e332]: + - generic [ref=f1e333]: select * from "order_lines" where "order_lines"."order_id" in (5) + - generic [ref=f1e334]: 0.1ms + - generic [ref=f1e335]: + - generic [ref=f1e336]: + - generic [ref=f1e337]: sqlite + - code [ref=f1e344]: + - generic [ref=f1e345]: select * from "payments" where "payments"."order_id" in (5) + - generic [ref=f1e346]: 0.14ms + - generic [ref=f1e347]: + - generic [ref=f1e348]: + - generic [ref=f1e349]: sqlite + - code [ref=f1e356]: + - generic [ref=f1e357]: select * from "refunds" where "refunds"."payment_id" in (5) + - generic [ref=f1e358]: 0.12ms + - generic [ref=f1e359]: + - generic [ref=f1e360]: + - generic [ref=f1e361]: sqlite + - code [ref=f1e368]: + - generic [ref=f1e369]: select * from "refunds" where "refunds"."order_id" in (5) + - generic [ref=f1e370]: 0.02ms + - generic [ref=f1e371]: + - button [disabled] [ref=f1e372] + - button [disabled] [ref=f1e376] + - button "1" [ref=f1e380] [cursor=pointer] + - button "2" [ref=f1e382] [cursor=pointer] + - button [ref=f1e383] [cursor=pointer] + - button [ref=f1e386] [cursor=pointer] + - generic [ref=f1e391]: + - generic [ref=f1e392]: + - heading "Headers" [level=2] [ref=f1e393] + - generic [ref=f1e394]: + - generic [ref=f1e395]: + - generic [ref=f1e396]: cookie + - generic [ref=f1e398]: XSRF-TOKEN=eyJpdiI6IitGSm1id1JIazBDSFlqQTJVTEowZkE9PSIsInZhbHVlIjoiOGxBWC9kcTBEQ2c2RVVuRDgwbTBDWDVHenBBLzljbmhaVkpuOGFwRTJuRUZ2VXBjNXowSDNxRlBPL2p2OEU4MHIzc285dmgzdHkwOUlXVE05Q1I1VWVBa3NPYllrOHNRdkhiNUJyYjhrM1hSQ1B0emxzRStmcUNVdDZCSXlkaEoiLCJtYWMiOiI2MGZhYTY5YmUyN2ExMzYyNzUyNTc4MTQ4ZmM5M2RmMmU4ZjNlOWNmMDY3YjNhMjI1YzNlMDExN2RiMTIwODg1IiwidGFnIjoiIn0%3D; shop_session=eyJpdiI6Im5Db3lQVnpiOGtsWHNTSVp5Si9rK1E9PSIsInZhbHVlIjoiWFNvYlF3SVZ5YWZBUDhEeENFczg4MnREdUI0L2F1YU50akpOK29MdXVPWmlpb1JqdThnZCs1cDhUMzh4cDhTME5Ba2NKU3pGc1ltZ2dCSDFyQWFBbnk5SG80cmJLcGF6eFdydnBhSi9NQlRjWm5VbGxHNHlaN3k2ZnlsakFnWm4iLCJtYWMiOiJhOWRkNjBlZmExODYwMWJhNDk0YzA4OGU0YTNiMWM2YzM5MDU3NjE5YmQxODhlZWJlNzM2ZmFkOWE0NTFkOWFmIiwidGFnIjoiIn0%3D + - generic [ref=f1e399]: + - generic [ref=f1e400]: accept-language + - generic [ref=f1e402]: en-GB,en-US;q=0.9,en;q=0.8 + - generic [ref=f1e403]: + - generic [ref=f1e404]: accept-encoding + - generic [ref=f1e406]: gzip, deflate + - generic [ref=f1e407]: + - generic [ref=f1e408]: referer + - generic [ref=f1e410]: http://acme-fashion.test/admin/orders/5 + - generic [ref=f1e411]: + - generic [ref=f1e412]: origin + - generic [ref=f1e414]: http://acme-fashion.test + - generic [ref=f1e415]: + - generic [ref=f1e416]: accept + - generic [ref=f1e418]: "*/*" + - generic [ref=f1e419]: + - generic [ref=f1e420]: x-livewire + - generic [ref=f1e422]: "1" + - generic [ref=f1e423]: + - generic [ref=f1e424]: content-type + - generic [ref=f1e426]: application/json + - generic [ref=f1e427]: + - generic [ref=f1e428]: user-agent + - generic [ref=f1e430]: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 + - generic [ref=f1e431]: + - generic [ref=f1e432]: content-length + - generic [ref=f1e434]: "730" + - generic [ref=f1e435]: + - generic [ref=f1e436]: connection + - generic [ref=f1e438]: keep-alive + - generic [ref=f1e439]: + - generic [ref=f1e440]: host + - generic [ref=f1e442]: acme-fashion.test + - generic [ref=f1e443]: + - heading "Body" [level=2] [ref=f1e444] + - code [ref=f1e449]: + - generic [ref=f1e450]: "{" + - generic [ref=f1e451]: "\"_token\": \"v1cigc0AO9llddR4D8S9axZfXeOmbJvjtR964N87\"," + - generic [ref=f1e452]: "\"components\": [" + - generic [ref=f1e453]: "{" + - generic [ref=f1e454]: "\"snapshot\": \"{\"data\":{\"orderId\":5,\"showFulfillmentModal\":false,\"showRefundModal\":false,\"trackingCompany\":\"\",\"trackingNumber\":\"\",\"fulfillmentQuantities\":[{\"7\":1},{\"s\":\"arr\"}],\"refundAmount\":\"\",\"refundReason\":\"\",\"restock\":false},\"memo\":{\"id\":\"KP28VETFzacd91TwOUSd\",\"name\":\"admin.orders.show\",\"path\":\"admin/orders/5\",\"method\":\"GET\",\"release\":\"a-a-a\",\"children\":[],\"scripts\":[],\"assets\":[],\"errors\":[],\"locale\":\"en\",\"islands\":[]},\"checksum\":\"9987ae09bf7e180c3f2ad8b7fce034404e98d398409e5e9abd184fdfc0961398\"}\"," + - generic [ref=f1e455]: "\"updates\": []," + - generic [ref=f1e456]: "\"calls\": [" + - generic [ref=f1e457]: "{" + - generic [ref=f1e458]: "\"method\": \"confirmPayment\"," + - generic [ref=f1e459]: "\"params\": []," + - generic [ref=f1e460]: "\"metadata\": []" + - generic [ref=f1e461]: "}" + - generic [ref=f1e462]: "]" + - generic [ref=f1e463]: "}" + - generic [ref=f1e464]: "]" + - generic [ref=f1e465]: "}" + - generic [ref=f1e466]: + - heading "Routing" [level=2] [ref=f1e467] + - generic [ref=f1e468]: + - generic [ref=f1e469]: + - generic [ref=f1e470]: controller + - generic [ref=f1e472]: Livewire\Mechanisms\HandleRequests\HandleRequests@handleUpdate + - generic [ref=f1e473]: + - generic [ref=f1e474]: route name + - generic [ref=f1e476]: default-livewire.update + - generic [ref=f1e477]: + - generic [ref=f1e478]: middleware + - generic [ref=f1e480]: web + - generic [ref=f1e481]: + - heading "Routing parameters" [level=2] [ref=f1e482] + - generic [ref=f1e483]: // No routing parameters + - generic [ref=e986]: + - complementary [ref=e987]: + - navigation "Admin navigation" [ref=e988]: + - generic [ref=e993]: + - generic [ref=e994]: Shop + - generic [ref=e995]: Commerce platform + - generic [ref=e996]: + - link "Dashboard" [ref=e998] [cursor=pointer]: + - /url: /admin + - generic [ref=e1002]: Products + - generic [ref=e1003]: + - link "Products" [ref=e1004] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=e1008] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=e1012] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=e1016]: Orders + - link "Orders" [ref=e1018] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=e1022]: Customers + - link "Customers" [ref=e1024] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=e1028]: Discounts + - link "Discounts" [ref=e1030] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=e1035]: Content + - generic [ref=e1036]: + - link "Pages" [ref=e1037] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=e1041] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=e1045] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=e1049]: Insights + - generic [ref=e1050]: + - link "Analytics" [ref=e1051] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=e1056] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=e1060]: Platform + - generic [ref=e1061]: + - link "Settings" [ref=e1062] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=e1067] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=e1071] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=e1075]: + - banner [ref=e1076]: + - generic [ref=e1077]: + - button "Acme Fashion" [ref=e1080] + - generic [ref=e1084]: + - button "Notifications" [ref=e1086] + - button "AU Admin User" [ref=e1090]: + - generic [ref=e1091]: AU + - generic [ref=e1094]: Admin User + - main [ref=e1098]: + - generic [ref=e1099]: + - generic [ref=e1100]: + - link "Home" [ref=e1102] [cursor=pointer]: + - /url: /admin + - generic [ref=e1105]: "#1005" + - generic [ref=e1107]: + - generic [ref=e1108]: + - heading "#1005" [level=1] [ref=e1109] + - paragraph [ref=e1110]: Placed 2 hours ago + - generic [ref=e1111]: + - button "Confirm payment" [ref=e1112] + - button "Refund" [ref=e1120] + - generic [ref=e1133]: + - generic [ref=e1134]: Payment required + - text: This order must be paid before fulfillment can be created. + - generic [ref=e1135]: + - generic [ref=e1136]: + - generic [ref=e1137]: + - generic [ref=e1138]: Items + - generic [ref=e1141]: + - generic [ref=e1142]: + - generic [ref=e1143]: Leather Belt + - generic [ref=e1144]: SKU ACME-LEATHERB-SM-BLACK-2 Β· Qty 1 + - generic [ref=e1145]: €34.99 + - generic [ref=e1146]: + - generic [ref=e1147]: + - generic [ref=e1148]: Subtotal + - generic [ref=e1149]: €34.99 + - generic [ref=e1150]: + - generic [ref=e1151]: Discount + - generic [ref=e1152]: €0.00 + - generic [ref=e1153]: + - generic [ref=e1154]: Shipping + - generic [ref=e1155]: €4.99 + - generic [ref=e1156]: + - generic [ref=e1157]: Tax + - generic [ref=e1158]: €5.59 + - generic [ref=e1159]: + - generic [ref=e1160]: Total + - generic [ref=e1161]: €39.98 + - generic [ref=e1162]: + - generic [ref=e1163]: Fulfillment + - paragraph [ref=e1166]: No fulfillments yet. + - generic [ref=e1167]: + - generic [ref=e1168]: Timeline + - list [ref=e1170]: + - listitem [ref=e1171]: + - generic [ref=e1172]: Order created + - generic [ref=e1173]: Jul 11, 2026 09:00 + - generic [ref=e1174]: + - generic [ref=e1175]: + - generic [ref=e1176]: Status + - generic [ref=e1178]: + - generic [ref=e1179]: + - generic [ref=e1180]: Payment + - generic [ref=e1181]: Pending + - generic [ref=e1182]: + - generic [ref=e1183]: Fulfillment + - generic [ref=e1184]: Unfulfilled + - generic [ref=e1185]: + - generic [ref=e1186]: Customer + - generic [ref=e1188]: + - generic [ref=e1189]: Jane Smith + - link "jane@example.com" [ref=e1190] [cursor=pointer]: + - /url: mailto:jane@example.com + - generic [ref=e1191]: + - generic [ref=e1192]: Shipping address + - generic [ref=e1194]: Jane Smith Schillerstrasse 45 Munich Bavaria BY Germany DE 80336 + - status \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-10-28-347Z.yml b/.playwright-mcp/page-2026-07-11T11-10-28-347Z.yml new file mode 100644 index 00000000..2e89eea3 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-10-28-347Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e2]: + - link "Laravel Starter Kit" [ref=e4] [cursor=pointer]: + - /url: http://acme-fashion.test/dashboard + - navigation [ref=e9]: + - generic [ref=e10]: + - generic [ref=e11]: Platform + - link "Dashboard" [ref=e15] [cursor=pointer]: + - /url: http://acme-fashion.test/dashboard + - navigation [ref=e21]: + - link "Repository" [ref=e23] [cursor=pointer]: + - /url: https://github.com/laravel/livewire-starter-kit + - link "Documentation" [ref=e32] [cursor=pointer]: + - /url: https://laravel.com/docs/starter-kits#livewire + - button "AU Admin User" [ref=e38]: + - generic [ref=e39]: AU + - generic [ref=e42]: Admin User \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-10-28-467Z.yml b/.playwright-mcp/page-2026-07-11T11-10-28-467Z.yml new file mode 100644 index 00000000..a5f8f099 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-10-28-467Z.yml @@ -0,0 +1,127 @@ +- generic [ref=f1e2]: + - complementary [ref=f1e3]: + - navigation "Admin navigation" [ref=f1e4]: + - generic [ref=f1e9]: + - generic [ref=f1e10]: Shop + - generic [ref=f1e11]: Commerce platform + - generic [ref=f1e12]: + - link "Dashboard" [ref=f1e14] [cursor=pointer]: + - /url: /admin + - generic [ref=f1e18]: Products + - generic [ref=f1e19]: + - link "Products" [ref=f1e20] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=f1e24] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=f1e28] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=f1e32]: Orders + - link "Orders" [ref=f1e34] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=f1e38]: Customers + - link "Customers" [ref=f1e40] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=f1e44]: Discounts + - link "Discounts" [ref=f1e46] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=f1e51]: Content + - generic [ref=f1e52]: + - link "Pages" [ref=f1e53] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=f1e57] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=f1e61] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=f1e65]: Insights + - generic [ref=f1e66]: + - link "Analytics" [ref=f1e67] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=f1e72] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=f1e76]: Platform + - generic [ref=f1e77]: + - link "Settings" [ref=f1e78] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=f1e83] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=f1e87] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=f1e91]: + - banner [ref=f1e92]: + - generic [ref=f1e93]: + - button "Acme Fashion" [ref=f1e96] + - generic [ref=f1e100]: + - button "Notifications" [ref=f1e102] + - button "AU Admin User" [ref=f1e106]: + - generic [ref=f1e107]: AU + - generic [ref=f1e110]: Admin User + - main [ref=f1e114]: + - generic [ref=f1e115]: + - generic [ref=f1e116]: + - link "Home" [ref=f1e118] [cursor=pointer]: + - /url: /admin + - generic [ref=f1e121]: "#1005" + - generic [ref=f1e123]: + - generic [ref=f1e124]: + - heading "#1005" [level=1] [ref=f1e125] + - paragraph [ref=f1e126]: Placed 2 hours ago + - generic [ref=f1e127]: + - button "Confirm payment" [ref=f1e128] + - button "Refund" [ref=f1e136] + - generic [ref=f1e149]: + - generic [ref=f1e150]: Payment required + - text: This order must be paid before fulfillment can be created. + - generic [ref=f1e151]: + - generic [ref=f1e152]: + - generic [ref=f1e153]: + - generic [ref=f1e154]: Items + - generic [ref=f1e157]: + - generic [ref=f1e158]: + - generic [ref=f1e159]: Leather Belt + - generic [ref=f1e160]: SKU ACME-LEATHERB-SM-BLACK-2 Β· Qty 1 + - generic [ref=f1e161]: €34.99 + - generic [ref=f1e162]: + - generic [ref=f1e163]: + - generic [ref=f1e164]: Subtotal + - generic [ref=f1e165]: €34.99 + - generic [ref=f1e166]: + - generic [ref=f1e167]: Discount + - generic [ref=f1e168]: €0.00 + - generic [ref=f1e169]: + - generic [ref=f1e170]: Shipping + - generic [ref=f1e171]: €4.99 + - generic [ref=f1e172]: + - generic [ref=f1e173]: Tax + - generic [ref=f1e174]: €5.59 + - generic [ref=f1e175]: + - generic [ref=f1e176]: Total + - generic [ref=f1e177]: €39.98 + - generic [ref=f1e178]: + - generic [ref=f1e179]: Fulfillment + - paragraph [ref=f1e182]: No fulfillments yet. + - generic [ref=f1e183]: + - generic [ref=f1e184]: Timeline + - list [ref=f1e186]: + - listitem [ref=f1e187]: + - generic [ref=f1e188]: Order created + - generic [ref=f1e189]: Jul 11, 2026 09:10 + - generic [ref=f1e190]: + - generic [ref=f1e191]: + - generic [ref=f1e192]: Status + - generic [ref=f1e194]: + - generic [ref=f1e195]: + - generic [ref=f1e196]: Payment + - generic [ref=f1e197]: Pending + - generic [ref=f1e198]: + - generic [ref=f1e199]: Fulfillment + - generic [ref=f1e200]: Unfulfilled + - generic [ref=f1e201]: + - generic [ref=f1e202]: Customer + - generic [ref=f1e204]: + - generic [ref=f1e205]: Jane Smith + - link "jane@example.com" [ref=f1e206] [cursor=pointer]: + - /url: mailto:jane@example.com + - generic [ref=f1e207]: + - generic [ref=f1e208]: Shipping address + - generic [ref=f1e210]: Jane Smith Schillerstrasse 45 Munich Bavaria BY Germany DE 80336 + - status \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-10-29-642Z.yml b/.playwright-mcp/page-2026-07-11T11-10-29-642Z.yml new file mode 100644 index 00000000..e6b38c2b --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-10-29-642Z.yml @@ -0,0 +1,125 @@ +- generic [ref=f1e2]: + - complementary [ref=f1e3]: + - navigation "Admin navigation" [ref=f1e4]: + - generic [ref=f1e9]: + - generic [ref=f1e10]: Shop + - generic [ref=f1e11]: Commerce platform + - generic [ref=f1e12]: + - link "Dashboard" [ref=f1e14] [cursor=pointer]: + - /url: /admin + - generic [ref=f1e18]: Products + - generic [ref=f1e19]: + - link "Products" [ref=f1e20] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=f1e24] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=f1e28] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=f1e32]: Orders + - link "Orders" [ref=f1e34] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=f1e38]: Customers + - link "Customers" [ref=f1e40] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=f1e44]: Discounts + - link "Discounts" [ref=f1e46] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=f1e51]: Content + - generic [ref=f1e52]: + - link "Pages" [ref=f1e53] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=f1e57] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=f1e61] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=f1e65]: Insights + - generic [ref=f1e66]: + - link "Analytics" [ref=f1e67] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=f1e72] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=f1e76]: Platform + - generic [ref=f1e77]: + - link "Settings" [ref=f1e78] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=f1e83] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=f1e87] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=f1e91]: + - banner [ref=f1e92]: + - generic [ref=f1e93]: + - button "Acme Fashion" [ref=f1e96] + - generic [ref=f1e100]: + - button "Notifications" [ref=f1e102] + - button "AU Admin User" [ref=f1e106]: + - generic [ref=f1e107]: AU + - generic [ref=f1e110]: Admin User + - main [ref=f1e114]: + - generic [ref=f1e115]: + - generic [ref=f1e116]: + - link "Home" [ref=f1e118] [cursor=pointer]: + - /url: /admin + - generic [ref=f1e121]: "#1005" + - generic [ref=f1e123]: + - generic [ref=f1e124]: + - heading "#1005" [level=1] [ref=f1e125] + - paragraph [ref=f1e126]: Placed 2 hours ago + - button "Refund" [ref=f1e136] + - generic [ref=f1e151]: + - generic [ref=f1e152]: + - generic [ref=f1e153]: + - generic [ref=f1e154]: Items + - generic [ref=f1e157]: + - generic [ref=f1e158]: + - generic [ref=f1e159]: Leather Belt + - generic [ref=f1e160]: SKU ACME-LEATHERB-SM-BLACK-2 Β· Qty 1 + - generic [ref=f1e161]: €34.99 + - generic [ref=f1e162]: + - generic [ref=f1e163]: + - generic [ref=f1e164]: Subtotal + - generic [ref=f1e165]: €34.99 + - generic [ref=f1e166]: + - generic [ref=f1e167]: Discount + - generic [ref=f1e168]: €0.00 + - generic [ref=f1e169]: + - generic [ref=f1e170]: Shipping + - generic [ref=f1e171]: €4.99 + - generic [ref=f1e172]: + - generic [ref=f1e173]: Tax + - generic [ref=f1e174]: €5.59 + - generic [ref=f1e175]: + - generic [ref=f1e176]: Total + - generic [ref=f1e177]: €39.98 + - generic [ref=f1e178]: + - generic [ref=f1e179]: Fulfillment + - generic [ref=f1e181]: + - paragraph [ref=f1e182]: No fulfillments yet. + - button "Create fulfillment" [ref=f1e211] + - generic [ref=f1e183]: + - generic [ref=f1e184]: Timeline + - list [ref=f1e186]: + - listitem [ref=f1e187]: + - generic [ref=f1e188]: Order created + - generic [ref=f1e189]: Jul 11, 2026 09:10 + - generic [ref=f1e190]: + - generic [ref=f1e191]: + - generic [ref=f1e192]: Status + - generic [ref=f1e194]: + - generic [ref=f1e195]: + - generic [ref=f1e196]: Payment + - generic [ref=f1e197]: Paid + - generic [ref=f1e198]: + - generic [ref=f1e199]: Fulfillment + - generic [ref=f1e200]: Unfulfilled + - generic [ref=f1e201]: + - generic [ref=f1e202]: Customer + - generic [ref=f1e204]: + - generic [ref=f1e205]: Jane Smith + - link "jane@example.com" [ref=f1e206] [cursor=pointer]: + - /url: mailto:jane@example.com + - generic [ref=f1e207]: + - generic [ref=f1e208]: Shipping address + - generic [ref=f1e210]: Jane Smith Schillerstrasse 45 Munich Bavaria BY Germany DE 80336 + - status [ref=f1e221]: + - generic [ref=f1e222]: Payment confirmed. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-10-39-032Z.yml b/.playwright-mcp/page-2026-07-11T11-10-39-032Z.yml new file mode 100644 index 00000000..20edb2d6 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-10-39-032Z.yml @@ -0,0 +1,54 @@ +- generic [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Sign in" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account/login + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart + - main [ref=e27]: + - generic [ref=e29]: + - generic [ref=e30]: Sign in + - paragraph [ref=e31]: Access your orders and saved addresses. + - generic [ref=e32]: + - generic [ref=e33]: + - generic [ref=e34]: Email + - textbox "Email" [active] [ref=e36] + - generic [ref=e37]: + - generic [ref=e38]: Password + - generic [ref=e39]: + - textbox "Password" [ref=e40] + - button "Toggle password visibility" [ref=e42] + - generic [ref=e46]: + - checkbox "Remember me" [ref=e47] + - generic [ref=e49]: Remember me + - button "Sign in" [ref=e50] + - paragraph [ref=e56]: + - text: New here? + - link "Create an account" [ref=e57] [cursor=pointer]: + - /url: http://acme-fashion.test/account/register + - contentinfo [ref=e58]: + - generic [ref=e59]: + - generic [ref=e60]: + - paragraph [ref=e61]: Acme Fashion + - paragraph [ref=e62]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e63]: + - link "Shop all collections" [ref=e64] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e65] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e66]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-10-47-627Z.yml b/.playwright-mcp/page-2026-07-11T11-10-47-627Z.yml new file mode 100644 index 00000000..7142059f --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-10-47-627Z.yml @@ -0,0 +1,71 @@ +- generic [active] [ref=e67]: + - link "Skip to main content" [ref=e68] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e69]: Free shipping available with code FREESHIP + - banner [ref=e70]: + - generic [ref=e71]: + - link "Acme Fashion" [ref=e72] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e73]: + - link "Home" [ref=e74] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e75] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e76] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Account" [ref=e77] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - generic [ref=e78]: + - button "Open product search" [ref=e80]: + - generic [ref=e85]: Search + - button "Open shopping cart" [ref=e87]: + - generic [ref=e92]: Cart + - main [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Welcome, John Doe" [level=1] [ref=e97] + - paragraph [ref=e98]: customer@acme.test + - button "Sign out" [ref=e99] + - generic [ref=e105]: + - link [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - heading "Orders" [level=2] [ref=e107] + - paragraph [ref=e108]: View your order history + - link [ref=e109] [cursor=pointer]: + - /url: http://acme-fashion.test/account/addresses + - heading "Addresses" [level=2] [ref=e110] + - paragraph [ref=e111]: Manage delivery addresses + - heading "Recent orders" [level=2] [ref=e112] + - generic [ref=e113]: + - link "#1015 €54.47" [ref=e114] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1015 + - generic [ref=e115]: "#1015" + - generic [ref=e116]: €54.47 + - link "#1010 €504.98" [ref=e118] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1010 + - generic [ref=e119]: "#1010" + - generic [ref=e120]: €504.98 + - link "#1001 €54.97" [ref=e122] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1001 + - generic [ref=e123]: "#1001" + - generic [ref=e124]: €54.97 + - link "#1002 €89.97" [ref=e126] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1002 + - generic [ref=e127]: "#1002" + - generic [ref=e128]: €89.97 + - link "#1004 €29.98" [ref=e130] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1004 + - generic [ref=e131]: "#1004" + - generic [ref=e132]: €29.98 + - contentinfo [ref=e134]: + - generic [ref=e135]: + - generic [ref=e136]: + - paragraph [ref=e137]: Acme Fashion + - paragraph [ref=e138]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e139]: + - link "Shop all collections" [ref=e140] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e141] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e142]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-10-58-585Z.yml b/.playwright-mcp/page-2026-07-11T11-10-58-585Z.yml new file mode 100644 index 00000000..7142059f --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-10-58-585Z.yml @@ -0,0 +1,71 @@ +- generic [active] [ref=e67]: + - link "Skip to main content" [ref=e68] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e69]: Free shipping available with code FREESHIP + - banner [ref=e70]: + - generic [ref=e71]: + - link "Acme Fashion" [ref=e72] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e73]: + - link "Home" [ref=e74] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e75] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e76] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Account" [ref=e77] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - generic [ref=e78]: + - button "Open product search" [ref=e80]: + - generic [ref=e85]: Search + - button "Open shopping cart" [ref=e87]: + - generic [ref=e92]: Cart + - main [ref=e93]: + - generic [ref=e94]: + - generic [ref=e95]: + - generic [ref=e96]: + - heading "Welcome, John Doe" [level=1] [ref=e97] + - paragraph [ref=e98]: customer@acme.test + - button "Sign out" [ref=e99] + - generic [ref=e105]: + - link [ref=e106] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - heading "Orders" [level=2] [ref=e107] + - paragraph [ref=e108]: View your order history + - link [ref=e109] [cursor=pointer]: + - /url: http://acme-fashion.test/account/addresses + - heading "Addresses" [level=2] [ref=e110] + - paragraph [ref=e111]: Manage delivery addresses + - heading "Recent orders" [level=2] [ref=e112] + - generic [ref=e113]: + - link "#1015 €54.47" [ref=e114] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1015 + - generic [ref=e115]: "#1015" + - generic [ref=e116]: €54.47 + - link "#1010 €504.98" [ref=e118] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1010 + - generic [ref=e119]: "#1010" + - generic [ref=e120]: €504.98 + - link "#1001 €54.97" [ref=e122] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1001 + - generic [ref=e123]: "#1001" + - generic [ref=e124]: €54.97 + - link "#1002 €89.97" [ref=e126] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1002 + - generic [ref=e127]: "#1002" + - generic [ref=e128]: €89.97 + - link "#1004 €29.98" [ref=e130] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/#1004 + - generic [ref=e131]: "#1004" + - generic [ref=e132]: €29.98 + - contentinfo [ref=e134]: + - generic [ref=e135]: + - generic [ref=e136]: + - paragraph [ref=e137]: Acme Fashion + - paragraph [ref=e138]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e139]: + - link "Shop all collections" [ref=e140] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e141] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e142]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-12-12-799Z.yml b/.playwright-mcp/page-2026-07-11T11-12-12-799Z.yml new file mode 100644 index 00000000..58d30b81 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-12-12-799Z.yml @@ -0,0 +1,71 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: Free shipping available with code FREESHIP + - banner [ref=e4]: + - generic [ref=e5]: + - link "Acme Fashion" [ref=e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=e7]: + - link "Home" [ref=e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Account" [ref=e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - generic [ref=e12]: + - button "Open product search" [ref=e14]: + - generic [ref=e19]: Search + - button "Open shopping cart" [ref=e21]: + - generic [ref=e26]: Cart + - main [ref=e27]: + - generic [ref=e28]: + - generic [ref=e29]: + - generic [ref=e30]: + - heading "Welcome, John Doe" [level=1] [ref=e31] + - paragraph [ref=e32]: customer@acme.test + - button "Sign out" [ref=e33] + - generic [ref=e39]: + - link [ref=e40] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - heading "Orders" [level=2] [ref=e41] + - paragraph [ref=e42]: View your order history + - link [ref=e43] [cursor=pointer]: + - /url: http://acme-fashion.test/account/addresses + - heading "Addresses" [level=2] [ref=e44] + - paragraph [ref=e45]: Manage delivery addresses + - heading "Recent orders" [level=2] [ref=e46] + - generic [ref=e47]: + - link "#1015 €54.47" [ref=e48] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/15 + - generic [ref=e49]: "#1015" + - generic [ref=e50]: €54.47 + - link "#1010 €504.98" [ref=e52] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/10 + - generic [ref=e53]: "#1010" + - generic [ref=e54]: €504.98 + - link "#1001 €54.97" [ref=e56] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/1 + - generic [ref=e57]: "#1001" + - generic [ref=e58]: €54.97 + - link "#1002 €89.97" [ref=e60] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/2 + - generic [ref=e61]: "#1002" + - generic [ref=e62]: €89.97 + - link "#1004 €29.98" [ref=e64] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders/4 + - generic [ref=e65]: "#1004" + - generic [ref=e66]: €29.98 + - contentinfo [ref=e68]: + - generic [ref=e69]: + - generic [ref=e70]: + - paragraph [ref=e71]: Acme Fashion + - paragraph [ref=e72]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=e73]: + - link "Shop all collections" [ref=e74] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=e75] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=e76]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-12-19-324Z.yml b/.playwright-mcp/page-2026-07-11T11-12-19-324Z.yml new file mode 100644 index 00000000..a5a04eef --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-12-19-324Z.yml @@ -0,0 +1,60 @@ +- generic [active] [ref=f1e1]: + - link "Skip to main content" [ref=f1e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f1e3]: Free shipping available with code FREESHIP + - banner [ref=f1e4]: + - generic [ref=f1e5]: + - link "Acme Fashion" [ref=f1e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f1e7]: + - link "Home" [ref=f1e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f1e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Account" [ref=f1e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - generic [ref=f1e12]: + - button "Open product search" [ref=f1e14]: + - generic [ref=f1e19]: Search + - button "Open shopping cart" [ref=f1e21]: + - generic [ref=f1e26]: Cart + - main [ref=f1e27]: + - generic [ref=f1e28]: + - navigation "Breadcrumb" [ref=f1e29]: + - list [ref=f1e30]: + - listitem [ref=f1e31]: + - link "Account" [ref=f1e32] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - generic [ref=f1e33]: / + - listitem [ref=f1e34]: + - link "Orders" [ref=f1e35] [cursor=pointer]: + - /url: http://acme-fashion.test/account/orders + - generic [ref=f1e36]: / + - listitem [ref=f1e37]: + - generic [ref=f1e38]: "#1001" + - generic [ref=f1e39]: + - generic [ref=f1e40]: + - 'heading "Order #1001" [level=1] [ref=f1e41]' + - paragraph [ref=f1e42]: Placed Jul 9, 2026 + - generic [ref=f1e43]: paid + - generic [ref=f1e44]: + - heading "Items" [level=2] [ref=f1e45] + - generic [ref=f1e46]: + - generic [ref=f1e47]: 2 Γ— Classic Cotton T-Shirt + - generic [ref=f1e48]: €49.98 + - generic [ref=f1e50]: + - generic [ref=f1e51]: Total + - generic [ref=f1e52]: €54.97 + - contentinfo [ref=f1e54]: + - generic [ref=f1e55]: + - generic [ref=f1e56]: + - paragraph [ref=f1e57]: Acme Fashion + - paragraph [ref=f1e58]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f1e59]: + - link "Shop all collections" [ref=f1e60] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f1e61] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f1e62]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-12-28-195Z.yml b/.playwright-mcp/page-2026-07-11T11-12-28-195Z.yml new file mode 100644 index 00000000..de3ceb79 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-12-28-195Z.yml @@ -0,0 +1,100 @@ +- generic [active] [ref=f2e1]: + - link "Skip to main content" [ref=f2e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f2e3]: Free shipping available with code FREESHIP + - banner [ref=f2e4]: + - generic [ref=f2e5]: + - link "Acme Electronics" [ref=f2e6] [cursor=pointer]: + - /url: http://acme-electronics.test + - navigation "Main navigation" [ref=f2e7]: + - link "Home" [ref=f2e8] [cursor=pointer]: + - /url: http://acme-electronics.test + - link "Collections" [ref=f2e9] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - link "Search" [ref=f2e10] [cursor=pointer]: + - /url: http://acme-electronics.test/search + - link "Sign in" [ref=f2e11] [cursor=pointer]: + - /url: http://acme-electronics.test/account/login + - generic [ref=f2e12]: + - button "Open product search" [ref=f2e14]: + - generic [ref=f2e19]: Search + - button "Open shopping cart" [ref=f2e21]: + - generic [ref=f2e26]: Cart + - main [ref=f2e27]: + - generic [ref=f2e28]: + - generic [ref=f2e31]: + - generic [ref=f2e32]: New season + - heading "Find your next favorite." [level=1] [ref=f2e33] + - paragraph [ref=f2e34]: Explore curated essentials from Acme Electronics. + - link "Shop collections" [ref=f2e36] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - generic [ref=f2e38]: + - generic [ref=f2e39]: + - generic [ref=f2e40]: + - paragraph [ref=f2e41]: Explore + - heading "Featured collections" [level=2] [ref=f2e42] + - link "View all" [ref=f2e43] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - generic [ref=f2e44]: + - link [ref=f2e45] [cursor=pointer]: + - /url: http://acme-electronics.test/collections/featured + - heading "Featured" [level=3] [ref=f2e46] + - paragraph [ref=f2e47]: 3 products + - link [ref=f2e48] [cursor=pointer]: + - /url: http://acme-electronics.test/collections/accessories + - heading "Accessories" [level=3] [ref=f2e49] + - paragraph [ref=f2e50]: 2 products + - generic [ref=f2e51]: + - heading "Featured products" [level=2] [ref=f2e52] + - generic [ref=f2e53]: + - article [ref=f2e54]: + - link "Monitor Stand image placeholder Monitor Stand €49.99" [ref=f2e55] [cursor=pointer]: + - /url: http://acme-electronics.test/products/monitor-stand + - img "Monitor Stand image placeholder" [ref=f2e56]: + - generic [ref=f2e57]: Monitor Stand + - generic [ref=f2e58]: + - heading "Monitor Stand" [level=2] [ref=f2e59] + - generic [ref=f2e60]: €49.99 + - article [ref=f2e62]: + - link "Mechanical Keyboard image placeholder Mechanical Keyboard €129.99" [ref=f2e63] [cursor=pointer]: + - /url: http://acme-electronics.test/products/mechanical-keyboard + - img "Mechanical Keyboard image placeholder" [ref=f2e64]: + - generic [ref=f2e65]: Mechanical Keyboard + - generic [ref=f2e66]: + - heading "Mechanical Keyboard" [level=2] [ref=f2e67] + - generic [ref=f2e68]: €129.99 + - article [ref=f2e70]: + - link "USB-C Cable 2m image placeholder USB-C Cable 2m €12.99" [ref=f2e71] [cursor=pointer]: + - /url: http://acme-electronics.test/products/usb-c-cable-2m + - img "USB-C Cable 2m image placeholder" [ref=f2e72]: + - generic [ref=f2e73]: USB-C Cable 2m + - generic [ref=f2e74]: + - heading "USB-C Cable 2m" [level=2] [ref=f2e75] + - generic [ref=f2e76]: €12.99 + - article [ref=f2e78]: + - link "Wireless Headphones image placeholder Wireless Headphones €149.99" [ref=f2e79] [cursor=pointer]: + - /url: http://acme-electronics.test/products/wireless-headphones + - img "Wireless Headphones image placeholder" [ref=f2e80]: + - generic [ref=f2e81]: Wireless Headphones + - generic [ref=f2e82]: + - heading "Wireless Headphones" [level=2] [ref=f2e83] + - generic [ref=f2e84]: €149.99 + - article [ref=f2e86]: + - link "Pro Laptop 15 image placeholder Pro Laptop 15 €999.99" [ref=f2e87] [cursor=pointer]: + - /url: http://acme-electronics.test/products/pro-laptop-15 + - img "Pro Laptop 15 image placeholder" [ref=f2e88]: + - generic [ref=f2e89]: Pro Laptop 15 + - generic [ref=f2e90]: + - heading "Pro Laptop 15" [level=2] [ref=f2e91] + - generic [ref=f2e92]: €999.99 + - contentinfo [ref=f2e94]: + - generic [ref=f2e95]: + - generic [ref=f2e96]: + - paragraph [ref=f2e97]: Acme Electronics + - paragraph [ref=f2e98]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f2e99]: + - link "Shop all collections" [ref=f2e100] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - link "Search" [ref=f2e101] [cursor=pointer]: + - /url: http://acme-electronics.test/search + - paragraph [ref=f2e102]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-13-17-485Z.yml b/.playwright-mcp/page-2026-07-11T11-13-17-485Z.yml new file mode 100644 index 00000000..69abf61f --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-13-17-485Z.yml @@ -0,0 +1,93 @@ +- generic [active] [ref=f3e1]: + - link "Skip to main content" [ref=f3e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f3e3]: Free shipping available with code FREESHIP + - banner [ref=f3e4]: + - generic [ref=f3e5]: + - link "Acme Electronics" [ref=f3e6] [cursor=pointer]: + - /url: http://acme-electronics.test + - generic [ref=f3e7]: + - button "Open product search" [ref=f3e9]: + - generic [ref=f3e14]: Search + - button "Open shopping cart" [ref=f3e16]: + - generic [ref=f3e21]: Cart + - group [ref=f3e22]: + - generic "Menu" [ref=f3e23] [cursor=pointer] + - main [ref=f3e24]: + - generic [ref=f3e25]: + - generic [ref=f3e28]: + - generic [ref=f3e29]: New season + - heading "Find your next favorite." [level=1] [ref=f3e30] + - paragraph [ref=f3e31]: Explore curated essentials from Acme Electronics. + - link "Shop collections" [ref=f3e33] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - generic [ref=f3e35]: + - generic [ref=f3e36]: + - generic [ref=f3e37]: + - paragraph [ref=f3e38]: Explore + - heading "Featured collections" [level=2] [ref=f3e39] + - link "View all" [ref=f3e40] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - generic [ref=f3e41]: + - link [ref=f3e42] [cursor=pointer]: + - /url: http://acme-electronics.test/collections/featured + - heading "Featured" [level=3] [ref=f3e43] + - paragraph [ref=f3e44]: 3 products + - link [ref=f3e45] [cursor=pointer]: + - /url: http://acme-electronics.test/collections/accessories + - heading "Accessories" [level=3] [ref=f3e46] + - paragraph [ref=f3e47]: 2 products + - generic [ref=f3e48]: + - heading "Featured products" [level=2] [ref=f3e49] + - generic [ref=f3e50]: + - article [ref=f3e51]: + - link "Monitor Stand image placeholder Monitor Stand €49.99" [ref=f3e52] [cursor=pointer]: + - /url: http://acme-electronics.test/products/monitor-stand + - img "Monitor Stand image placeholder" [ref=f3e53]: + - generic [ref=f3e54]: Monitor Stand + - generic [ref=f3e55]: + - heading "Monitor Stand" [level=2] [ref=f3e56] + - generic [ref=f3e57]: €49.99 + - article [ref=f3e59]: + - link "Mechanical Keyboard image placeholder Mechanical Keyboard €129.99" [ref=f3e60] [cursor=pointer]: + - /url: http://acme-electronics.test/products/mechanical-keyboard + - img "Mechanical Keyboard image placeholder" [ref=f3e61]: + - generic [ref=f3e62]: Mechanical Keyboard + - generic [ref=f3e63]: + - heading "Mechanical Keyboard" [level=2] [ref=f3e64] + - generic [ref=f3e65]: €129.99 + - article [ref=f3e67]: + - link "USB-C Cable 2m image placeholder USB-C Cable 2m €12.99" [ref=f3e68] [cursor=pointer]: + - /url: http://acme-electronics.test/products/usb-c-cable-2m + - img "USB-C Cable 2m image placeholder" [ref=f3e69]: + - generic [ref=f3e70]: USB-C Cable 2m + - generic [ref=f3e71]: + - heading "USB-C Cable 2m" [level=2] [ref=f3e72] + - generic [ref=f3e73]: €12.99 + - article [ref=f3e75]: + - link "Wireless Headphones image placeholder Wireless Headphones €149.99" [ref=f3e76] [cursor=pointer]: + - /url: http://acme-electronics.test/products/wireless-headphones + - img "Wireless Headphones image placeholder" [ref=f3e77]: + - generic [ref=f3e78]: Wireless Headphones + - generic [ref=f3e79]: + - heading "Wireless Headphones" [level=2] [ref=f3e80] + - generic [ref=f3e81]: €149.99 + - article [ref=f3e83]: + - link "Pro Laptop 15 image placeholder Pro Laptop 15 €999.99" [ref=f3e84] [cursor=pointer]: + - /url: http://acme-electronics.test/products/pro-laptop-15 + - img "Pro Laptop 15 image placeholder" [ref=f3e85]: + - generic [ref=f3e86]: Pro Laptop 15 + - generic [ref=f3e87]: + - heading "Pro Laptop 15" [level=2] [ref=f3e88] + - generic [ref=f3e89]: €999.99 + - contentinfo [ref=f3e91]: + - generic [ref=f3e92]: + - generic [ref=f3e93]: + - paragraph [ref=f3e94]: Acme Electronics + - paragraph [ref=f3e95]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f3e96]: + - link "Shop all collections" [ref=f3e97] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - link "Search" [ref=f3e98] [cursor=pointer]: + - /url: http://acme-electronics.test/search + - paragraph [ref=f3e99]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-13-26-021Z.yml b/.playwright-mcp/page-2026-07-11T11-13-26-021Z.yml new file mode 100644 index 00000000..0cb9698d --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-13-26-021Z.yml @@ -0,0 +1,100 @@ +- generic [ref=f3e1]: + - link "Skip to main content" [ref=f3e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f3e3]: Free shipping available with code FREESHIP + - banner [ref=f3e4]: + - generic [ref=f3e5]: + - link "Acme Electronics" [ref=f3e6] [cursor=pointer]: + - /url: http://acme-electronics.test + - generic [ref=f3e7]: + - button "Open product search" [ref=f3e9]: + - generic [ref=f3e14]: Search + - button "Open shopping cart" [ref=f3e16]: + - generic [ref=f3e21]: Cart + - group [ref=f3e22]: + - generic "Menu" [active] [ref=f3e23] [cursor=pointer] + - navigation "Mobile navigation" [ref=f3e100]: + - link "Home" [ref=f3e101] [cursor=pointer]: + - /url: http://acme-electronics.test + - link "Collections" [ref=f3e102] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - link "Account" [ref=f3e103] [cursor=pointer]: + - /url: http://acme-electronics.test/account + - main [ref=f3e24]: + - generic [ref=f3e25]: + - generic [ref=f3e28]: + - generic [ref=f3e29]: New season + - heading "Find your next favorite." [level=1] [ref=f3e30] + - paragraph [ref=f3e31]: Explore curated essentials from Acme Electronics. + - link "Shop collections" [ref=f3e33] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - generic [ref=f3e35]: + - generic [ref=f3e36]: + - generic [ref=f3e37]: + - paragraph [ref=f3e38]: Explore + - heading "Featured collections" [level=2] [ref=f3e39] + - link "View all" [ref=f3e40] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - generic [ref=f3e41]: + - link [ref=f3e42] [cursor=pointer]: + - /url: http://acme-electronics.test/collections/featured + - heading "Featured" [level=3] [ref=f3e43] + - paragraph [ref=f3e44]: 3 products + - link [ref=f3e45] [cursor=pointer]: + - /url: http://acme-electronics.test/collections/accessories + - heading "Accessories" [level=3] [ref=f3e46] + - paragraph [ref=f3e47]: 2 products + - generic [ref=f3e48]: + - heading "Featured products" [level=2] [ref=f3e49] + - generic [ref=f3e50]: + - article [ref=f3e51]: + - link "Monitor Stand image placeholder Monitor Stand €49.99" [ref=f3e52] [cursor=pointer]: + - /url: http://acme-electronics.test/products/monitor-stand + - img "Monitor Stand image placeholder" [ref=f3e53]: + - generic [ref=f3e54]: Monitor Stand + - generic [ref=f3e55]: + - heading "Monitor Stand" [level=2] [ref=f3e56] + - generic [ref=f3e57]: €49.99 + - article [ref=f3e59]: + - link "Mechanical Keyboard image placeholder Mechanical Keyboard €129.99" [ref=f3e60] [cursor=pointer]: + - /url: http://acme-electronics.test/products/mechanical-keyboard + - img "Mechanical Keyboard image placeholder" [ref=f3e61]: + - generic [ref=f3e62]: Mechanical Keyboard + - generic [ref=f3e63]: + - heading "Mechanical Keyboard" [level=2] [ref=f3e64] + - generic [ref=f3e65]: €129.99 + - article [ref=f3e67]: + - link "USB-C Cable 2m image placeholder USB-C Cable 2m €12.99" [ref=f3e68] [cursor=pointer]: + - /url: http://acme-electronics.test/products/usb-c-cable-2m + - img "USB-C Cable 2m image placeholder" [ref=f3e69]: + - generic [ref=f3e70]: USB-C Cable 2m + - generic [ref=f3e71]: + - heading "USB-C Cable 2m" [level=2] [ref=f3e72] + - generic [ref=f3e73]: €12.99 + - article [ref=f3e75]: + - link "Wireless Headphones image placeholder Wireless Headphones €149.99" [ref=f3e76] [cursor=pointer]: + - /url: http://acme-electronics.test/products/wireless-headphones + - img "Wireless Headphones image placeholder" [ref=f3e77]: + - generic [ref=f3e78]: Wireless Headphones + - generic [ref=f3e79]: + - heading "Wireless Headphones" [level=2] [ref=f3e80] + - generic [ref=f3e81]: €149.99 + - article [ref=f3e83]: + - link "Pro Laptop 15 image placeholder Pro Laptop 15 €999.99" [ref=f3e84] [cursor=pointer]: + - /url: http://acme-electronics.test/products/pro-laptop-15 + - img "Pro Laptop 15 image placeholder" [ref=f3e85]: + - generic [ref=f3e86]: Pro Laptop 15 + - generic [ref=f3e87]: + - heading "Pro Laptop 15" [level=2] [ref=f3e88] + - generic [ref=f3e89]: €999.99 + - contentinfo [ref=f3e91]: + - generic [ref=f3e92]: + - generic [ref=f3e93]: + - paragraph [ref=f3e94]: Acme Electronics + - paragraph [ref=f3e95]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f3e96]: + - link "Shop all collections" [ref=f3e97] [cursor=pointer]: + - /url: http://acme-electronics.test/collections + - link "Search" [ref=f3e98] [cursor=pointer]: + - /url: http://acme-electronics.test/search + - paragraph [ref=f3e99]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-24-36-347Z.yml b/.playwright-mcp/page-2026-07-11T11-24-36-347Z.yml new file mode 100644 index 00000000..77aef4f2 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-24-36-347Z.yml @@ -0,0 +1,131 @@ +- generic [active] [ref=f4e1]: + - link "Skip to main content" [ref=f4e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=f4e3]: Free shipping available with code FREESHIP + - banner [ref=f4e4]: + - generic [ref=f4e5]: + - link "Acme Fashion" [ref=f4e6] [cursor=pointer]: + - /url: http://acme-fashion.test + - navigation "Main navigation" [ref=f4e7]: + - link "Home" [ref=f4e8] [cursor=pointer]: + - /url: http://acme-fashion.test + - link "Collections" [ref=f4e9] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f4e10] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - link "Account" [ref=f4e11] [cursor=pointer]: + - /url: http://acme-fashion.test/account + - generic [ref=f4e12]: + - button "Open product search" [ref=f4e14]: + - generic [ref=f4e19]: Search + - button "Open shopping cart" [ref=f4e21]: + - generic [ref=f4e26]: Cart + - main [ref=f4e27]: + - generic [ref=f4e28]: + - generic [ref=f4e31]: + - generic [ref=f4e32]: New season + - heading "Find your next favorite." [level=1] [ref=f4e33] + - paragraph [ref=f4e34]: Explore curated essentials from Acme Fashion. + - link "Shop collections" [ref=f4e36] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=f4e38]: + - generic [ref=f4e39]: + - generic [ref=f4e40]: + - paragraph [ref=f4e41]: Explore + - heading "Featured collections" [level=2] [ref=f4e42] + - link "View all" [ref=f4e43] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - generic [ref=f4e44]: + - link [ref=f4e45] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/new-arrivals + - heading "New Arrivals" [level=3] [ref=f4e46] + - paragraph [ref=f4e47]: 7 products + - link [ref=f4e48] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/t-shirts + - heading "T-Shirts" [level=3] [ref=f4e49] + - paragraph [ref=f4e50]: 4 products + - link [ref=f4e51] [cursor=pointer]: + - /url: http://acme-fashion.test/collections/pants-jeans + - heading "Pants & Jeans" [level=3] [ref=f4e52] + - paragraph [ref=f4e53]: 4 products + - generic [ref=f4e54]: + - heading "Featured products" [level=2] [ref=f4e55] + - generic [ref=f4e56]: + - article [ref=f4e57]: + - link "Cashmere Overcoat image placeholder Cashmere Overcoat €499.99" [ref=f4e58] [cursor=pointer]: + - /url: http://acme-fashion.test/products/cashmere-overcoat + - img "Cashmere Overcoat image placeholder" [ref=f4e59]: + - generic [ref=f4e60]: Cashmere Overcoat + - generic [ref=f4e61]: + - heading "Cashmere Overcoat" [level=2] [ref=f4e62] + - generic [ref=f4e63]: €499.99 + - article [ref=f4e65]: + - link "Gift Card image placeholder Gift Card €25.00" [ref=f4e66] [cursor=pointer]: + - /url: http://acme-fashion.test/products/gift-card + - img "Gift Card image placeholder" [ref=f4e67]: + - generic [ref=f4e68]: Gift Card + - generic [ref=f4e69]: + - heading "Gift Card" [level=2] [ref=f4e70] + - generic [ref=f4e71]: €25.00 + - article [ref=f4e73]: + - link "Backorder Denim Jacket image placeholder Backorder Denim Jacket €99.99" [ref=f4e74] [cursor=pointer]: + - /url: http://acme-fashion.test/products/backorder-denim-jacket + - img "Backorder Denim Jacket image placeholder" [ref=f4e75]: + - generic [ref=f4e76]: Backorder Denim Jacket + - generic [ref=f4e77]: + - heading "Backorder Denim Jacket" [level=2] [ref=f4e78] + - generic [ref=f4e79]: €99.99 + - article [ref=f4e81]: + - link "Limited Edition Sneakers image placeholder Limited Edition Sneakers €159.99" [ref=f4e82] [cursor=pointer]: + - /url: http://acme-fashion.test/products/limited-edition-sneakers + - img "Limited Edition Sneakers image placeholder" [ref=f4e83]: + - generic [ref=f4e84]: Limited Edition Sneakers + - generic [ref=f4e85]: + - heading "Limited Edition Sneakers" [level=2] [ref=f4e86] + - generic [ref=f4e87]: €159.99 + - article [ref=f4e89]: + - link "Bucket Hat image placeholder Bucket Hat €24.99" [ref=f4e90] [cursor=pointer]: + - /url: http://acme-fashion.test/products/bucket-hat + - img "Bucket Hat image placeholder" [ref=f4e91]: + - generic [ref=f4e92]: Bucket Hat + - generic [ref=f4e93]: + - heading "Bucket Hat" [level=2] [ref=f4e94] + - generic [ref=f4e95]: €24.99 + - article [ref=f4e97]: + - link "Canvas Tote Bag image placeholder Canvas Tote Bag €19.99" [ref=f4e98] [cursor=pointer]: + - /url: http://acme-fashion.test/products/canvas-tote-bag + - img "Canvas Tote Bag image placeholder" [ref=f4e99]: + - generic [ref=f4e100]: Canvas Tote Bag + - generic [ref=f4e101]: + - heading "Canvas Tote Bag" [level=2] [ref=f4e102] + - generic [ref=f4e103]: €19.99 + - article [ref=f4e105]: + - link "Wool Scarf image placeholder Wool Scarf €29.99" [ref=f4e106] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wool-scarf + - img "Wool Scarf image placeholder" [ref=f4e107]: + - generic [ref=f4e108]: Wool Scarf + - generic [ref=f4e109]: + - heading "Wool Scarf" [level=2] [ref=f4e110] + - generic [ref=f4e111]: €29.99 + - article [ref=f4e113]: + - link "Wide Leg Trousers image placeholder Wide Leg Trousers €49.99 €69.99 Sale price" [ref=f4e114] [cursor=pointer]: + - /url: http://acme-fashion.test/products/wide-leg-trousers + - img "Wide Leg Trousers image placeholder" [ref=f4e115]: + - generic [ref=f4e116]: Wide Leg Trousers + - generic [ref=f4e117]: + - heading "Wide Leg Trousers" [level=2] [ref=f4e118] + - generic [ref=f4e119]: + - generic [ref=f4e120]: €49.99 + - generic [ref=f4e121]: €69.99 + - generic [ref=f4e122]: Sale price + - contentinfo [ref=f4e123]: + - generic [ref=f4e124]: + - generic [ref=f4e125]: + - paragraph [ref=f4e126]: Acme Fashion + - paragraph [ref=f4e127]: Thoughtfully selected products for everyday life. + - navigation "Footer navigation" [ref=f4e128]: + - link "Shop all collections" [ref=f4e129] [cursor=pointer]: + - /url: http://acme-fashion.test/collections + - link "Search" [ref=f4e130] [cursor=pointer]: + - /url: http://acme-fashion.test/search + - paragraph [ref=f4e131]: Secure mock checkout. Prices shown in EUR. \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-24-36-506Z.yml b/.playwright-mcp/page-2026-07-11T11-24-36-506Z.yml new file mode 100644 index 00000000..2e89eea3 --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-24-36-506Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e2]: + - link "Laravel Starter Kit" [ref=e4] [cursor=pointer]: + - /url: http://acme-fashion.test/dashboard + - navigation [ref=e9]: + - generic [ref=e10]: + - generic [ref=e11]: Platform + - link "Dashboard" [ref=e15] [cursor=pointer]: + - /url: http://acme-fashion.test/dashboard + - navigation [ref=e21]: + - link "Repository" [ref=e23] [cursor=pointer]: + - /url: https://github.com/laravel/livewire-starter-kit + - link "Documentation" [ref=e32] [cursor=pointer]: + - /url: https://laravel.com/docs/starter-kits#livewire + - button "AU Admin User" [ref=e38]: + - generic [ref=e39]: AU + - generic [ref=e42]: Admin User \ No newline at end of file diff --git a/.playwright-mcp/page-2026-07-11T11-25-55-023Z.yml b/.playwright-mcp/page-2026-07-11T11-25-55-023Z.yml new file mode 100644 index 00000000..10e76cbd --- /dev/null +++ b/.playwright-mcp/page-2026-07-11T11-25-55-023Z.yml @@ -0,0 +1,158 @@ +- generic [ref=f1e2]: + - complementary [ref=f1e3]: + - navigation "Admin navigation" [ref=f1e4]: + - generic [ref=f1e9]: + - generic [ref=f1e10]: Shop + - generic [ref=f1e11]: Commerce platform + - generic [ref=f1e12]: + - link "Dashboard" [ref=f1e14] [cursor=pointer]: + - /url: /admin + - generic [ref=f1e18]: Products + - generic [ref=f1e19]: + - link "Products" [ref=f1e20] [cursor=pointer]: + - /url: /admin/products + - link "Collections" [ref=f1e24] [cursor=pointer]: + - /url: /admin/collections + - link "Inventory" [ref=f1e28] [cursor=pointer]: + - /url: /admin/inventory + - generic [ref=f1e32]: Orders + - link "Orders" [ref=f1e34] [cursor=pointer]: + - /url: /admin/orders + - generic [ref=f1e38]: Customers + - link "Customers" [ref=f1e40] [cursor=pointer]: + - /url: /admin/customers + - generic [ref=f1e44]: Discounts + - link "Discounts" [ref=f1e46] [cursor=pointer]: + - /url: /admin/discounts + - generic [ref=f1e51]: Content + - generic [ref=f1e52]: + - link "Pages" [ref=f1e53] [cursor=pointer]: + - /url: /admin/pages + - link "Navigation" [ref=f1e57] [cursor=pointer]: + - /url: /admin/navigation + - link "Themes" [ref=f1e61] [cursor=pointer]: + - /url: /admin/themes + - generic [ref=f1e65]: Insights + - generic [ref=f1e66]: + - link "Analytics" [ref=f1e67] [cursor=pointer]: + - /url: /admin/analytics + - link "Search" [ref=f1e72] [cursor=pointer]: + - /url: /admin/settings/search + - generic [ref=f1e76]: Platform + - generic [ref=f1e77]: + - link "Settings" [ref=f1e78] [cursor=pointer]: + - /url: /admin/settings + - link "Apps" [ref=f1e83] [cursor=pointer]: + - /url: /admin/apps + - link "Developers" [ref=f1e87] [cursor=pointer]: + - /url: /admin/developers + - generic [ref=f1e91]: + - banner [ref=f1e92]: + - generic [ref=f1e93]: + - button "Acme Fashion" [ref=f1e96] + - generic [ref=f1e100]: + - button "Notifications" [ref=f1e102] + - button "AU Admin User" [ref=f1e106]: + - generic [ref=f1e107]: AU + - generic [ref=f1e110]: Admin User + - main [ref=f1e114]: + - generic [ref=f1e115]: + - generic [ref=f1e116]: + - link "Home" [ref=f1e118] [cursor=pointer]: + - /url: /admin + - generic [ref=f1e121]: Dashboard + - generic [ref=f1e123]: + - generic [ref=f1e124]: + - heading "Dashboard" [level=1] [ref=f1e125] + - paragraph [ref=f1e126]: A live view of store performance. + - combobox "Date range" [ref=f1e128]: + - option "Today" + - option "Last 7 days" + - option "Last 30 days" [selected] + - option "Custom range" + - generic [ref=f1e129]: + - generic [ref=f1e132]: + - paragraph [ref=f1e133]: Total sales + - generic [ref=f1e134]: €1,517.12 + - generic [ref=f1e139]: + - paragraph [ref=f1e140]: Orders + - generic [ref=f1e141]: "15" + - generic [ref=f1e146]: + - paragraph [ref=f1e147]: Average order value + - generic [ref=f1e148]: €101.14 + - generic [ref=f1e153]: + - paragraph [ref=f1e154]: Visitors + - generic [ref=f1e155]: 3,481 + - generic [ref=f1e158]: + - generic [ref=f1e159]: + - generic [ref=f1e160]: Orders over time + - generic "Orders chart" [ref=f1e162]: + - generic: + - 'generic "2026-06-16: 1"' + - generic: + - 'generic "2026-06-21: 1"' + - generic: + - 'generic "2026-06-26: 1"' + - generic: + - 'generic "2026-06-27: 1"' + - generic: + - 'generic "2026-06-29: 1"' + - generic: + - 'generic "2026-07-01: 1"' + - generic: + - 'generic "2026-07-06: 1"' + - generic: + - 'generic "2026-07-07: 1"' + - generic: + - 'generic "2026-07-08: 1"' + - generic: + - 'generic "2026-07-09: 1"' + - generic: + - 'generic "2026-07-10: 3"' + - generic: + - 'generic "2026-07-11: 2"' + - generic [ref=f1e163]: + - generic [ref=f1e164]: Conversion funnel + - generic [ref=f1e166]: + - generic [ref=f1e168]: + - generic [ref=f1e169]: Visits + - generic [ref=f1e170]: 3,481 + - generic [ref=f1e174]: + - generic [ref=f1e175]: Add To Cart + - generic [ref=f1e176]: "740" + - generic [ref=f1e180]: + - generic [ref=f1e181]: Checkout Started + - generic [ref=f1e182]: "352" + - generic [ref=f1e186]: + - generic [ref=f1e187]: Checkout Completed + - generic [ref=f1e188]: "152" + - generic [ref=f1e191]: + - generic [ref=f1e192]: Top products + - table [ref=f1e195]: + - rowgroup [ref=f1e196]: + - row [ref=f1e197]: + - columnheader "Product" [ref=f1e198] + - columnheader "Units sold" [ref=f1e199] + - columnheader "Revenue" [ref=f1e200] + - rowgroup [ref=f1e201]: + - row [ref=f1e202]: + - cell "Cashmere Overcoat" [ref=f1e203] + - cell "1" [ref=f1e204] + - cell "€499.99" [ref=f1e205] + - row [ref=f1e206]: + - cell "Classic Cotton T-Shirt" [ref=f1e207] + - cell "5" [ref=f1e208] + - cell "€124.95" [ref=f1e209] + - row [ref=f1e210]: + - cell "Running Sneakers" [ref=f1e211] + - cell "1" [ref=f1e212] + - cell "€119.99" [ref=f1e213] + - row [ref=f1e214]: + - cell "Premium Slim Fit Jeans" [ref=f1e215] + - cell "1" [ref=f1e216] + - cell "€79.99" [ref=f1e217] + - row [ref=f1e218]: + - cell "Chino Shorts" [ref=f1e219] + - cell "2" [ref=f1e220] + - cell "€79.98" [ref=f1e221] + - status \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..a5316efa --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. You must use sub-agents for role play (e.g. frontend-, backend-deveoper, QA Analyst, QA Engineer, etc). You must do in one go without stopping. You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you. + +Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message. + +When implementation is fully done, then make a full review meeting with Playwright in Chrome and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. Shop is running at http://shop.test/. + +Don't re-use any existing implementation in another branch. Build it from scratch. From 0e3d4c3f83536c518081a19703869d81864ef0af Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 21:44:18 +0200 Subject: [PATCH 02/10] docs: initialize shop implementation progress --- specs/progress.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 specs/progress.md diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..85aed49d --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,51 @@ +# Shop Implementation Progress + +Last updated: 2026-07-12 + +## Mission + +Build the complete self-contained multi-tenant shop described by Specs 01-09 from the clean Laravel starter on branch `2026-07-12-codex-gpt-5-6-sol-ultra`. No implementation from another branch will be reused. + +## Delivery Status + +| Phase | Status | Verification | +| --- | --- | --- | +| 0. Specification traceability and repository audit | In progress | Specs and starter audited by backend, frontend, and QA roles | +| 1. Foundation, tenancy, authentication, authorization | Pending | Pest + migration/seed smoke | +| 2. Catalog, inventory, collections, media | Pending | Pest unit/feature | +| 3. Themes, CMS, navigation, storefront shell | Pending | Pest feature + Chrome | +| 4. Cart, checkout, discounts, shipping, taxes | Pending | Pest unit/feature + Chrome | +| 5. Payments, orders, refunds, fulfillment | Pending | Pest unit/feature + Chrome | +| 6. Customer accounts | Pending | Pest feature + Chrome | +| 7. Admin panel | Pending | Pest feature + Chrome | +| 8. Search, analytics, apps, webhooks | Pending | Pest unit/feature | +| 9. Accessibility, responsive, dark mode, error states | Pending | Build + Chrome review | +| 10. Full quality and acceptance verification | Pending | Pint, quality checker, all Pest, fresh seed, routes, config cache, Chrome | + +## Iteration Log + +### Iteration 0 - Audit and planning + +- Confirmed the current branch contains only the Laravel 12 / Livewire 4 starter plus specifications. +- Confirmed no shop implementation is present in the working tree. +- Loaded the required in-app Chrome acceptance workflow and Laravel deterministic quality workflow. +- Started independent backend architecture, frontend architecture, and QA traceability reviews. +- Began mapping the strict dependency order from `09-IMPLEMENTATION-ROADMAP.md` to implementation commits. + +## Verification Ledger + +| Check | Latest Result | +| --- | --- | +| Working tree at start | Clean | +| Existing shop code reused | No | +| Pest suite | Not run yet | +| Vite production build | Not run yet | +| Fresh migrate and seed | Not run yet | +| Laravel code-quality checker | Not installed yet | +| Chrome acceptance review | Not run yet | + +## Open Risks + +- SQLite FTS5 availability must be verified in the local PHP SQLite build. +- `shop.test` must resolve to the seeded storefront while admin tenant selection remains session-based. +- The specification spans roughly 320-380 files; traceability and staged verification are required to prevent late integration defects. From 53fd82525aaa7ddc3bcb4933369e66dff9d1cbff Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 22:19:39 +0200 Subject: [PATCH 03/10] feat: build multi-tenant shop domain foundation --- .env.example | 12 +- app/Auth/CustomerUserProvider.php | 33 +++ app/Contracts/PaymentProvider.php | 17 ++ app/Contracts/TaxProvider.php | 11 + app/Enums/AppInstallationStatus.php | 10 + app/Enums/AppStatus.php | 9 + app/Enums/CartStatus.php | 10 + app/Enums/CheckoutStatus.php | 13 ++ app/Enums/CollectionStatus.php | 10 + app/Enums/CollectionType.php | 9 + app/Enums/DiscountStatus.php | 11 + app/Enums/DiscountType.php | 9 + app/Enums/DiscountValueType.php | 10 + app/Enums/FinancialStatus.php | 13 ++ app/Enums/FulfillmentOrderStatus.php | 10 + app/Enums/FulfillmentShipmentStatus.php | 10 + app/Enums/FulfillmentStatus.php | 10 + app/Enums/InventoryPolicy.php | 9 + app/Enums/MediaStatus.php | 10 + app/Enums/MediaType.php | 9 + app/Enums/NavigationItemType.php | 11 + app/Enums/OrderStatus.php | 12 ++ app/Enums/PageStatus.php | 10 + app/Enums/PaymentMethod.php | 10 + app/Enums/PaymentStatus.php | 11 + app/Enums/ProductStatus.php | 10 + app/Enums/RefundStatus.php | 10 + app/Enums/ShippingRateType.php | 11 + app/Enums/StoreDomainType.php | 10 + app/Enums/StoreStatus.php | 9 + app/Enums/StoreUserRole.php | 11 + app/Enums/TaxMode.php | 9 + app/Enums/TaxProviderType.php | 9 + app/Enums/ThemeStatus.php | 9 + app/Enums/TlsMode.php | 9 + app/Enums/UserStatus.php | 9 + app/Enums/VariantStatus.php | 9 + app/Enums/WebhookDeliveryStatus.php | 10 + app/Enums/WebhookSubscriptionStatus.php | 10 + app/Events/CartUpdated.php | 14 ++ app/Events/CheckoutAddressed.php | 14 ++ app/Events/CheckoutCompleted.php | 15 ++ app/Events/CheckoutExpired.php | 14 ++ app/Events/CheckoutShippingSelected.php | 14 ++ app/Events/FulfillmentCreated.php | 14 ++ app/Events/FulfillmentDelivered.php | 14 ++ app/Events/FulfillmentShipped.php | 14 ++ app/Events/OrderCancelled.php | 14 ++ app/Events/OrderCreated.php | 14 ++ app/Events/OrderFulfilled.php | 14 ++ app/Events/OrderPaid.php | 14 ++ app/Events/OrderRefunded.php | 15 ++ app/Events/ProductCreated.php | 14 ++ app/Events/ProductDeleted.php | 14 ++ app/Events/ProductStatusChanged.php | 18 ++ app/Events/ProductUpdated.php | 14 ++ .../CartVersionMismatchException.php | 13 ++ app/Exceptions/DomainException.php | 7 + app/Exceptions/FulfillmentGuardException.php | 5 + .../InsufficientInventoryException.php | 5 + .../InvalidCheckoutTransitionException.php | 5 + app/Exceptions/InvalidDiscountException.php | 11 + .../InvalidProductTransitionException.php | 5 + app/Exceptions/PaymentFailedException.php | 11 + .../ShippingUnavailableException.php | 5 + .../Api/Admin/AnalyticsController.php | 40 ++++ .../Api/Admin/CollectionController.php | 83 ++++++++ .../Api/Admin/ContentController.php | 110 ++++++++++ .../Api/Admin/DiscountController.php | 66 ++++++ .../Controllers/Api/Admin/OrderController.php | 81 ++++++++ .../Api/Admin/PlatformController.php | 56 +++++ .../Api/Admin/ProductController.php | 99 +++++++++ .../Api/Admin/SettingsController.php | 59 ++++++ .../Api/Storefront/AnalyticsController.php | 39 ++++ .../Api/Storefront/CartController.php | 106 ++++++++++ .../Api/Storefront/CheckoutController.php | 126 ++++++++++++ .../Api/Storefront/OrderController.php | 35 ++++ .../Api/Storefront/SearchController.php | 28 +++ app/Http/Middleware/CheckStoreRole.php | 21 ++ app/Http/Middleware/CustomerAuthenticate.php | 20 ++ app/Http/Middleware/ResolveStore.php | 76 +++++++ app/Jobs/AggregateAnalytics.php | 24 +++ app/Jobs/CancelUnpaidBankTransferOrders.php | 32 +++ app/Jobs/CleanupAbandonedCarts.php | 30 +++ app/Jobs/DeliverWebhook.php | 70 +++++++ app/Jobs/ExpireAbandonedCheckouts.php | 26 +++ app/Jobs/ProcessMediaUpload.php | 77 +++++++ app/Models/AnalyticsDaily.php | 39 ++++ app/Models/AnalyticsEvent.php | 32 +++ app/Models/App.php | 32 +++ app/Models/AppInstallation.php | 43 ++++ app/Models/Cart.php | 40 ++++ app/Models/CartLine.php | 40 ++++ app/Models/Checkout.php | 49 +++++ app/Models/Collection.php | 33 +++ app/Models/CollectionProduct.php | 32 +++ app/Models/Concerns/BelongsToStore.php | 30 +++ app/Models/Customer.php | 76 +++++++ app/Models/CustomerAddress.php | 29 +++ app/Models/Discount.php | 35 ++++ app/Models/Fulfillment.php | 38 ++++ app/Models/FulfillmentLine.php | 31 +++ app/Models/InventoryItem.php | 37 ++++ app/Models/NavigationItem.php | 46 +++++ app/Models/NavigationMenu.php | 20 ++ app/Models/OauthClient.php | 31 +++ app/Models/OauthToken.php | 28 +++ app/Models/Order.php | 67 ++++++ app/Models/OrderLine.php | 51 +++++ app/Models/Organization.php | 19 ++ app/Models/Page.php | 23 +++ app/Models/Payment.php | 41 ++++ app/Models/Product.php | 56 +++++ app/Models/ProductMedia.php | 39 ++++ app/Models/ProductOption.php | 32 +++ app/Models/ProductOptionValue.php | 33 +++ app/Models/ProductVariant.php | 60 ++++++ app/Models/Refund.php | 35 ++++ app/Models/Scopes/StoreScope.php | 23 +++ app/Models/SearchQuery.php | 24 +++ app/Models/SearchSettings.php | 28 +++ app/Models/ShippingRate.php | 37 ++++ app/Models/ShippingZone.php | 30 +++ app/Models/Store.php | 147 +++++++++++++ app/Models/StoreDomain.php | 27 +++ app/Models/StoreSettings.php | 25 +++ app/Models/StoreUser.php | 33 +++ app/Models/TaxSettings.php | 32 +++ app/Models/Theme.php | 35 ++++ app/Models/ThemeFile.php | 26 +++ app/Models/ThemeSettings.php | 30 +++ app/Models/User.php | 62 +++++- app/Models/VariantOptionValue.php | 27 +++ app/Models/WebhookDelivery.php | 34 +++ app/Models/WebhookSubscription.php | 41 ++++ app/Observers/ProductObserver.php | 32 +++ app/Policies/CollectionPolicy.php | 18 ++ app/Policies/Concerns/ChecksStoreRoles.php | 43 ++++ app/Policies/CustomerPolicy.php | 16 ++ app/Policies/DiscountPolicy.php | 18 ++ app/Policies/FulfillmentPolicy.php | 16 ++ app/Policies/OrderPolicy.php | 19 ++ app/Policies/PagePolicy.php | 18 ++ app/Policies/ProductPolicy.php | 18 ++ app/Policies/RefundPolicy.php | 16 ++ app/Policies/StorePolicy.php | 17 ++ app/Policies/ThemePolicy.php | 18 ++ app/Providers/AppServiceProvider.php | 43 +++- app/Services/AnalyticsService.php | 70 +++++++ app/Services/CartService.php | 194 ++++++++++++++++++ app/Services/CheckoutService.php | 184 +++++++++++++++++ app/Services/CustomerService.php | 31 +++ app/Services/DiscountService.php | 127 ++++++++++++ app/Services/FulfillmentService.php | 111 ++++++++++ app/Services/InventoryService.php | 84 ++++++++ app/Services/NavigationService.php | 43 ++++ app/Services/OrderService.php | 188 +++++++++++++++++ app/Services/Payment/MockPaymentProvider.php | 6 + app/Services/PaymentService.php | 19 ++ app/Services/Payments/MockPaymentProvider.php | 45 ++++ app/Services/PricingEngine.php | 87 ++++++++ app/Services/ProductService.php | 151 ++++++++++++++ app/Services/RefundService.php | 66 ++++++ app/Services/SearchService.php | 108 ++++++++++ app/Services/ShippingCalculator.php | 114 ++++++++++ app/Services/Tax/ManualTaxProvider.php | 39 ++++ app/Services/Tax/StripeTaxProvider.php | 21 ++ app/Services/TaxCalculator.php | 45 ++++ app/Services/ThemeSettingsService.php | 25 +++ app/Services/VariantMatrixService.php | 93 +++++++++ app/Services/WebhookService.php | 48 +++++ app/Support/HandleGenerator.php | 37 ++++ app/ValueObjects/Address.php | 71 +++++++ app/ValueObjects/DiscountResult.php | 13 ++ app/ValueObjects/DiscountValidationResult.php | 15 ++ app/ValueObjects/PaymentResult.php | 16 ++ app/ValueObjects/PricingResult.php | 41 ++++ app/ValueObjects/RefundResult.php | 12 ++ app/ValueObjects/TaxCalculationRequest.php | 16 ++ app/ValueObjects/TaxCalculationResult.php | 20 ++ app/ValueObjects/TaxLine.php | 26 +++ app/ValueObjects/WebhookResult.php | 8 + bootstrap/app.php | 15 +- composer.json | 1 + composer.lock | 4 +- config/auth.php | 14 ++ config/database.php | 10 +- database/factories/AnalyticsEventFactory.php | 51 +++++ database/factories/CartFactory.php | 38 ++++ database/factories/CartLineFactory.php | 23 +++ database/factories/CheckoutFactory.php | 61 ++++++ database/factories/CollectionFactory.php | 39 ++++ database/factories/CustomerAddressFactory.php | 37 ++++ database/factories/CustomerFactory.php | 33 +++ database/factories/DiscountFactory.php | 63 ++++++ database/factories/FulfillmentFactory.php | 35 ++++ database/factories/FulfillmentLineFactory.php | 19 ++ database/factories/InventoryItemFactory.php | 36 ++++ database/factories/NavigationItemFactory.php | 36 ++++ database/factories/NavigationMenuFactory.php | 18 ++ database/factories/OrderFactory.php | 95 +++++++++ database/factories/OrderLineFactory.php | 27 +++ database/factories/OrganizationFactory.php | 16 ++ database/factories/PageFactory.php | 34 +++ database/factories/PaymentFactory.php | 53 +++++ database/factories/ProductFactory.php | 53 +++++ database/factories/ProductVariantFactory.php | 49 +++++ database/factories/RefundFactory.php | 32 +++ database/factories/ShippingRateFactory.php | 37 ++++ database/factories/ShippingZoneFactory.php | 19 ++ database/factories/StoreDomainFactory.php | 35 ++++ database/factories/StoreFactory.php | 27 +++ database/factories/ThemeFactory.php | 25 +++ database/factories/UserFactory.php | 11 +- .../0001_01_01_000000_create_users_table.php | 9 +- ..._add_two_factor_columns_to_users_table.php | 2 +- ..._01_01_000100_create_foundation_tables.php | 103 ++++++++++ ...01_01_000200_create_tenant_root_tables.php | 177 ++++++++++++++++ ...6_01_01_000300_create_dependent_tables.php | 185 +++++++++++++++++ ...01_01_000400_create_transaction_tables.php | 155 ++++++++++++++ ...1_01_000500_create_order_detail_tables.php | 74 +++++++ ...ate_refund_and_fulfillment_line_tables.php | 40 ++++ ...01_01_000700_create_products_fts_table.php | 27 +++ database/seeders/AnalyticsSeeder.php | 25 +++ database/seeders/CollectionSeeder.php | 31 +++ database/seeders/CustomerSeeder.php | 64 ++++++ database/seeders/DatabaseSeeder.php | 26 ++- database/seeders/DiscountSeeder.php | 28 +++ database/seeders/NavigationSeeder.php | 35 ++++ database/seeders/OrderSeeder.php | 106 ++++++++++ database/seeders/OrganizationSeeder.php | 14 ++ database/seeders/PageSeeder.php | 26 +++ database/seeders/ProductSeeder.php | 134 ++++++++++++ database/seeders/SearchSettingsSeeder.php | 21 ++ database/seeders/ShippingSeeder.php | 28 +++ database/seeders/StoreDomainSeeder.php | 29 +++ database/seeders/StoreSeeder.php | 28 +++ database/seeders/StoreSettingsSeeder.php | 21 ++ database/seeders/StoreUserSeeder.php | 30 +++ database/seeders/TaxSettingsSeeder.php | 18 ++ database/seeders/ThemeSeeder.php | 41 ++++ database/seeders/UserSeeder.php | 29 +++ routes/api.php | 91 ++++++++ specs/progress.md | 24 ++- 244 files changed, 8995 insertions(+), 35 deletions(-) create mode 100644 app/Auth/CustomerUserProvider.php create mode 100644 app/Contracts/PaymentProvider.php create mode 100644 app/Contracts/TaxProvider.php create mode 100644 app/Enums/AppInstallationStatus.php create mode 100644 app/Enums/AppStatus.php create mode 100644 app/Enums/CartStatus.php create mode 100644 app/Enums/CheckoutStatus.php create mode 100644 app/Enums/CollectionStatus.php create mode 100644 app/Enums/CollectionType.php create mode 100644 app/Enums/DiscountStatus.php create mode 100644 app/Enums/DiscountType.php create mode 100644 app/Enums/DiscountValueType.php create mode 100644 app/Enums/FinancialStatus.php create mode 100644 app/Enums/FulfillmentOrderStatus.php create mode 100644 app/Enums/FulfillmentShipmentStatus.php create mode 100644 app/Enums/FulfillmentStatus.php create mode 100644 app/Enums/InventoryPolicy.php create mode 100644 app/Enums/MediaStatus.php create mode 100644 app/Enums/MediaType.php create mode 100644 app/Enums/NavigationItemType.php create mode 100644 app/Enums/OrderStatus.php create mode 100644 app/Enums/PageStatus.php create mode 100644 app/Enums/PaymentMethod.php create mode 100644 app/Enums/PaymentStatus.php create mode 100644 app/Enums/ProductStatus.php create mode 100644 app/Enums/RefundStatus.php create mode 100644 app/Enums/ShippingRateType.php create mode 100644 app/Enums/StoreDomainType.php create mode 100644 app/Enums/StoreStatus.php create mode 100644 app/Enums/StoreUserRole.php create mode 100644 app/Enums/TaxMode.php create mode 100644 app/Enums/TaxProviderType.php create mode 100644 app/Enums/ThemeStatus.php create mode 100644 app/Enums/TlsMode.php create mode 100644 app/Enums/UserStatus.php create mode 100644 app/Enums/VariantStatus.php create mode 100644 app/Enums/WebhookDeliveryStatus.php create mode 100644 app/Enums/WebhookSubscriptionStatus.php create mode 100644 app/Events/CartUpdated.php create mode 100644 app/Events/CheckoutAddressed.php create mode 100644 app/Events/CheckoutCompleted.php create mode 100644 app/Events/CheckoutExpired.php create mode 100644 app/Events/CheckoutShippingSelected.php create mode 100644 app/Events/FulfillmentCreated.php create mode 100644 app/Events/FulfillmentDelivered.php create mode 100644 app/Events/FulfillmentShipped.php create mode 100644 app/Events/OrderCancelled.php create mode 100644 app/Events/OrderCreated.php create mode 100644 app/Events/OrderFulfilled.php create mode 100644 app/Events/OrderPaid.php create mode 100644 app/Events/OrderRefunded.php create mode 100644 app/Events/ProductCreated.php create mode 100644 app/Events/ProductDeleted.php create mode 100644 app/Events/ProductStatusChanged.php create mode 100644 app/Events/ProductUpdated.php create mode 100644 app/Exceptions/CartVersionMismatchException.php create mode 100644 app/Exceptions/DomainException.php create mode 100644 app/Exceptions/FulfillmentGuardException.php create mode 100644 app/Exceptions/InsufficientInventoryException.php create mode 100644 app/Exceptions/InvalidCheckoutTransitionException.php create mode 100644 app/Exceptions/InvalidDiscountException.php create mode 100644 app/Exceptions/InvalidProductTransitionException.php create mode 100644 app/Exceptions/PaymentFailedException.php create mode 100644 app/Exceptions/ShippingUnavailableException.php create mode 100644 app/Http/Controllers/Api/Admin/AnalyticsController.php create mode 100644 app/Http/Controllers/Api/Admin/CollectionController.php create mode 100644 app/Http/Controllers/Api/Admin/ContentController.php create mode 100644 app/Http/Controllers/Api/Admin/DiscountController.php create mode 100644 app/Http/Controllers/Api/Admin/OrderController.php create mode 100644 app/Http/Controllers/Api/Admin/PlatformController.php create mode 100644 app/Http/Controllers/Api/Admin/ProductController.php create mode 100644 app/Http/Controllers/Api/Admin/SettingsController.php create mode 100644 app/Http/Controllers/Api/Storefront/AnalyticsController.php create mode 100644 app/Http/Controllers/Api/Storefront/CartController.php create mode 100644 app/Http/Controllers/Api/Storefront/CheckoutController.php create mode 100644 app/Http/Controllers/Api/Storefront/OrderController.php create mode 100644 app/Http/Controllers/Api/Storefront/SearchController.php create mode 100644 app/Http/Middleware/CheckStoreRole.php create mode 100644 app/Http/Middleware/CustomerAuthenticate.php create mode 100644 app/Http/Middleware/ResolveStore.php create mode 100644 app/Jobs/AggregateAnalytics.php create mode 100644 app/Jobs/CancelUnpaidBankTransferOrders.php create mode 100644 app/Jobs/CleanupAbandonedCarts.php create mode 100644 app/Jobs/DeliverWebhook.php create mode 100644 app/Jobs/ExpireAbandonedCheckouts.php create mode 100644 app/Jobs/ProcessMediaUpload.php create mode 100644 app/Models/AnalyticsDaily.php create mode 100644 app/Models/AnalyticsEvent.php create mode 100644 app/Models/App.php create mode 100644 app/Models/AppInstallation.php create mode 100644 app/Models/Cart.php create mode 100644 app/Models/CartLine.php create mode 100644 app/Models/Checkout.php create mode 100644 app/Models/Collection.php create mode 100644 app/Models/CollectionProduct.php create mode 100644 app/Models/Concerns/BelongsToStore.php create mode 100644 app/Models/Customer.php create mode 100644 app/Models/CustomerAddress.php create mode 100644 app/Models/Discount.php create mode 100644 app/Models/Fulfillment.php create mode 100644 app/Models/FulfillmentLine.php create mode 100644 app/Models/InventoryItem.php create mode 100644 app/Models/NavigationItem.php create mode 100644 app/Models/NavigationMenu.php create mode 100644 app/Models/OauthClient.php create mode 100644 app/Models/OauthToken.php create mode 100644 app/Models/Order.php create mode 100644 app/Models/OrderLine.php create mode 100644 app/Models/Organization.php create mode 100644 app/Models/Page.php create mode 100644 app/Models/Payment.php create mode 100644 app/Models/Product.php create mode 100644 app/Models/ProductMedia.php create mode 100644 app/Models/ProductOption.php create mode 100644 app/Models/ProductOptionValue.php create mode 100644 app/Models/ProductVariant.php create mode 100644 app/Models/Refund.php create mode 100644 app/Models/Scopes/StoreScope.php create mode 100644 app/Models/SearchQuery.php create mode 100644 app/Models/SearchSettings.php create mode 100644 app/Models/ShippingRate.php create mode 100644 app/Models/ShippingZone.php create mode 100644 app/Models/Store.php create mode 100644 app/Models/StoreDomain.php create mode 100644 app/Models/StoreSettings.php create mode 100644 app/Models/StoreUser.php create mode 100644 app/Models/TaxSettings.php create mode 100644 app/Models/Theme.php create mode 100644 app/Models/ThemeFile.php create mode 100644 app/Models/ThemeSettings.php create mode 100644 app/Models/VariantOptionValue.php create mode 100644 app/Models/WebhookDelivery.php create mode 100644 app/Models/WebhookSubscription.php create mode 100644 app/Observers/ProductObserver.php create mode 100644 app/Policies/CollectionPolicy.php create mode 100644 app/Policies/Concerns/ChecksStoreRoles.php create mode 100644 app/Policies/CustomerPolicy.php create mode 100644 app/Policies/DiscountPolicy.php create mode 100644 app/Policies/FulfillmentPolicy.php create mode 100644 app/Policies/OrderPolicy.php create mode 100644 app/Policies/PagePolicy.php create mode 100644 app/Policies/ProductPolicy.php create mode 100644 app/Policies/RefundPolicy.php create mode 100644 app/Policies/StorePolicy.php create mode 100644 app/Policies/ThemePolicy.php create mode 100644 app/Services/AnalyticsService.php create mode 100644 app/Services/CartService.php create mode 100644 app/Services/CheckoutService.php create mode 100644 app/Services/CustomerService.php create mode 100644 app/Services/DiscountService.php create mode 100644 app/Services/FulfillmentService.php create mode 100644 app/Services/InventoryService.php create mode 100644 app/Services/NavigationService.php create mode 100644 app/Services/OrderService.php create mode 100644 app/Services/Payment/MockPaymentProvider.php create mode 100644 app/Services/PaymentService.php create mode 100644 app/Services/Payments/MockPaymentProvider.php create mode 100644 app/Services/PricingEngine.php create mode 100644 app/Services/ProductService.php create mode 100644 app/Services/RefundService.php create mode 100644 app/Services/SearchService.php create mode 100644 app/Services/ShippingCalculator.php create mode 100644 app/Services/Tax/ManualTaxProvider.php create mode 100644 app/Services/Tax/StripeTaxProvider.php create mode 100644 app/Services/TaxCalculator.php create mode 100644 app/Services/ThemeSettingsService.php create mode 100644 app/Services/VariantMatrixService.php create mode 100644 app/Services/WebhookService.php create mode 100644 app/Support/HandleGenerator.php create mode 100644 app/ValueObjects/Address.php create mode 100644 app/ValueObjects/DiscountResult.php create mode 100644 app/ValueObjects/DiscountValidationResult.php create mode 100644 app/ValueObjects/PaymentResult.php create mode 100644 app/ValueObjects/PricingResult.php create mode 100644 app/ValueObjects/RefundResult.php create mode 100644 app/ValueObjects/TaxCalculationRequest.php create mode 100644 app/ValueObjects/TaxCalculationResult.php create mode 100644 app/ValueObjects/TaxLine.php create mode 100644 app/ValueObjects/WebhookResult.php create mode 100644 database/factories/AnalyticsEventFactory.php create mode 100644 database/factories/CartFactory.php create mode 100644 database/factories/CartLineFactory.php create mode 100644 database/factories/CheckoutFactory.php create mode 100644 database/factories/CollectionFactory.php create mode 100644 database/factories/CustomerAddressFactory.php create mode 100644 database/factories/CustomerFactory.php create mode 100644 database/factories/DiscountFactory.php create mode 100644 database/factories/FulfillmentFactory.php create mode 100644 database/factories/FulfillmentLineFactory.php create mode 100644 database/factories/InventoryItemFactory.php create mode 100644 database/factories/NavigationItemFactory.php create mode 100644 database/factories/NavigationMenuFactory.php create mode 100644 database/factories/OrderFactory.php create mode 100644 database/factories/OrderLineFactory.php create mode 100644 database/factories/OrganizationFactory.php create mode 100644 database/factories/PageFactory.php create mode 100644 database/factories/PaymentFactory.php create mode 100644 database/factories/ProductFactory.php create mode 100644 database/factories/ProductVariantFactory.php create mode 100644 database/factories/RefundFactory.php create mode 100644 database/factories/ShippingRateFactory.php create mode 100644 database/factories/ShippingZoneFactory.php create mode 100644 database/factories/StoreDomainFactory.php create mode 100644 database/factories/StoreFactory.php create mode 100644 database/factories/ThemeFactory.php create mode 100644 database/migrations/2026_01_01_000100_create_foundation_tables.php create mode 100644 database/migrations/2026_01_01_000200_create_tenant_root_tables.php create mode 100644 database/migrations/2026_01_01_000300_create_dependent_tables.php create mode 100644 database/migrations/2026_01_01_000400_create_transaction_tables.php create mode 100644 database/migrations/2026_01_01_000500_create_order_detail_tables.php create mode 100644 database/migrations/2026_01_01_000600_create_refund_and_fulfillment_line_tables.php create mode 100644 database/migrations/2026_01_01_000700_create_products_fts_table.php create mode 100644 database/seeders/AnalyticsSeeder.php create mode 100644 database/seeders/CollectionSeeder.php create mode 100644 database/seeders/CustomerSeeder.php create mode 100644 database/seeders/DiscountSeeder.php create mode 100644 database/seeders/NavigationSeeder.php create mode 100644 database/seeders/OrderSeeder.php create mode 100644 database/seeders/OrganizationSeeder.php create mode 100644 database/seeders/PageSeeder.php create mode 100644 database/seeders/ProductSeeder.php create mode 100644 database/seeders/SearchSettingsSeeder.php create mode 100644 database/seeders/ShippingSeeder.php create mode 100644 database/seeders/StoreDomainSeeder.php create mode 100644 database/seeders/StoreSeeder.php create mode 100644 database/seeders/StoreSettingsSeeder.php create mode 100644 database/seeders/StoreUserSeeder.php create mode 100644 database/seeders/TaxSettingsSeeder.php create mode 100644 database/seeders/ThemeSeeder.php create mode 100644 database/seeders/UserSeeder.php create mode 100644 routes/api.php diff --git a/.env.example b/.env.example index c0660ea1..e2f54286 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ -APP_NAME=Laravel +APP_NAME=Shop APP_ENV=local APP_KEY= APP_DEBUG=true -APP_URL=http://localhost +APP_URL=http://shop.test APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -27,17 +27,17 @@ DB_CONNECTION=sqlite # DB_USERNAME=root # DB_PASSWORD= -SESSION_DRIVER=database +SESSION_DRIVER=file SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +FILESYSTEM_DISK=public +QUEUE_CONNECTION=sync -CACHE_STORE=database +CACHE_STORE=file # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..8218bcdf --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,33 @@ + $credentials */ + public function retrieveByCredentials(array $credentials): ?Authenticatable + { + if (empty($credentials) || (count($credentials) === 1 && array_key_exists('password', $credentials))) { + return null; + } + + $query = Customer::withoutGlobalScopes(); + if (app()->bound('current_store')) { + $query->where('store_id', app('current_store')->id); + } else { + return null; + } + + foreach ($credentials as $key => $value) { + if (! str_contains($key, 'password')) { + $query->where($key, $value); + } + } + + return $query->first(); + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..dbb6c980 --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,17 @@ + $details */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details = []): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Contracts/TaxProvider.php b/app/Contracts/TaxProvider.php new file mode 100644 index 00000000..0ea95c5c --- /dev/null +++ b/app/Contracts/TaxProvider.php @@ -0,0 +1,11 @@ +validate(['from' => ['required', 'date'], 'to' => ['required', 'date', 'after_or_equal:from'], 'granularity' => ['sometimes', 'in:day,week,month']]); + $daily = $this->analytics->getDailyMetrics(app('current_store'), $data['from'], $data['to']); + + return response()->json(['data' => [ + 'revenue_amount' => (int) $daily->sum('revenue_amount'), + 'orders_count' => (int) $daily->sum('orders_count'), + 'visits_count' => (int) $daily->sum('visits_count'), + 'series' => $daily, + ]]); + } + + public function exportOrders(int $storeId): StreamedResponse + { + return response()->streamDownload(function () use ($storeId): void { + $out = fopen('php://output', 'wb'); + fputcsv($out, ['order_number', 'created_at', 'status', 'financial_status', 'fulfillment_status', 'customer_email', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'currency', 'tracking_number']); + Order::withoutGlobalScopes()->where('store_id', $storeId)->with('fulfillments')->orderBy('id')->each(function (Order $order) use ($out): void { + fputcsv($out, [$order->order_number, $order->created_at?->toIso8601String(), $order->status, $order->financial_status, $order->fulfillment_status, $order->email, $order->subtotal_amount, $order->discount_amount, $order->shipping_amount, $order->tax_amount, $order->total_amount, $order->currency, $order->fulfillments->first()?->tracking_number]); + }); + fclose($out); + }, 'orders-'.now()->toDateString().'.csv', ['Content-Type' => 'text/csv']); + } +} diff --git a/app/Http/Controllers/Api/Admin/CollectionController.php b/app/Http/Controllers/Api/Admin/CollectionController.php new file mode 100644 index 00000000..d3155bed --- /dev/null +++ b/app/Http/Controllers/Api/Admin/CollectionController.php @@ -0,0 +1,83 @@ +validate(['status' => ['sometimes', 'in:draft,active,archived'], 'query' => ['sometimes', 'string'], 'per_page' => ['sometimes', 'integer', 'max:100']]); + $items = Collection::withoutGlobalScopes()->where('store_id', $storeId) + ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status'])) + ->when(isset($data['query']), fn ($q) => $q->where('title', 'like', '%'.$data['query'].'%')) + ->withCount('products')->paginate($data['per_page'] ?? 25); + + return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total(), 'current_page' => $items->currentPage()]]); + } + + public function store(Request $request, int $storeId): JsonResponse + { + $data = $this->validateData($request, $storeId); + $collection = Collection::withoutGlobalScopes()->create([ + ...$data, + 'store_id' => $storeId, + 'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'collections', $storeId), + ]); + $collection->products()->sync($this->positions($data['product_ids'] ?? [])); + + return response()->json(['data' => $collection->load('products')], 201); + } + + public function update(Request $request, int $storeId, int $collectionId): JsonResponse + { + $collection = $this->find($storeId, $collectionId); + $data = $this->validateData($request, $storeId, $collectionId, true); + $collection->update(collect($data)->except('product_ids')->all()); + if (array_key_exists('product_ids', $data)) { + $collection->products()->sync($this->positions($data['product_ids'])); + } + + return response()->json(['data' => $collection->refresh()->load('products')]); + } + + public function destroy(int $storeId, int $collectionId): JsonResponse + { + $this->find($storeId, $collectionId)->delete(); + + return response()->json(['message' => 'Collection deleted.']); + } + + /** @return array */ + private function validateData(Request $request, int $storeId, ?int $ignore = null, bool $partial = false): array + { + return $request->validate([ + 'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'string', Rule::unique('collections')->where('store_id', $storeId)->ignore($ignore)], + 'description_html' => ['nullable', 'string'], + 'type' => ['sometimes', 'in:manual,automated'], + 'status' => ['sometimes', 'in:draft,active,archived'], + 'product_ids' => ['sometimes', 'array'], + 'product_ids.*' => ['integer'], + ]); + } + + /** @param list $ids @return array */ + private function positions(array $ids): array + { + return collect(array_values($ids))->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]])->all(); + } + + private function find(int $storeId, int $id): Collection + { + return Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id); + } +} diff --git a/app/Http/Controllers/Api/Admin/ContentController.php b/app/Http/Controllers/Api/Admin/ContentController.php new file mode 100644 index 00000000..cd12fc75 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ContentController.php @@ -0,0 +1,110 @@ +validate(['status' => ['sometimes', 'in:draft,published,archived'], 'per_page' => ['sometimes', 'integer', 'max:100']]); + $pages = Page::withoutGlobalScopes()->where('store_id', $storeId)->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))->paginate($data['per_page'] ?? 25); + + return response()->json(['data' => $pages->items(), 'meta' => ['total' => $pages->total()]]); + } + + public function storePage(Request $request, int $storeId): JsonResponse + { + $data = $this->pageData($request, $storeId); + $page = Page::withoutGlobalScopes()->create(['store_id' => $storeId, ...$data, 'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'pages', $storeId)]); + + return response()->json(['data' => $page], 201); + } + + public function updatePage(Request $request, int $storeId, int $pageId): JsonResponse + { + $page = $this->page($storeId, $pageId); + $page->update($this->pageData($request, $storeId, $pageId, true)); + + return response()->json(['data' => $page->refresh()]); + } + + public function destroyPage(int $storeId, int $pageId): JsonResponse + { + $this->page($storeId, $pageId)->delete(); + + return response()->json(['message' => 'Page deleted.']); + } + + public function storeTheme(Request $request, int $storeId): JsonResponse + { + $data = $request->validate(['name' => ['required_without:file', 'nullable', 'string'], 'file' => ['sometimes', 'file', 'mimes:zip', 'max:51200']]); + $theme = Theme::withoutGlobalScopes()->create(['store_id' => $storeId, 'name' => $data['name'] ?? pathinfo($data['file']->getClientOriginalName(), PATHINFO_FILENAME), 'version' => '1.0.0', 'status' => 'draft']); + if (isset($data['file'])) { + $path = $data['file']->store("themes/{$storeId}/{$theme->id}", 'local'); + $theme->files()->create(['path' => 'theme.zip', 'storage_key' => $path, 'sha256' => hash_file('sha256', $data['file']->getRealPath()), 'byte_size' => $data['file']->getSize()]); + } + + return response()->json(['data' => $theme->load('files')], 201); + } + + public function publishTheme(int $storeId, int $themeId): JsonResponse + { + $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); + DB::transaction(function () use ($storeId, $theme): void { + Theme::withoutGlobalScopes()->where('store_id', $storeId)->whereKeyNot($theme->id)->update(['status' => 'draft', 'published_at' => null]); + $theme->update(['status' => 'published', 'published_at' => now()]); + }); + + return response()->json(['data' => $theme->refresh()]); + } + + public function updateThemeSettings(Request $request, int $storeId, int $themeId): JsonResponse + { + $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); + $data = $request->validate(['settings' => ['required', 'array']]); + $settings = $theme->settings()->updateOrCreate(['theme_id' => $theme->id], ['settings_json' => $data['settings']]); + + return response()->json(['data' => $settings]); + } + + public function reindex(int $storeId): JsonResponse + { + \App\Models\Product::withoutGlobalScopes()->where('store_id', $storeId)->each(fn ($product) => $this->search->syncProduct($product)); + + return response()->json(['message' => 'Search index rebuilt.'], 202); + } + + public function searchStatus(int $storeId): JsonResponse + { + return response()->json(['data' => ['status' => 'ready', 'products_indexed' => \App\Models\Product::withoutGlobalScopes()->where('store_id', $storeId)->count(), 'last_indexed_at' => now()]]); + } + + /** @return array */ + private function pageData(Request $request, int $storeId, ?int $ignore = null, bool $partial = false): array + { + return $request->validate([ + 'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'string', Rule::unique('pages')->where('store_id', $storeId)->ignore($ignore)], + 'body_html' => ['nullable', 'string'], + 'status' => ['sometimes', 'in:draft,published,archived'], + 'published_at' => ['nullable', 'date'], + ]); + } + + private function page(int $storeId, int $id): Page + { + return Page::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id); + } +} diff --git a/app/Http/Controllers/Api/Admin/DiscountController.php b/app/Http/Controllers/Api/Admin/DiscountController.php new file mode 100644 index 00000000..9b2465e9 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DiscountController.php @@ -0,0 +1,66 @@ +validate(['type' => ['sometimes', 'in:code,automatic'], 'status' => ['sometimes', 'in:draft,active,expired,disabled'], 'per_page' => ['sometimes', 'integer', 'max:100']]); + $items = Discount::withoutGlobalScopes()->where('store_id', $storeId) + ->when(isset($data['type']), fn ($q) => $q->where('type', $data['type'])) + ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status'])) + ->latest()->paginate($data['per_page'] ?? 25); + + return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total()]]); + } + + public function store(Request $request, int $storeId): JsonResponse + { + $discount = Discount::withoutGlobalScopes()->create(['store_id' => $storeId, ...$this->data($request, $storeId)]); + + return response()->json(['data' => $discount], 201); + } + + public function update(Request $request, int $storeId, int $discountId): JsonResponse + { + $discount = $this->find($storeId, $discountId); + $discount->update($this->data($request, $storeId, $discountId, true)); + + return response()->json(['data' => $discount->refresh()]); + } + + public function destroy(int $storeId, int $discountId): JsonResponse + { + $this->find($storeId, $discountId)->delete(); + + return response()->json(['message' => 'Discount deleted.']); + } + + /** @return array */ + private function data(Request $request, int $storeId, ?int $ignore = null, bool $partial = false): array + { + return $request->validate([ + 'type' => [$partial ? 'sometimes' : 'required', 'in:code,automatic'], + 'code' => ['nullable', 'string', Rule::unique('discounts')->where('store_id', $storeId)->ignore($ignore)], + 'value_type' => [$partial ? 'sometimes' : 'required', 'in:fixed,percent,free_shipping'], + 'value_amount' => [$partial ? 'sometimes' : 'required', 'integer', 'min:0'], + 'starts_at' => [$partial ? 'sometimes' : 'required', 'date'], + 'ends_at' => ['nullable', 'date', 'after:starts_at'], + 'usage_limit' => ['nullable', 'integer', 'min:1'], + 'rules_json' => ['sometimes', 'array'], + 'status' => ['sometimes', 'in:draft,active,expired,disabled'], + ]); + } + + private function find(int $storeId, int $id): Discount + { + return Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id); + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php new file mode 100644 index 00000000..77c56d06 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -0,0 +1,81 @@ +validate([ + 'status' => ['sometimes', 'in:pending,paid,fulfilled,cancelled,refunded'], + 'financial_status' => ['sometimes', 'in:pending,authorized,paid,partially_refunded,refunded,voided'], + 'fulfillment_status' => ['sometimes', 'in:unfulfilled,partial,fulfilled'], + 'query' => ['sometimes', 'string'], + 'per_page' => ['sometimes', 'integer', 'max:100'], + ]); + $orders = Order::withoutGlobalScopes()->where('store_id', $storeId) + ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status'])) + ->when(isset($data['financial_status']), fn ($q) => $q->where('financial_status', $data['financial_status'])) + ->when(isset($data['fulfillment_status']), fn ($q) => $q->where('fulfillment_status', $data['fulfillment_status'])) + ->when(isset($data['query']), fn ($q) => $q->where(fn ($nested) => $nested->where('order_number', 'like', '%'.$data['query'].'%')->orWhere('email', 'like', '%'.$data['query'].'%'))) + ->with('customer')->latest('placed_at')->paginate($data['per_page'] ?? 25); + + return response()->json(['data' => $orders->items(), 'meta' => ['total' => $orders->total(), 'current_page' => $orders->currentPage()]]); + } + + public function show(int $storeId, int $orderId): JsonResponse + { + return response()->json(['data' => $this->find($storeId, $orderId)->load(['lines', 'payments', 'refunds', 'fulfillments.lines', 'customer'])]); + } + + public function fulfill(Request $request, int $storeId, int $orderId): JsonResponse + { + $data = $request->validate([ + 'lines' => ['required', 'array', 'min:1'], + 'lines.*' => ['required', 'integer', 'min:1'], + 'tracking_company' => ['nullable', 'string'], + 'tracking_number' => ['nullable', 'string'], + 'tracking_url' => ['nullable', 'url'], + ]); + $tracking = collect($data)->only(['tracking_company', 'tracking_number', 'tracking_url'])->all(); + $fulfillment = $this->fulfillments->create($this->find($storeId, $orderId), $data['lines'], $tracking); + + return response()->json(['data' => $fulfillment], 201); + } + + public function refund(Request $request, int $storeId, int $orderId): JsonResponse + { + $data = $request->validate(['amount' => ['required', 'integer', 'min:1'], 'reason' => ['nullable', 'string'], 'restock' => ['sometimes', 'boolean'], 'lines' => ['sometimes', 'array']]); + $order = $this->find($storeId, $orderId)->load('payments'); + $refund = $this->refunds->create($order, $order->payments->firstOrFail(), $data['amount'], $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? null); + + return response()->json(['data' => $refund], 201); + } + + public function confirmPayment(int $storeId, int $orderId): JsonResponse + { + $order = $this->find($storeId, $orderId); + $this->orders->confirmBankTransfer($order); + + return response()->json(['data' => $order->refresh()]); + } + + private function find(int $storeId, int $id): Order + { + return Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id); + } +} diff --git a/app/Http/Controllers/Api/Admin/PlatformController.php b/app/Http/Controllers/Api/Admin/PlatformController.php new file mode 100644 index 00000000..0a1ad972 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/PlatformController.php @@ -0,0 +1,56 @@ +validate(['name' => ['required', 'string', 'max:255'], 'billing_email' => ['required', 'email', 'max:255']]); + + return response()->json(['data' => Organization::query()->create($data)], 201); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'organization_id' => ['required', 'exists:organizations,id'], 'name' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'regex:/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/', 'max:63', 'unique:stores,handle'], + 'default_currency' => ['required', 'string', 'size:3'], 'default_locale' => ['required', 'string', 'max:10'], 'timezone' => ['required', 'timezone'], + ]); + $store = Store::query()->create([...$data, 'status' => 'active']); + StoreSettings::query()->create(['store_id' => $store->id, 'settings_json' => []]); + + return response()->json(['data' => $store], 201); + } + + public function invite(Request $request, int $storeId): JsonResponse + { + $data = $request->validate(['email' => ['required', 'email'], 'role' => ['required', 'in:owner,admin,staff,support']]); + $user = User::query()->firstOrCreate(['email' => mb_strtolower($data['email'])], ['name' => str($data['email'])->before('@')->headline(), 'password_hash' => Hash::make(str()->password()), 'status' => 'active']); + if ($user->stores()->whereKey($storeId)->exists()) { + return response()->json(['message' => 'User is already a member.'], 409); + } + DB::table('store_users')->insert(['store_id' => $storeId, 'user_id' => $user->id, 'role' => $data['role'], 'created_at' => now()]); + + return response()->json(['data' => ['email' => $user->email, 'role' => $data['role'], 'invited_at' => now(), 'expires_at' => now()->addWeek()]], 201); + } + + public function me(Request $request, int $storeId): JsonResponse + { + $role = $request->user()->roleForStore(app('current_store')); + + return response()->json(['data' => ['user_id' => $request->user()->id, 'store_id' => $storeId, 'role' => $role, 'email' => $request->user()->email, 'name' => $request->user()->name, 'permissions' => $request->user()->currentAccessToken()?->abilities ?? []]]); + } +} diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php new file mode 100644 index 00000000..92b071d1 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -0,0 +1,99 @@ +validate([ + 'status' => ['sometimes', 'in:draft,active,archived'], + 'query' => ['sometimes', 'string', 'max:200'], + 'collection_id' => ['sometimes', 'integer'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + 'sort' => ['sometimes', 'in:title_asc,title_desc,created_at_asc,created_at_desc,updated_at_desc'], + ]); + [$sort, $direction] = match ($validated['sort'] ?? 'updated_at_desc') { + 'title_asc' => ['title', 'asc'], 'title_desc' => ['title', 'desc'], + 'created_at_asc' => ['created_at', 'asc'], 'created_at_desc' => ['created_at', 'desc'], + default => ['updated_at', 'desc'], + }; + $products = Product::withoutGlobalScopes()->where('store_id', $storeId) + ->when(isset($validated['status']), fn ($query) => $query->where('status', $validated['status'])) + ->when(isset($validated['query']), fn ($query) => $query->where(function ($nested) use ($validated): void { + $term = $validated['query']; + $nested->where('title', 'like', "%{$term}%")->orWhere('vendor', 'like', "%{$term}%") + ->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', "%{$term}%")); + })) + ->when(isset($validated['collection_id']), fn ($query) => $query->whereHas('collections', fn ($collections) => $collections->whereKey($validated['collection_id']))) + ->with(['variants.inventoryItem', 'media', 'collections'])->orderBy($sort, $direction)->paginate($validated['per_page'] ?? 25); + + return response()->json(['data' => $products->items(), 'meta' => ['current_page' => $products->currentPage(), 'last_page' => $products->lastPage(), 'total' => $products->total()]]); + } + + public function store(Request $request, int $storeId): JsonResponse + { + $data = $this->validated($request, $storeId); + $product = $this->products->create(app('current_store'), $data); + + return response()->json(['data' => $product], 201); + } + + public function show(int $storeId, int $productId): JsonResponse + { + return response()->json(['data' => $this->product($storeId, $productId)->load(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'media', 'collections'])]); + } + + public function update(Request $request, int $storeId, int $productId): JsonResponse + { + $data = $this->validated($request, $storeId, true); + + return response()->json(['data' => $this->products->update($this->product($storeId, $productId), $data)]); + } + + public function destroy(int $storeId, int $productId): JsonResponse + { + $product = $this->product($storeId, $productId); + $this->products->transitionStatus($product, ProductStatus::Archived); + + return response()->json(['message' => 'Product archived.', 'data' => $product->refresh()]); + } + + /** @return array */ + private function validated(Request $request, int $storeId, bool $partial = false): array + { + $sometimes = $partial ? ['sometimes'] : ['required']; + + return $request->validate([ + 'title' => [...$sometimes, 'string', 'max:255'], + 'handle' => ['sometimes', 'string', 'max:255', Rule::unique('products')->where('store_id', $storeId)->ignore($request->route('productId'))], + 'description_html' => ['nullable', 'string'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'tags' => ['sometimes', 'array'], + 'status' => ['sometimes', 'in:draft,active,archived'], + 'options' => ['sometimes', 'array', 'max:3'], + 'options.*.name' => ['required_with:options', 'string', 'max:100'], + 'options.*.values' => ['required_with:options', 'array', 'min:1'], + 'variant' => ['sometimes', 'array'], + 'variant.price_amount' => ['sometimes', 'integer', 'min:0'], + 'variant.sku' => ['nullable', 'string', 'max:100'], + 'variant.quantity_on_hand' => ['sometimes', 'integer'], + ]); + } + + private function product(int $storeId, int $productId): Product + { + return Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId); + } +} diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php new file mode 100644 index 00000000..13e6f3fa --- /dev/null +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -0,0 +1,59 @@ +json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]); + } + + public function storeZone(Request $request, int $storeId): JsonResponse + { + $data = $request->validate(['name' => ['required', 'string'], 'countries_json' => ['required', 'array'], 'regions_json' => ['sometimes', 'array']]); + $zone = ShippingZone::withoutGlobalScopes()->create(['store_id' => $storeId, ...$data, 'regions_json' => $data['regions_json'] ?? []]); + + return response()->json(['data' => $zone], 201); + } + + public function updateZone(Request $request, int $storeId, int $zoneId): JsonResponse + { + $zone = $this->zone($storeId, $zoneId); + $zone->update($request->validate(['name' => ['sometimes', 'string'], 'countries_json' => ['sometimes', 'array'], 'regions_json' => ['sometimes', 'array']])); + + return response()->json(['data' => $zone->refresh()]); + } + + public function storeRate(Request $request, int $storeId, int $zoneId): JsonResponse + { + $data = $request->validate(['name' => ['required', 'string'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'config_json' => ['required', 'array'], 'is_active' => ['sometimes', 'boolean']]); + $rate = $this->zone($storeId, $zoneId)->rates()->create($data); + + return response()->json(['data' => $rate], 201); + } + + public function tax(int $storeId): JsonResponse + { + return response()->json(['data' => TaxSettings::withoutGlobalScopes()->where('store_id', $storeId)->firstOrFail()]); + } + + public function updateTax(Request $request, int $storeId): JsonResponse + { + $data = $request->validate(['mode' => ['required', 'in:manual,provider'], 'provider' => ['required', 'in:none,stripe_tax'], 'prices_include_tax' => ['required', 'boolean'], 'config_json' => ['required', 'array']]); + $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data); + + return response()->json(['data' => $settings]); + } + + private function zone(int $storeId, int $id): ShippingZone + { + return ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id); + } +} diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsController.php b/app/Http/Controllers/Api/Storefront/AnalyticsController.php new file mode 100644 index 00000000..b6bb6d7d --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/AnalyticsController.php @@ -0,0 +1,39 @@ +validate([ + 'events' => ['required', 'array', 'max:100'], + 'events.*.type' => ['required', 'in:page_view,product_view,add_to_cart,remove_from_cart,checkout_started,checkout_completed,search'], + 'events.*.session_id' => ['required', 'string', 'max:255'], + 'events.*.client_event_id' => ['required', 'string', 'max:255'], + 'events.*.occurred_at' => ['required', 'date'], + 'events.*.properties' => ['sometimes', 'array'], + ]); + foreach ($validated['events'] as $event) { + $this->analytics->track( + app('current_store'), + $event['type'], + $event['properties'] ?? [], + $event['session_id'], + auth('customer')->id(), + $event['client_event_id'], + Carbon::parse($event['occurred_at']), + ); + } + + return response()->json(['accepted' => count($validated['events'])], 202); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php new file mode 100644 index 00000000..432e97ee --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -0,0 +1,106 @@ +user(); + $cart = $this->carts->create($store, $customer); + $request->session()->put('cart_id', $cart->id); + + return response()->json(['data' => $this->data($cart)], 201); + } + + public function show(int $cartId): JsonResponse + { + return response()->json(['data' => $this->data($this->cart($cartId))]); + } + + public function addLine(Request $request, int $cartId): JsonResponse + { + $validated = $request->validate([ + 'variant_id' => ['required', 'integer'], + 'quantity' => ['required', 'integer', 'min:1', 'max:99'], + 'expected_version' => ['sometimes', 'integer', 'min:1'], + ]); + + try { + $cart = $this->cart($cartId); + $this->carts->addLine($cart, $validated['variant_id'], $validated['quantity'], $validated['expected_version'] ?? null); + + return response()->json(['data' => $this->data($cart->refresh())], 201); + } catch (CartVersionMismatchException $exception) { + return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); + } + } + + public function updateLine(Request $request, int $cartId, int $lineId): JsonResponse + { + $validated = $request->validate([ + 'quantity' => ['required', 'integer', 'min:0', 'max:99'], + 'expected_version' => ['sometimes', 'integer', 'min:1'], + ]); + try { + $cart = $this->cart($cartId); + $this->carts->updateLineQuantity($cart, $lineId, $validated['quantity'], $validated['expected_version'] ?? null); + + return response()->json(['data' => $this->data($cart->refresh())]); + } catch (CartVersionMismatchException $exception) { + return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); + } + } + + public function removeLine(Request $request, int $cartId, int $lineId): JsonResponse + { + $validated = $request->validate(['expected_version' => ['sometimes', 'integer', 'min:1']]); + try { + $cart = $this->cart($cartId); + $this->carts->removeLine($cart, $lineId, $validated['expected_version'] ?? null); + + return response()->json(['data' => $this->data($cart->refresh())]); + } catch (CartVersionMismatchException $exception) { + return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); + } + } + + private function cart(int $id): Cart + { + return Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines.variant.product')->findOrFail($id); + } + + /** @return array */ + private function data(Cart $cart): array + { + $cart->loadMissing('lines.variant.product'); + + return [ + 'id' => $cart->id, + 'currency' => $cart->currency, + 'status' => $cart->status, + 'cart_version' => $cart->cart_version, + 'subtotal_amount' => (int) $cart->lines->sum('line_subtotal_amount'), + 'lines' => $cart->lines->map(fn ($line): array => [ + 'id' => $line->id, + 'variant_id' => $line->variant_id, + 'title' => $line->variant?->product?->title, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_subtotal_amount' => $line->line_subtotal_amount, + 'line_discount_amount' => $line->line_discount_amount, + 'line_total_amount' => $line->line_total_amount, + ])->values(), + ]; + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 00000000..8b2aef0c --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,126 @@ +validate(['cart_id' => ['required', 'integer']]); + $cart = Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines')->findOrFail($validated['cart_id']); + $checkout = $this->checkouts->create($cart); + + return response()->json(['data' => $this->data($checkout)], 201); + } + + public function show(int $checkoutId): JsonResponse + { + return response()->json(['data' => $this->data($this->checkout($checkoutId))]); + } + + public function address(Request $request, int $checkoutId): JsonResponse + { + $validated = $request->validate([ + 'email' => ['required', 'email'], + 'shipping_address' => ['required', 'array'], + 'shipping_address.first_name' => ['required', 'string'], + 'shipping_address.last_name' => ['required', 'string'], + 'shipping_address.address1' => ['required', 'string'], + 'shipping_address.city' => ['required', 'string'], + 'shipping_address.country' => ['required_without:shipping_address.country_code', 'nullable', 'string', 'size:2'], + 'shipping_address.country_code' => ['required_without:shipping_address.country', 'nullable', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required_without:shipping_address.zip', 'nullable', 'string'], + 'shipping_address.zip' => ['required_without:shipping_address.postal_code', 'nullable', 'string'], + 'billing_address' => ['sometimes', 'array'], + ]); + $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $validated); + $rates = $this->shipping->getAvailableRates(app('current_store'), (array) $checkout->shipping_address_json, $checkout->cart); + + return response()->json(['data' => $this->data($checkout), 'available_shipping_rates' => $rates]); + } + + public function shipping(Request $request, int $checkoutId): JsonResponse + { + $validated = $request->validate(['shipping_rate_id' => ['nullable', 'integer']]); + $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $validated['shipping_rate_id'] ?? null); + + return response()->json(['data' => $this->data($checkout)]); + } + + public function payment(Request $request, int $checkoutId): JsonResponse + { + $validated = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]); + $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $validated['payment_method']); + + return response()->json(['data' => $this->data($checkout)]); + } + + public function discount(Request $request, int $checkoutId): JsonResponse + { + $validated = $request->validate(['code' => ['required', 'string', 'max:100']]); + $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), $validated['code']); + + return response()->json(['data' => $this->data($checkout)]); + } + + public function removeDiscount(int $checkoutId): JsonResponse + { + $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), null); + + return response()->json(['data' => $this->data($checkout)]); + } + + public function pay(Request $request, int $checkoutId): JsonResponse + { + $details = $request->validate([ + 'card_number' => ['sometimes', 'string', 'max:30'], + 'expiry' => ['sometimes', 'string', 'max:10'], + 'cvc' => ['sometimes', 'string', 'max:4'], + 'cardholder_name' => ['sometimes', 'string', 'max:255'], + ]); + try { + $order = $this->checkouts->completeCheckout($this->checkout($checkoutId), $details); + + return response()->json(['data' => ['checkout' => $this->data($this->checkout($checkoutId)), 'order' => $order->load(['lines', 'payments'])]]); + } catch (PaymentFailedException $exception) { + return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422); + } + } + + private function checkout(int $id): Checkout + { + return Checkout::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with(['cart.lines.variant', 'store'])->findOrFail($id); + } + + /** @return array */ + private function data(Checkout $checkout): array + { + return [ + 'id' => $checkout->id, + 'cart_id' => $checkout->cart_id, + 'status' => $checkout->status, + 'email' => $checkout->email, + 'shipping_address' => $checkout->shipping_address_json, + 'billing_address' => $checkout->billing_address_json, + 'shipping_method_id' => $checkout->shipping_method_id, + 'payment_method' => $checkout->payment_method, + 'discount_code' => $checkout->discount_code, + 'totals' => $checkout->totals_json, + 'expires_at' => $checkout->expires_at, + ]; + } +} diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php new file mode 100644 index 00000000..7ddea724 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/OrderController.php @@ -0,0 +1,35 @@ +where('store_id', app('current_store')->id)->where('order_number', $orderNumber)->with(['lines', 'fulfillments'])->firstOrFail(); + $expected = hash_hmac('sha256', $order->order_number.'|'.$order->email, (string) config('app.key')); + abort_unless(is_string($request->query('token')) && hash_equals($expected, $request->query('token')), 401); + + return response()->json([ + 'order_number' => $order->order_number, + 'status' => $order->status, + 'financial_status' => $order->financial_status, + 'fulfillment_status' => $order->fulfillment_status, + 'email' => $order->email, + 'currency' => $order->currency, + 'placed_at' => $order->placed_at, + 'lines' => $order->lines, + 'totals' => [ + 'subtotal_amount' => $order->subtotal_amount, 'discount_amount' => $order->discount_amount, + 'shipping_amount' => $order->shipping_amount, 'tax_amount' => $order->tax_amount, 'total_amount' => $order->total_amount, + ], + 'shipping_address' => $order->shipping_address_json, + 'fulfillments' => $order->fulfillments, + ]); + } +} diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php new file mode 100644 index 00000000..3db5d778 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -0,0 +1,28 @@ +validate(['q' => ['required', 'string', 'min:1', 'max:200'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50']]); + $results = $this->search->search(app('current_store'), $validated['q'], $request->only(['vendor', 'collection_id']), $validated['per_page'] ?? 12); + + return response()->json(['data' => $results->items(), 'meta' => ['current_page' => $results->currentPage(), 'last_page' => $results->lastPage(), 'total' => $results->total()]]); + } + + public function suggest(Request $request): JsonResponse + { + $validated = $request->validate(['q' => ['required', 'string', 'min:2', 'max:100']]); + + return response()->json(['data' => $this->search->autocomplete(app('current_store'), $validated['q'])]); + } +} diff --git a/app/Http/Middleware/CheckStoreRole.php b/app/Http/Middleware/CheckStoreRole.php new file mode 100644 index 00000000..7cf34c3f --- /dev/null +++ b/app/Http/Middleware/CheckStoreRole.php @@ -0,0 +1,21 @@ +bound('current_store') && $request->user() !== null, 403); + $role = $request->user()->roleForStore(app('current_store')); + $value = $role instanceof BackedEnum ? (string) $role->value : (string) $role; + abort_unless(in_array($value, $roles, true), 403); + + return $next($request); + } +} diff --git a/app/Http/Middleware/CustomerAuthenticate.php b/app/Http/Middleware/CustomerAuthenticate.php new file mode 100644 index 00000000..5d41b866 --- /dev/null +++ b/app/Http/Middleware/CustomerAuthenticate.php @@ -0,0 +1,20 @@ +check()) { + return redirect()->guest('/account/login'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..76d2e289 --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,76 @@ +is('api/admin/*'); + $store = $adminApi + ? $this->fromAdminApiRoute($request) + : ($request->is('admin', 'admin/*') ? $this->fromAdminSession($request) : $this->fromHostname($request)); + app()->instance('current_store', $store); + $request->attributes->set('store', $store); + + if ($this->status($store) === 'suspended') { + if ($request->is('admin', 'admin/*')) { + $settingsRead = $request->isMethod('GET') && $request->is('admin/settings*'); + abort_unless($settingsRead, 403, 'This store is suspended.'); + } else { + abort(503, 'This store is temporarily unavailable.'); + } + } + + return $next($request); + } + + private function fromHostname(Request $request): Store + { + $hostname = mb_strtolower($request->getHost()); + $storeId = Cache::remember("store_domain:{$hostname}", now()->addMinutes(5), fn (): ?int => StoreDomain::query()->where('hostname', $hostname)->value('store_id')); + abort_if($storeId === null, 404); + + return Store::query()->findOrFail($storeId); + } + + private function fromAdminSession(Request $request): Store + { + $user = Auth::guard('web')->user(); + abort_if($user === null, 403); + + $storeId = $request->session()->get('current_store_id'); + if ($storeId === null) { + $storeId = $user->stores()->orderBy('stores.id')->value('stores.id'); + abort_if($storeId === null, 403); + $request->session()->put('current_store_id', $storeId); + } + abort_unless($user->stores()->whereKey($storeId)->exists(), 403); + + return Store::query()->findOrFail($storeId); + } + + private function fromAdminApiRoute(Request $request): Store + { + $user = $request->user(); + abort_if($user === null, 401); + $storeId = (int) $request->route('storeId'); + abort_unless($storeId > 0 && $user->stores()->whereKey($storeId)->exists(), 403); + + return Store::query()->findOrFail($storeId); + } + + private function status(Store $store): string + { + return $store->status instanceof BackedEnum ? (string) $store->status->value : (string) $store->status; + } +} diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php new file mode 100644 index 00000000..38b6bd8a --- /dev/null +++ b/app/Jobs/AggregateAnalytics.php @@ -0,0 +1,24 @@ +date ?? now()->subDay()->toDateString(); + Store::query()->where('status', 'active')->each(fn (Store $store) => $analytics->aggregate($store, $date)); + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php new file mode 100644 index 00000000..5ff06586 --- /dev/null +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -0,0 +1,32 @@ +where('payment_method', 'bank_transfer') + ->where('financial_status', 'pending') + ->with('store.settings') + ->chunkById(100, function ($pending) use ($orders): void { + foreach ($pending as $order) { + $days = (int) data_get($order->store?->settings?->settings_json, 'bank_transfer_cancel_days', 7); + if ($order->placed_at?->lt(now()->subDays($days))) { + $orders->cancel($order, 'Bank transfer payment timeout'); + } + } + }); + } +} diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php new file mode 100644 index 00000000..cef08322 --- /dev/null +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -0,0 +1,30 @@ +where('status', 'active')->where('updated_at', '<', now()->subDays(14)) + ->with('checkouts') + ->chunkById(100, function ($carts) use ($checkouts): void { + foreach ($carts as $cart) { + foreach ($cart->checkouts->whereNotIn('status', ['completed', 'expired']) as $checkout) { + $checkouts->expireCheckout($checkout); + } + $cart->update(['status' => 'abandoned']); + } + }); + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..c40ef75e --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,70 @@ + */ + public array $backoff = [60, 300, 1800, 7200, 43200]; + + /** @param array $payload */ + public function __construct( + public readonly WebhookDelivery $delivery, + public readonly string $eventType, + public readonly array $payload, + ) {} + + public function handle(WebhookService $webhooks): void + { + $delivery = $this->delivery->fresh('subscription'); + $subscriptionStatus = $delivery?->subscription?->status; + $subscriptionStatus = $subscriptionStatus instanceof \BackedEnum ? $subscriptionStatus->value : $subscriptionStatus; + if ($delivery === null || $delivery->subscription === null || $subscriptionStatus !== 'active') { + return; + } + + $body = json_encode($this->payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $secret = (string) $delivery->subscription->signing_secret_encrypted; + $delivery->increment('attempt_count'); + $delivery->update(['last_attempt_at' => now()]); + + try { + $response = Http::timeout(10)->withHeaders([ + 'X-Platform-Signature' => $webhooks->sign($body, $secret), + 'X-Platform-Event' => $this->eventType, + 'X-Platform-Delivery-Id' => $delivery->event_id, + 'X-Platform-Timestamp' => (string) now()->timestamp, + 'Content-Type' => 'application/json', + ])->withBody($body, 'application/json')->post($delivery->subscription->target_url); + + $delivery->update([ + 'status' => $response->successful() ? 'success' : 'failed', + 'response_code' => $response->status(), + 'response_body_snippet' => mb_substr($response->body(), 0, 1000), + ]); + if (! $response->successful()) { + $webhooks->recordFailure($delivery); + $response->throw(); + } + } catch (Throwable $exception) { + $delivery->update(['status' => 'failed', 'response_body_snippet' => mb_substr($exception->getMessage(), 0, 1000)]); + $webhooks->recordFailure($delivery); + throw $exception; + } + } +} diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php new file mode 100644 index 00000000..31f5980f --- /dev/null +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -0,0 +1,26 @@ +whereNotIn('status', ['completed', 'expired']) + ->where(function ($query): void { + $query->where('expires_at', '<', now())->orWhere('updated_at', '<', now()->subDay()); + }) + ->chunkById(100, fn ($checkouts) => $checkouts->each(fn (Checkout $checkout) => $service->expireCheckout($checkout))); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..251ffcfa --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,77 @@ +media->fresh(); + if ($media === null) { + return; + } + + try { + $disk = Storage::disk('public'); + if (! $disk->exists($media->storage_key)) { + throw new \RuntimeException('The source media file is missing.'); + } + + $path = $disk->path($media->storage_key); + $info = @getimagesize($path); + if ($info === false) { + throw new \RuntimeException('The uploaded file is not a supported image.'); + } + + [$width, $height] = $info; + $media->update([ + 'width' => $width, + 'height' => $height, + 'mime_type' => $info['mime'] ?? $disk->mimeType($media->storage_key), + 'byte_size' => $disk->size($media->storage_key), + ]); + + $contents = $disk->get($media->storage_key); + $source = function_exists('imagecreatefromstring') ? @imagecreatefromstring($contents) : false; + if ($source !== false) { + foreach (['thumbnail' => 150, 'small' => 300, 'medium' => 600, 'large' => 1200] as $name => $maximum) { + $scale = min(1, $maximum / max($width, $height)); + $targetWidth = max(1, (int) round($width * $scale)); + $targetHeight = max(1, (int) round($height * $scale)); + $target = imagecreatetruecolor($targetWidth, $targetHeight); + imagealphablending($target, false); + imagesavealpha($target, true); + imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); + ob_start(); + imagewebp($target, null, 85); + $encoded = (string) ob_get_clean(); + imagedestroy($target); + $disk->put("media/{$media->product_id}/{$media->id}/{$name}.webp", $encoded); + } + imagedestroy($source); + } + + $media->update(['status' => 'ready']); + } catch (Throwable $exception) { + $media->update(['status' => 'failed']); + Log::error('Product media processing failed.', ['media_id' => $media->id, 'error' => $exception->getMessage()]); + throw $exception; + } + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..89afb453 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,39 @@ + 'date', + 'orders_count' => 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..6413669e --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,32 @@ + 'array', + 'occurred_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..bfef2da3 --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,32 @@ + AppStatus::class]; + } + + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function oauthClients(): HasMany + { + return $this->hasMany(OauthClient::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..556f3f66 --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,43 @@ + 'array', + 'status' => AppInstallationStatus::class, + 'installed_at' => 'datetime', + ]; + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + public function oauthTokens(): HasMany + { + return $this->hasMany(OauthToken::class, 'installation_id'); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..08852d73 --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,40 @@ + 'integer', + 'status' => CartStatus::class, + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..3efe1c4e --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,40 @@ + 'integer', + 'unit_price_amount' => 'integer', + 'line_subtotal_amount' => 'integer', + 'line_discount_amount' => 'integer', + 'line_total_amount' => 'integer', + ]; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..4289f964 --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,49 @@ + CheckoutStatus::class, + 'payment_method' => PaymentMethod::class, + 'shipping_address_json' => 'array', + 'billing_address_json' => 'array', + 'tax_provider_snapshot_json' => 'array', + 'totals_json' => 'array', + 'expires_at' => 'datetime', + ]; + } + + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function shippingRate(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..57267abe --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,33 @@ + CollectionType::class, + 'status' => CollectionStatus::class, + ]; + } + + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->using(CollectionProduct::class) + ->withPivot('position') + ->orderByPivot('position'); + } +} diff --git a/app/Models/CollectionProduct.php b/app/Models/CollectionProduct.php new file mode 100644 index 00000000..67796da2 --- /dev/null +++ b/app/Models/CollectionProduct.php @@ -0,0 +1,32 @@ + 'integer']; + } + + public function collection(): BelongsTo + { + return $this->belongsTo(Collection::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..943c729a --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,30 @@ +getAttribute('store_id') !== null || ! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + $model->setAttribute('store_id', $store instanceof Store ? $store->getKey() : $store); + }); + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..3d053671 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,76 @@ + 'hashed', + 'marketing_opt_in' => 'boolean', + ]; + } + + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + public function getAuthPassword(): string + { + return (string) $this->password_hash; + } + + public function setPasswordAttribute(mixed $value): void + { + $this->setAttribute('password_hash', $value); + } + + public function getPasswordAttribute(): string + { + return (string) $this->password_hash; + } + + public function getRememberTokenName(): string + { + return ''; + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..5f9c7a73 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,29 @@ + 'array', + 'is_default' => 'boolean', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..3cde2d14 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,35 @@ + DiscountType::class, + 'value_type' => DiscountValueType::class, + 'value_amount' => 'integer', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + 'rules_json' => 'array', + 'status' => DiscountStatus::class, + ]; + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..cd905d30 --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,38 @@ + FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function lines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php new file mode 100644 index 00000000..eaa010b6 --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,31 @@ + 'integer']; + } + + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..c26f8e9a --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,37 @@ + 'integer', + 'quantity_reserved' => 'integer', + 'policy' => InventoryPolicy::class, + ]; + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function availableQuantity(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..b66e6b52 --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,46 @@ + NavigationItemType::class, + 'resource_id' => 'integer', + 'position' => 'integer', + ]; + } + + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } + + public function page(): BelongsTo + { + return $this->belongsTo(Page::class, 'resource_id'); + } + + public function collection(): BelongsTo + { + return $this->belongsTo(Collection::class, 'resource_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'resource_id'); + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..b2370491 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,20 @@ +hasMany(NavigationItem::class, 'menu_id')->orderBy('position'); + } +} diff --git a/app/Models/OauthClient.php b/app/Models/OauthClient.php new file mode 100644 index 00000000..6a8e61ce --- /dev/null +++ b/app/Models/OauthClient.php @@ -0,0 +1,31 @@ + 'encrypted', + 'redirect_uris_json' => 'array', + ]; + } + + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } +} diff --git a/app/Models/OauthToken.php b/app/Models/OauthToken.php new file mode 100644 index 00000000..9c9f33e6 --- /dev/null +++ b/app/Models/OauthToken.php @@ -0,0 +1,28 @@ + 'datetime']; + } + + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'installation_id'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..3af2ee54 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,67 @@ + PaymentMethod::class, + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::class, + 'subtotal_amount' => 'integer', + 'discount_amount' => 'integer', + 'shipping_amount' => 'integer', + 'tax_amount' => 'integer', + 'total_amount' => 'integer', + 'billing_address_json' => 'array', + 'shipping_address_json' => 'array', + 'placed_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..6a49f62e --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,51 @@ + 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..ecc17e4f --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,19 @@ +hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..0aa36fa9 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,23 @@ + PageStatus::class, + 'published_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..8c9cb684 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,41 @@ + PaymentMethod::class, + 'status' => PaymentStatus::class, + 'amount' => 'integer', + 'raw_json_encrypted' => 'encrypted:array', + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..189efc1b --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,56 @@ + ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + public function options(): HasMany + { + return $this->hasMany(ProductOption::class)->orderBy('position'); + } + + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class)->orderBy('position'); + } + + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class)->orderBy('position'); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->using(CollectionProduct::class) + ->withPivot('position') + ->orderByPivot('position'); + } + + public function orderLines(): HasMany + { + return $this->hasMany(OrderLine::class); + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..9bfceda8 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,39 @@ + MediaType::class, + 'width' => 'integer', + 'height' => 'integer', + 'byte_size' => 'integer', + 'position' => 'integer', + 'status' => MediaStatus::class, + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..ba5a5b28 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,32 @@ + 'integer']; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function values(): HasMany + { + return $this->hasMany(ProductOptionValue::class)->orderBy('position'); + } +} diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php new file mode 100644 index 00000000..82d553e7 --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,33 @@ + 'integer']; + } + + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } + + public function variants(): BelongsToMany + { + return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id') + ->using(VariantOptionValue::class); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..89eec499 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,60 @@ + 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + 'position' => 'integer', + 'status' => VariantStatus::class, + ]; + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function optionValues(): BelongsToMany + { + return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id') + ->using(VariantOptionValue::class); + } + + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } + + public function cartLines(): HasMany + { + return $this->hasMany(CartLine::class, 'variant_id'); + } + + public function orderLines(): HasMany + { + return $this->hasMany(OrderLine::class, 'variant_id'); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..2298e177 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,35 @@ + 'integer', + 'status' => RefundStatus::class, + ]; + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..902f00db --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,23 @@ +bound('current_store')) { + return; + } + + $store = app('current_store'); + $storeId = $store instanceof Store ? $store->getKey() : $store; + + $builder->where($model->qualifyColumn('store_id'), $storeId); + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..1dd3fc16 --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,24 @@ + 'array', + 'results_count' => 'integer', + ]; + } +} diff --git a/app/Models/SearchSettings.php b/app/Models/SearchSettings.php new file mode 100644 index 00000000..85f6d5fc --- /dev/null +++ b/app/Models/SearchSettings.php @@ -0,0 +1,28 @@ + 'array', + 'stop_words_json' => 'array', + ]; + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..283820c8 --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,37 @@ + ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + public function zone(): BelongsTo + { + return $this->belongsTo(ShippingZone::class, 'zone_id'); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class, 'shipping_method_id'); + } +} diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php new file mode 100644 index 00000000..ff371e9c --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,30 @@ + 'array', + 'regions_json' => 'array', + ]; + } + + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..8156354f --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,147 @@ + StoreStatus::class]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function memberships(): HasMany + { + return $this->hasMany(StoreUser::class); + } + + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + public function products(): HasMany + { + return $this->hasMany(Product::class); + } + + public function collections(): HasMany + { + return $this->hasMany(Collection::class); + } + + public function inventoryItems(): HasMany + { + return $this->hasMany(InventoryItem::class); + } + + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } + + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function discounts(): HasMany + { + return $this->hasMany(Discount::class); + } + + public function shippingZones(): HasMany + { + return $this->hasMany(ShippingZone::class); + } + + public function taxSettings(): HasOne + { + return $this->hasOne(TaxSettings::class); + } + + public function themes(): HasMany + { + return $this->hasMany(Theme::class); + } + + public function pages(): HasMany + { + return $this->hasMany(Page::class); + } + + public function navigationMenus(): HasMany + { + return $this->hasMany(NavigationMenu::class); + } + + public function searchSettings(): HasOne + { + return $this->hasOne(SearchSettings::class); + } + + public function searchQueries(): HasMany + { + return $this->hasMany(SearchQuery::class); + } + + public function analyticsEvents(): HasMany + { + return $this->hasMany(AnalyticsEvent::class); + } + + public function analyticsDaily(): HasMany + { + return $this->hasMany(AnalyticsDaily::class); + } + + public function appInstallations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } + + public function webhookSubscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..bc153548 --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,27 @@ + StoreDomainType::class, + 'is_primary' => 'boolean', + 'tls_mode' => TlsMode::class, + ]; + } +} diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php new file mode 100644 index 00000000..9d762153 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,25 @@ + 'array']; + } +} diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php new file mode 100644 index 00000000..7345db8c --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,33 @@ + StoreUserRole::class]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..46d55b23 --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,32 @@ + TaxMode::class, + 'provider' => TaxProviderType::class, + 'prices_include_tax' => 'boolean', + 'config_json' => 'array', + ]; + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..472cedc1 --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,35 @@ + ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..16d11484 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,26 @@ + 'integer']; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php new file mode 100644 index 00000000..4d54f104 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,30 @@ + 'array']; + } + + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..51dedfa2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,17 +2,21 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; +use App\Enums\UserStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -22,7 +26,10 @@ class User extends Authenticatable protected $fillable = [ 'name', 'email', + 'password_hash', 'password', + 'status', + 'last_login_at', ]; /** @@ -32,6 +39,7 @@ class User extends Authenticatable */ protected $hidden = [ 'password', + 'password_hash', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token', @@ -46,10 +54,58 @@ protected function casts(): array { return [ 'email_verified_at' => 'datetime', - 'password' => 'hashed', + 'last_login_at' => 'datetime', + 'password_hash' => 'hashed', + 'status' => UserStatus::class, ]; } + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role'); + } + + public function storeMemberships(): HasMany + { + return $this->hasMany(StoreUser::class); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $role = StoreUser::query() + ->where('store_id', $store->getKey()) + ->where('user_id', $this->getKey()) + ->value('role'); + + if ($role instanceof StoreUserRole) { + return $role; + } + + return is_string($role) ? StoreUserRole::tryFrom($role) : null; + } + + public function getAuthPasswordName(): string + { + return 'password_hash'; + } + + public function getAuthPassword(): string + { + return (string) $this->password_hash; + } + + public function setPasswordAttribute(mixed $value): void + { + $this->setAttribute('password_hash', $value); + } + + public function getPasswordAttribute(): string + { + return (string) $this->password_hash; + } + /** * Get the user's initials */ diff --git a/app/Models/VariantOptionValue.php b/app/Models/VariantOptionValue.php new file mode 100644 index 00000000..5a328efa --- /dev/null +++ b/app/Models/VariantOptionValue.php @@ -0,0 +1,27 @@ +belongsTo(ProductVariant::class, 'variant_id'); + } + + public function optionValue(): BelongsTo + { + return $this->belongsTo(ProductOptionValue::class, 'product_option_value_id'); + } +} diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..fe49837d --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,34 @@ + 'integer', + 'status' => WebhookDeliveryStatus::class, + 'last_attempt_at' => 'datetime', + 'response_code' => 'integer', + ]; + } + + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..6cabd633 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,41 @@ + 'encrypted', + 'status' => WebhookSubscriptionStatus::class, + ]; + } + + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'app_installation_id'); + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } +} diff --git a/app/Observers/ProductObserver.php b/app/Observers/ProductObserver.php new file mode 100644 index 00000000..78f7a9cb --- /dev/null +++ b/app/Observers/ProductObserver.php @@ -0,0 +1,32 @@ +search->syncProduct($product); + event(new ProductCreated($product)); + } + + public function updated(Product $product): void + { + $this->search->syncProduct($product); + event(new ProductUpdated($product)); + } + + public function deleted(Product $product): void + { + $this->search->removeProduct((int) $product->id); + event(new ProductDeleted($product)); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php new file mode 100644 index 00000000..61c545f1 --- /dev/null +++ b/app/Policies/CollectionPolicy.php @@ -0,0 +1,18 @@ +hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } + public function view(User $user, Collection $collection): bool { return $this->hasRole($user, $collection, ['owner', 'admin', 'staff', 'support']); } + public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } + public function update(User $user, Collection $collection): bool { return $this->hasRole($user, $collection, ['owner', 'admin', 'staff']); } + public function delete(User $user, Collection $collection): bool { return $this->hasRole($user, $collection, ['owner', 'admin']); } +} diff --git a/app/Policies/Concerns/ChecksStoreRoles.php b/app/Policies/Concerns/ChecksStoreRoles.php new file mode 100644 index 00000000..1cdc2e40 --- /dev/null +++ b/app/Policies/Concerns/ChecksStoreRoles.php @@ -0,0 +1,43 @@ + $roles */ + protected function hasRole(User $user, Model|Store|null $resource, array $roles): bool + { + $store = $resource instanceof Store ? $resource : $this->storeFor($resource); + $store ??= app()->bound('current_store') ? app('current_store') : null; + if ($store === null) { + return false; + } + $role = $user->roleForStore($store); + $value = $role instanceof BackedEnum ? (string) $role->value : ($role === null ? null : (string) $role); + + return $value !== null && in_array($value, $roles, true); + } + + private function storeFor(?Model $resource): ?Store + { + if ($resource === null) { + return null; + } + if (isset($resource->store_id)) { + return Store::query()->find($resource->store_id); + } + if (method_exists($resource, 'store')) { + return $resource->store; + } + if (method_exists($resource, 'order')) { + return $resource->order?->store; + } + + return null; + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..cc4fbbc1 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,16 @@ +hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } + public function view(User $user, Customer $customer): bool { return $this->hasRole($user, $customer, ['owner', 'admin', 'staff', 'support']); } + public function update(User $user, Customer $customer): bool { return $this->hasRole($user, $customer, ['owner', 'admin', 'staff']); } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..6b01e4d8 --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,18 @@ +hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } + public function view(User $user, Discount $discount): bool { return $this->hasRole($user, $discount, ['owner', 'admin', 'staff', 'support']); } + public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } + public function update(User $user, Discount $discount): bool { return $this->hasRole($user, $discount, ['owner', 'admin', 'staff']); } + public function delete(User $user, Discount $discount): bool { return $this->hasRole($user, $discount, ['owner', 'admin']); } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..9d477eec --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,16 @@ +hasRole($user, $order, ['owner', 'admin', 'staff']); } + public function update(User $user, Fulfillment $fulfillment): bool { return $this->hasRole($user, $fulfillment, ['owner', 'admin', 'staff']); } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..05646e2f --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,19 @@ +hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } + public function view(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin', 'staff', 'support']); } + public function update(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); } + public function cancel(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin']); } + public function refund(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin']); } + public function fulfill(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..9256b6fd --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,18 @@ +hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } + public function view(User $user, Page $page): bool { return $this->hasRole($user, $page, ['owner', 'admin', 'staff', 'support']); } + public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } + public function update(User $user, Page $page): bool { return $this->hasRole($user, $page, ['owner', 'admin', 'staff']); } + public function delete(User $user, Page $page): bool { return $this->hasRole($user, $page, ['owner', 'admin']); } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..637b69a6 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,18 @@ +hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } + public function view(User $user, Product $product): bool { return $this->hasRole($user, $product, ['owner', 'admin', 'staff', 'support']); } + public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } + public function update(User $user, Product $product): bool { return $this->hasRole($user, $product, ['owner', 'admin', 'staff']); } + public function delete(User $user, Product $product): bool { return $this->hasRole($user, $product, ['owner', 'admin']); } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..58a760dc --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,16 @@ +hasRole($user, $order, ['owner', 'admin']); } + public function view(User $user, Refund $refund): bool { return $this->hasRole($user, $refund, ['owner', 'admin', 'staff', 'support']); } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..363f7bc5 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,17 @@ +hasRole($user, $store, ['owner', 'admin', 'staff', 'support']); } + public function update(User $user, Store $store): bool { return $this->hasRole($user, $store, ['owner', 'admin']); } + public function delete(User $user, Store $store): bool { return $this->hasRole($user, $store, ['owner']); } + public function manageStaff(User $user, Store $store): bool { return $this->hasRole($user, $store, ['owner', 'admin']); } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..e0df6bc5 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,18 @@ +hasRole($user, null, ['owner', 'admin']); } + public function view(User $user, Theme $theme): bool { return $this->hasRole($user, $theme, ['owner', 'admin']); } + public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin']); } + public function update(User $user, Theme $theme): bool { return $this->hasRole($user, $theme, ['owner', 'admin']); } + public function delete(User $user, Theme $theme): bool { return $this->hasRole($user, $theme, ['owner', 'admin']); } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..dfb46b4c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,19 @@ namespace App\Providers; +use App\Auth\CustomerUserProvider; +use App\Contracts\PaymentProvider; +use App\Contracts\TaxProvider; +use App\Models\Product; +use App\Observers\ProductObserver; +use App\Services\Payments\MockPaymentProvider; +use App\Services\Tax\ManualTaxProvider; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -15,7 +25,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(PaymentProvider::class, MockPaymentProvider::class); + $this->app->singleton(TaxProvider::class, ManualTaxProvider::class); } /** @@ -24,6 +35,17 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureAuthentication(); + $this->configureRateLimits(); + Product::observe(ProductObserver::class); + + if (config('database.default') === 'sqlite') { + try { + DB::statement('PRAGMA cache_size = -20000'); + } catch (\Throwable) { + // The database file may not exist yet during the first Composer setup. + } + } } /** @@ -47,4 +69,23 @@ protected function configureDefaults(): void : null ); } + + private function configureAuthentication(): void + { + Auth::provider('tenant-customers', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider( + $app['hash'], + $config['model'], + )); + } + + private function configureRateLimits(): void + { + RateLimiter::for('login', fn ($request) => Limit::perMinute(5)->by($request->ip().'|'.mb_strtolower((string) $request->input('email')))); + RateLimiter::for('api.admin', fn ($request) => Limit::perMinute(60)->by((string) ($request->user()?->getAuthIdentifier() ?? $request->ip()))); + RateLimiter::for('api.storefront', fn ($request) => Limit::perMinute(120)->by($request->ip())); + RateLimiter::for('checkout', fn ($request) => Limit::perMinute(10)->by($request->session()->getId())); + RateLimiter::for('search', fn ($request) => Limit::perMinute(30)->by($request->ip())); + RateLimiter::for('analytics', fn ($request) => Limit::perMinute(60)->by($request->ip())); + RateLimiter::for('webhooks', fn ($request) => Limit::perMinute(100)->by($request->ip())); + } } diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 00000000..322e3023 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,70 @@ + $properties */ + public function track( + Store $store, + string $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ?CarbonInterface $occurredAt = null, + ): ?AnalyticsEvent { + if (! in_array($type, ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search'], true)) { + throw new \InvalidArgumentException('Unsupported analytics event type.'); + } + + return AnalyticsEvent::withoutGlobalScopes()->firstOrCreate([ + 'store_id' => $store->id, + 'client_event_id' => $clientEventId ?? (string) Str::uuid(), + ], [ + 'type' => $type, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => $properties, + 'occurred_at' => $occurredAt ?? now(), + ]); + } + + /** @return Collection */ + public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection + { + return AnalyticsDaily::withoutGlobalScopes()->where('store_id', $store->id)->whereBetween('date', [$startDate, $endDate])->orderBy('date')->get(); + } + + public function aggregate(Store $store, CarbonInterface|string $date): AnalyticsDaily + { + $date = is_string($date) ? $date : $date->toDateString(); + $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->id)->whereDate('occurred_at', $date)->get(); + $completed = $events->where('type', 'checkout_completed'); + $revenue = (int) $completed->sum(fn (AnalyticsEvent $event): int => (int) data_get($event->properties_json, 'total_amount', data_get($event->properties_json, 'total', 0))); + $orders = $completed->count(); + + DB::table('analytics_daily')->updateOrInsert( + ['store_id' => $store->id, 'date' => $date], + [ + 'orders_count' => $orders, + 'revenue_amount' => $revenue, + 'aov_amount' => $orders > 0 ? intdiv($revenue, $orders) : 0, + 'visits_count' => $events->where('type', 'page_view')->pluck('session_id')->filter()->unique()->count(), + 'add_to_cart_count' => $events->where('type', 'add_to_cart')->count(), + 'checkout_started_count' => $events->where('type', 'checkout_started')->count(), + 'checkout_completed_count' => $orders, + ], + ); + + return AnalyticsDaily::withoutGlobalScopes()->where('store_id', $store->id)->whereDate('date', $date)->firstOrFail(); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..3ca5bd55 --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,194 @@ +create([ + 'store_id' => $store->id, + 'customer_id' => $customer?->id, + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => 'active', + ]); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + if ($customer !== null) { + $customerCart = Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('customer_id', $customer->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + if ($customerCart !== null) { + return $customerCart; + } + } + + $cartId = session('cart_id'); + if ($cartId !== null) { + $cart = Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->find($cartId); + + if ($cart !== null) { + return $cart; + } + } + + $cart = $this->create($store, $customer); + session(['cart_id' => $cart->id]); + + return $cart; + } + + public function addLine(Cart $cart, int|ProductVariant $variant, int $quantity = 1, ?int $expectedVersion = null): CartLine + { + $this->assertVersion($cart, $expectedVersion); + $this->positive($quantity); + $variant = $variant instanceof ProductVariant + ? $variant + : ProductVariant::withoutGlobalScopes()->with(['product', 'inventoryItem'])->findOrFail($variant); + $variant->loadMissing(['product', 'inventoryItem']); + + if ((int) $variant->product->store_id !== (int) $cart->store_id) { + throw new DomainException('The selected variant does not belong to this store.'); + } + + if ($this->enumValue($variant->product->status) !== 'active' || $this->enumValue($variant->status) !== 'active') { + throw new DomainException('This product is not available.'); + } + + return DB::transaction(function () use ($cart, $variant, $quantity): CartLine { + $line = CartLine::query()->where('cart_id', $cart->id)->where('variant_id', $variant->id)->first(); + $newQuantity = $quantity + ($line?->quantity ?? 0); + + if ($variant->inventoryItem !== null && ! $this->inventory->checkAvailability($variant->inventoryItem, $newQuantity)) { + throw new \App\Exceptions\InsufficientInventoryException('The requested quantity is not available.'); + } + + $amounts = $this->amounts((int) $variant->price_amount, $newQuantity); + $line ??= new CartLine(['cart_id' => $cart->id, 'variant_id' => $variant->id]); + $line->fill(['quantity' => $newQuantity, ...$amounts])->save(); + $this->bumpVersion($cart); + + return $line->refresh(); + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): CartLine + { + $this->assertVersion($cart, $expectedVersion); + if ($quantity === 0) { + $this->removeLine($cart, $lineId, $expectedVersion); + + return new CartLine; + } + $this->positive($quantity); + + return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine { + $line = CartLine::query()->where('cart_id', $cart->id)->with('variant.inventoryItem')->findOrFail($lineId); + + if ($line->variant->inventoryItem !== null && ! $this->inventory->checkAvailability($line->variant->inventoryItem, $quantity)) { + throw new \App\Exceptions\InsufficientInventoryException('The requested quantity is not available.'); + } + + $line->fill(['quantity' => $quantity, ...$this->amounts((int) $line->variant->price_amount, $quantity)])->save(); + $this->bumpVersion($cart); + + return $line->refresh(); + }); + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + $this->assertVersion($cart, $expectedVersion); + DB::transaction(function () use ($cart, $lineId): void { + CartLine::query()->where('cart_id', $cart->id)->findOrFail($lineId)->delete(); + $this->bumpVersion($cart); + }); + } + + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + if ((int) $guest->store_id !== (int) $customer->store_id) { + throw new DomainException('Carts from different stores cannot be merged.'); + } + + return DB::transaction(function () use ($guest, $customer): Cart { + $guest->load('lines'); + foreach ($guest->lines as $line) { + $target = CartLine::query()->where('cart_id', $customer->id)->where('variant_id', $line->variant_id)->first(); + if ($target !== null) { + $target->quantity += $line->quantity; + $target->fill($this->amounts((int) $target->unit_price_amount, (int) $target->quantity))->save(); + } else { + $line->cart_id = $customer->id; + $line->save(); + } + } + + $guest->update(['status' => 'abandoned']); + $this->bumpVersion($customer); + session()->forget('cart_id'); + + return $customer->refresh()->load('lines'); + }); + } + + public function assertVersion(Cart $cart, ?int $expectedVersion): void + { + if ($expectedVersion !== null && (int) $cart->cart_version !== $expectedVersion) { + throw new CartVersionMismatchException($cart->fresh('lines') ?? $cart); + } + } + + /** @return array{unit_price_amount: int, line_subtotal_amount: int, line_discount_amount: int, line_total_amount: int} */ + private function amounts(int $unitPrice, int $quantity): array + { + $subtotal = $unitPrice * $quantity; + + return [ + 'unit_price_amount' => $unitPrice, + 'line_subtotal_amount' => $subtotal, + 'line_discount_amount' => 0, + 'line_total_amount' => $subtotal, + ]; + } + + private function bumpVersion(Cart $cart): void + { + $cart->increment('cart_version'); + $cart->refresh(); + } + + private function positive(int $quantity): void + { + if ($quantity < 1) { + throw new \InvalidArgumentException('Quantity must be at least one.'); + } + } + + private function enumValue(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..57bdaca0 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,184 @@ +lines()->count() === 0) { + throw new InvalidCheckoutTransitionException('An empty cart cannot be checked out.'); + } + + return Checkout::withoutGlobalScopes()->create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => 'started', + 'expires_at' => now()->addDay(), + ]); + } + + /** @param array $data */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + $this->assertState($checkout, ['started', 'addressed', 'shipping_selected']); + $address = (array) ($data['shipping_address'] ?? $data['address'] ?? $data); + $email = (string) ($data['email'] ?? $checkout->email ?? ''); + Validator::make(['email' => $email, ...$address], [ + 'email' => ['required', 'email'], + 'first_name' => ['required', 'string', 'max:100'], + 'last_name' => ['required', 'string', 'max:100'], + 'address1' => ['required', 'string', 'max:255'], + 'city' => ['required', 'string', 'max:100'], + 'country_code' => ['required_without:country', 'nullable', 'string', 'size:2'], + 'country' => ['required_without:country_code', 'nullable', 'string', 'size:2'], + 'postal_code' => ['required_without:zip', 'nullable', 'string', 'max:20'], + 'zip' => ['required_without:postal_code', 'nullable', 'string', 'max:20'], + ])->validate(); + + $checkout->fill([ + 'email' => mb_strtolower($email), + 'shipping_address_json' => $address, + 'billing_address_json' => (array) ($data['billing_address'] ?? $address), + 'shipping_method_id' => null, + 'status' => 'addressed', + ])->save(); + $this->pricing->calculate($checkout); + event(new CheckoutAddressed($checkout)); + + return $checkout->refresh(); + } + + public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout + { + $this->assertState($checkout, ['addressed', 'shipping_selected']); + $checkout->loadMissing(['store', 'cart.lines.variant']); + + if (! $this->shipping->requiresShipping($checkout->cart)) { + $checkout->update(['shipping_method_id' => null, 'status' => 'shipping_selected']); + } else { + $rates = $this->shipping->getAvailableRates($checkout->store, (array) $checkout->shipping_address_json, $checkout->cart); + if ($shippingRateId === null || ! $rates->contains('id', $shippingRateId)) { + throw new ShippingUnavailableException('The selected shipping method is not available for this address.'); + } + $checkout->update(['shipping_method_id' => $shippingRateId, 'status' => 'shipping_selected']); + } + $this->pricing->calculate($checkout); + event(new CheckoutShippingSelected($checkout)); + + return $checkout->refresh(); + } + + public function applyDiscount(Checkout $checkout, ?string $code): Checkout + { + $this->assertState($checkout, ['addressed', 'shipping_selected']); + $checkout->discount_code = $code === null || trim($code) === '' ? null : trim($code); + $checkout->save(); + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + } + + public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $paymentMethod): Checkout + { + $this->assertState($checkout, ['shipping_selected']); + $method = $paymentMethod instanceof PaymentMethod ? $paymentMethod : PaymentMethod::from($paymentMethod); + $checkout->loadMissing('cart.lines.variant.inventoryItem'); + + DB::transaction(function () use ($checkout, $method): void { + foreach ($checkout->cart->lines as $line) { + if ($line->variant->inventoryItem !== null) { + $this->inventory->reserve($line->variant->inventoryItem, (int) $line->quantity); + } + } + $checkout->update(['payment_method' => $method, 'status' => 'payment_selected', 'expires_at' => now()->addDay()]); + }); + + return $checkout->refresh(); + } + + /** @param array $paymentDetails */ + public function completeCheckout(Checkout $checkout, array $paymentDetails = []): Order + { + if ($this->state($checkout) === 'completed') { + $id = (int) data_get($checkout->totals_json, 'order_id'); + + return Order::withoutGlobalScopes()->findOrFail($id); + } + $this->assertState($checkout, ['payment_selected']); + $method = $checkout->payment_method instanceof PaymentMethod + ? $checkout->payment_method + : PaymentMethod::from((string) $checkout->payment_method); + $result = $this->payments->charge($checkout, $method, $paymentDetails); + + if (! $result->success) { + $this->releaseReservations($checkout); + $checkout->update(['status' => 'shipping_selected', 'payment_method' => null]); + throw new PaymentFailedException($result->errorCode ?? 'payment_failed', $result->errorMessage); + } + + $order = $this->orders->createFromCheckout($checkout, $result); + event(new CheckoutCompleted($checkout, $order)); + + return $order; + } + + public function expireCheckout(Checkout $checkout): void + { + if (in_array($this->state($checkout), ['completed', 'expired'], true)) { + return; + } + if ($this->state($checkout) === 'payment_selected') { + $this->releaseReservations($checkout); + } + $checkout->update(['status' => 'expired']); + event(new CheckoutExpired($checkout)); + } + + private function releaseReservations(Checkout $checkout): void + { + $checkout->loadMissing('cart.lines.variant.inventoryItem'); + foreach ($checkout->cart->lines as $line) { + if ($line->variant->inventoryItem !== null) { + $this->inventory->release($line->variant->inventoryItem, (int) $line->quantity); + } + } + } + + /** @param list $states */ + private function assertState(Checkout $checkout, array $states): void + { + if (! in_array($this->state($checkout), $states, true)) { + throw new InvalidCheckoutTransitionException('This checkout action is not valid in its current state.'); + } + } + + private function state(Checkout $checkout): string + { + return $checkout->status instanceof BackedEnum ? (string) $checkout->status->value : (string) $checkout->status; + } +} diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php new file mode 100644 index 00000000..f626a1e9 --- /dev/null +++ b/app/Services/CustomerService.php @@ -0,0 +1,31 @@ + $data */ + public function register(Store $store, array $data): Customer + { + $validated = Validator::make($data, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('customers')->where('store_id', $store->id)], + 'password' => ['required', 'string', 'min:8'], + 'marketing_opt_in' => ['sometimes', 'boolean'], + ])->validate(); + + return Customer::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'name' => $validated['name'], + 'email' => mb_strtolower($validated['email']), + 'password_hash' => Hash::make($validated['password']), + 'marketing_opt_in' => (bool) ($validated['marketing_opt_in'] ?? false), + ]); + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..4ef42193 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,127 @@ +where('store_id', $store->id) + ->whereRaw('LOWER(code) = ?', [mb_strtolower(trim($code))]) + ->first(); + + if ($discount === null) { + throw new InvalidDiscountException('not_found'); + } + if ($this->value($discount->status) !== 'active') { + throw new InvalidDiscountException('expired'); + } + if ($discount->starts_at !== null && $discount->starts_at->isFuture()) { + throw new InvalidDiscountException('not_yet_active'); + } + if ($discount->ends_at !== null && $discount->ends_at->isPast()) { + throw new InvalidDiscountException('expired'); + } + if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) { + throw new InvalidDiscountException('usage_limit_reached'); + } + + $cart->loadMissing('lines.variant.product.collections'); + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + $rules = (array) ($discount->rules_json ?? []); + $minimum = (int) ($rules['min_purchase_amount'] ?? $rules['minimum_purchase'] ?? 0); + if ($minimum > 0 && $subtotal < $minimum) { + throw new InvalidDiscountException('minimum_not_met'); + } + if ($this->qualifyingLines($discount, $cart->lines)->isEmpty() && $cart->lines->isNotEmpty()) { + throw new InvalidDiscountException('not_applicable'); + } + + return $discount; + } + + public function validateResult(string $code, Store $store, Cart $cart): DiscountValidationResult + { + try { + return new DiscountValidationResult(true, $this->validate($code, $store, $cart)); + } catch (InvalidDiscountException $exception) { + return new DiscountValidationResult(false, errorCode: $exception->reason, errorMessage: $exception->getMessage()); + } + } + + /** @param array|Collection $lines */ + public function calculate(Discount $discount, int $subtotal, array|Collection $lines): DiscountResult + { + $lines = $lines instanceof Collection ? $lines : collect($lines); + $qualifying = $this->qualifyingLines($discount, $lines); + $qualifyingSubtotal = (int) $qualifying->sum(fn (mixed $line): int => $this->lineSubtotal($line)); + + if ($this->value($discount->value_type) === 'free_shipping') { + return new DiscountResult(0, [], true); + } + if ($qualifyingSubtotal < 1 || $subtotal < 1) { + return new DiscountResult(0); + } + + $amount = $this->value($discount->value_type) === 'percent' + ? (int) round($qualifyingSubtotal * (int) $discount->value_amount / 100) + : min((int) $discount->value_amount, $qualifyingSubtotal); + $amount = min($amount, $subtotal); + + $remaining = $amount; + $allocations = []; + $lastIndex = $qualifying->count() - 1; + foreach ($qualifying->values() as $index => $line) { + $lineId = (int) (is_array($line) ? ($line['id'] ?? $index) : ($line->id ?? $index)); + $allocation = $index === $lastIndex + ? $remaining + : (int) round($amount * $this->lineSubtotal($line) / $qualifyingSubtotal); + $allocation = min($remaining, $allocation); + $allocations[$lineId] = $allocation; + $remaining -= $allocation; + } + + return new DiscountResult($amount, $allocations); + } + + /** @param Collection $lines @return Collection */ + private function qualifyingLines(Discount $discount, Collection $lines): Collection + { + $rules = (array) ($discount->rules_json ?? []); + $productIds = array_map('intval', (array) ($rules['applicable_product_ids'] ?? [])); + $collectionIds = array_map('intval', (array) ($rules['applicable_collection_ids'] ?? [])); + if ($productIds === [] && $collectionIds === []) { + return $lines; + } + + return $lines->filter(function (mixed $line) use ($productIds, $collectionIds): bool { + $product = is_array($line) ? ($line['product'] ?? null) : ($line->variant?->product ?? null); + $productId = (int) (is_array($line) ? ($line['product_id'] ?? 0) : ($product?->id ?? 0)); + $ids = $product?->relationLoaded('collections') ? $product->collections->modelKeys() : []; + + return in_array($productId, $productIds, true) || array_intersect($ids, $collectionIds) !== []; + }); + } + + private function lineSubtotal(mixed $line): int + { + return (int) (is_array($line) + ? ($line['line_subtotal_amount'] ?? $line['subtotal'] ?? (($line['unit_price_amount'] ?? 0) * ($line['quantity'] ?? 0))) + : ($line->line_subtotal_amount ?? (($line->unit_price_amount ?? 0) * ($line->quantity ?? 0)))); + } + + private function value(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..69ae80a7 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,111 @@ + $lines @param array|null $tracking */ + public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment + { + if (! in_array($this->value($order->financial_status), ['paid', 'partially_refunded'], true)) { + throw new FulfillmentGuardException('Fulfillment cannot be created until payment is confirmed.'); + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + $order->load('lines.fulfillmentLines'); + foreach ($lines as $lineId => $quantity) { + $line = $order->lines->firstWhere('id', (int) $lineId); + $fulfilled = $line?->fulfillmentLines->sum('quantity') ?? 0; + if ($line === null || $quantity < 1 || $quantity > $line->quantity - $fulfilled) { + throw ValidationException::withMessages(['lines' => 'A fulfillment quantity exceeds the unfulfilled quantity.']); + } + } + + $fulfillment = $order->fulfillments()->create([ + 'status' => 'pending', + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + ]); + foreach ($lines as $lineId => $quantity) { + $fulfillment->lines()->create(['order_line_id' => (int) $lineId, 'quantity' => $quantity]); + } + $this->refreshOrderStatus($order); + event(new FulfillmentCreated($fulfillment)); + + return $fulfillment->refresh()->load('lines'); + }); + } + + /** @param array|null $tracking */ + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + { + if ($this->value($fulfillment->status) !== 'pending') { + throw new FulfillmentGuardException('Only pending fulfillments can be shipped.'); + } + $fulfillment->fill(array_filter([ + 'status' => 'shipped', + 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $tracking['tracking_number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, + 'shipped_at' => now(), + ], fn (mixed $value): bool => $value !== null))->save(); + event(new FulfillmentShipped($fulfillment)); + } + + public function markAsDelivered(Fulfillment $fulfillment): void + { + if ($this->value($fulfillment->status) !== 'shipped') { + throw new FulfillmentGuardException('Only shipped fulfillments can be delivered.'); + } + $fulfillment->update(['status' => 'delivered']); + event(new FulfillmentDelivered($fulfillment)); + } + + public function autoFulfillDigital(Order $order): ?Fulfillment + { + $order->loadMissing('lines.variant'); + if ($order->lines->isEmpty() || $order->lines->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping)) { + return null; + } + + $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'shipped_at' => now()]); + foreach ($order->lines as $line) { + $fulfillment->lines()->create(['order_line_id' => $line->id, 'quantity' => $line->quantity]); + } + $order->update(['fulfillment_status' => 'fulfilled', 'status' => 'fulfilled']); + event(new OrderFulfilled($order)); + + return $fulfillment; + } + + private function refreshOrderStatus(Order $order): void + { + $order->refresh()->load('lines.fulfillmentLines'); + $fulfilled = $order->lines->every(fn ($line): bool => $line->fulfillmentLines->sum('quantity') >= $line->quantity); + $any = $order->lines->contains(fn ($line): bool => $line->fulfillmentLines->sum('quantity') > 0); + $order->update([ + 'fulfillment_status' => $fulfilled ? 'fulfilled' : ($any ? 'partial' : 'unfulfilled'), + 'status' => $fulfilled ? 'fulfilled' : $order->status, + ]); + if ($fulfilled) { + event(new OrderFulfilled($order)); + } + } + + private function value(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..365028ea --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,84 @@ +quantity_on_hand - $item->quantity_reserved; + } + + public function checkAvailability(InventoryItem $item, int $quantity): bool + { + return $this->policy($item) === 'continue' || $this->available($item) >= $quantity; + } + + public function reserve(InventoryItem $item, int $quantity): void + { + $this->mutate($item, function (InventoryItem $locked) use ($quantity): void { + $this->positive($quantity); + + if (! $this->checkAvailability($locked, $quantity)) { + throw new InsufficientInventoryException('Not enough inventory is available.'); + } + + $locked->increment('quantity_reserved', $quantity); + }); + } + + public function release(InventoryItem $item, int $quantity): void + { + $this->mutate($item, function (InventoryItem $locked) use ($quantity): void { + $this->positive($quantity); + $locked->quantity_reserved = max(0, $locked->quantity_reserved - $quantity); + $locked->save(); + }); + } + + public function commit(InventoryItem $item, int $quantity): void + { + $this->mutate($item, function (InventoryItem $locked) use ($quantity): void { + $this->positive($quantity); + $locked->quantity_on_hand -= $quantity; + $locked->quantity_reserved = max(0, $locked->quantity_reserved - $quantity); + $locked->save(); + }); + } + + public function restock(InventoryItem $item, int $quantity): void + { + $this->mutate($item, function (InventoryItem $locked) use ($quantity): void { + $this->positive($quantity); + $locked->increment('quantity_on_hand', $quantity); + }); + } + + /** @param callable(InventoryItem): void $callback */ + private function mutate(InventoryItem $item, callable $callback): void + { + DB::transaction(function () use ($item, $callback): void { + /** @var InventoryItem $locked */ + $locked = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey()); + $callback($locked); + $item->refresh(); + }); + } + + private function policy(InventoryItem $item): string + { + return $item->policy instanceof BackedEnum ? (string) $item->policy->value : (string) $item->policy; + } + + private function positive(int $quantity): void + { + if ($quantity < 1) { + throw new \InvalidArgumentException('Quantity must be at least one.'); + } + } +} diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php new file mode 100644 index 00000000..2d3947d2 --- /dev/null +++ b/app/Services/NavigationService.php @@ -0,0 +1,43 @@ +> */ + public function buildTree(NavigationMenu $menu): array + { + return Cache::remember("navigation:{$menu->store_id}:{$menu->id}", now()->addMinutes(5), fn (): array => $menu->items() + ->orderBy('position') + ->get() + ->map(fn (NavigationItem $item): array => [ + 'id' => $item->id, + 'label' => $item->label, + 'type' => $this->value($item->type), + 'url' => $this->resolveUrl($item), + ])->all()); + } + + public function resolveUrl(NavigationItem $item): string + { + return match ($this->value($item->type)) { + 'page' => ($page = Page::withoutGlobalScopes()->find($item->resource_id)) ? '/pages/'.$page->handle : '#', + 'collection' => ($collection = ProductCollection::withoutGlobalScopes()->find($item->resource_id)) ? '/collections/'.$collection->handle : '#', + 'product' => ($product = Product::withoutGlobalScopes()->find($item->resource_id)) ? '/products/'.$product->handle : '#', + default => (string) ($item->url ?: '#'), + }; + } + + private function value(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..367b8240 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,188 @@ +lockForUpdate()->with(['cart.lines.variant.product', 'store'])->findOrFail($checkout->id); + $snapshot = (array) ($checkout->totals_json ?? []); + if (isset($snapshot['order_id'])) { + return Order::withoutGlobalScopes()->findOrFail((int) $snapshot['order_id']); + } + + $method = $this->value($checkout->payment_method); + $pending = $method === 'bank_transfer'; + $paymentResult ??= new PaymentResult(true, 'mock_manual', $pending ? 'pending' : 'captured'); + $customer = $this->resolveCustomer($checkout); + $order = Order::withoutGlobalScopes()->create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $customer?->id, + 'order_number' => $this->generateOrderNumber($checkout->store), + 'payment_method' => $method, + 'status' => $pending ? 'pending' : 'paid', + 'financial_status' => $pending ? 'pending' : 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => $snapshot['currency'] ?? $checkout->store->default_currency, + 'subtotal_amount' => (int) ($snapshot['subtotal'] ?? 0), + 'discount_amount' => (int) ($snapshot['discount'] ?? 0), + 'shipping_amount' => (int) ($snapshot['shipping'] ?? 0), + 'tax_amount' => (int) ($snapshot['tax_total'] ?? $snapshot['tax'] ?? 0), + 'total_amount' => (int) ($snapshot['total'] ?? 0), + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + foreach ($checkout->cart->lines as $line) { + $variant = $line->variant; + $title = $variant->product->title; + if (method_exists($variant, 'optionValues')) { + $labels = $variant->optionValues()->pluck('value')->all(); + $title .= $labels === [] ? '' : ' - '.implode(' / ', $labels); + } + $order->lines()->create([ + 'product_id' => $variant->product_id, + 'variant_id' => $variant->id, + 'title_snapshot' => $title, + 'sku_snapshot' => $variant->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'total_amount' => $line->line_total_amount, + 'tax_lines_json' => (array) ($snapshot['tax_lines'] ?? []), + 'discount_allocations_json' => $line->line_discount_amount > 0 + ? [['code' => $checkout->discount_code, 'amount' => $line->line_discount_amount]] + : [], + ]); + + if (! $pending && $variant->inventoryItem !== null) { + $this->inventory->commit($variant->inventoryItem, (int) $line->quantity); + } + } + + $order->payments()->create([ + 'provider' => 'mock', + 'method' => $method, + 'provider_payment_id' => $paymentResult->referenceId, + 'status' => $pending ? 'pending' : 'captured', + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => $paymentResult->raw, + ]); + + if ($checkout->discount_code !== null) { + \App\Models\Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('LOWER(code) = ?', [mb_strtolower($checkout->discount_code)]) + ->increment('usage_count'); + } + + $checkout->cart->update(['status' => 'converted']); + $checkout->status = 'completed'; + $snapshot['order_id'] = $order->id; + $checkout->totals_json = $snapshot; + $checkout->save(); + event(new OrderCreated($order)); + + if (! $pending) { + event(new OrderPaid($order)); + $this->fulfillments->autoFulfillDigital($order); + } + + return $order->refresh()->load(['lines', 'payments', 'fulfillments']); + }); + } + + public function generateOrderNumber(Store $store): string + { + $prefix = (string) (($store->settings?->settings_json['order_number_prefix'] ?? null) ?: '#'); + $last = Order::withoutGlobalScopes()->where('store_id', $store->id)->pluck('order_number') + ->map(fn (string $number): int => (int) preg_replace('/\D+/', '', $number)) + ->max() ?? 1000; + + return $prefix.max(1001, $last + 1); + } + + public function confirmBankTransfer(Order $order): void + { + if ($this->value($order->payment_method) !== 'bank_transfer' || $this->value($order->financial_status) !== 'pending') { + throw new DomainException('Only pending bank transfer orders can be confirmed.'); + } + + DB::transaction(function () use ($order): void { + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem !== null) { + $this->inventory->commit($line->variant->inventoryItem, (int) $line->quantity); + } + } + $order->payments()->where('status', 'pending')->update(['status' => 'captured']); + $order->update(['financial_status' => 'paid', 'status' => 'paid']); + event(new OrderPaid($order)); + $this->fulfillments->autoFulfillDigital($order); + }); + } + + public function cancel(Order $order, string $reason): void + { + if ($this->value($order->fulfillment_status) !== 'unfulfilled') { + throw new DomainException('Fulfilled orders cannot be cancelled.'); + } + + DB::transaction(function () use ($order, $reason): void { + if ($this->value($order->financial_status) === 'pending') { + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + if ($line->variant?->inventoryItem !== null) { + $this->inventory->release($line->variant->inventoryItem, (int) $line->quantity); + } + } + } + $order->payments()->where('status', 'pending')->update(['status' => 'failed']); + $order->update(['status' => 'cancelled', 'financial_status' => 'voided']); + event(new OrderCancelled($order, $reason)); + }); + } + + private function resolveCustomer(Checkout $checkout): ?Customer + { + if ($checkout->customer_id !== null) { + return Customer::withoutGlobalScopes()->find($checkout->customer_id); + } + if ($checkout->email === null || $checkout->email === '') { + return null; + } + + return Customer::withoutGlobalScopes()->firstOrCreate( + ['store_id' => $checkout->store_id, 'email' => mb_strtolower($checkout->email)], + ['name' => trim((string) data_get($checkout->shipping_address_json, 'first_name').' '.(string) data_get($checkout->shipping_address_json, 'last_name')), 'password_hash' => null, 'marketing_opt_in' => false], + ); + } + + private function value(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } +} diff --git a/app/Services/Payment/MockPaymentProvider.php b/app/Services/Payment/MockPaymentProvider.php new file mode 100644 index 00000000..bcac5bd0 --- /dev/null +++ b/app/Services/Payment/MockPaymentProvider.php @@ -0,0 +1,6 @@ + $details */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details = []): PaymentResult + { + return $this->provider->charge($checkout, $method, $details); + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..387f17fc --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,45 @@ + $details */ + public function charge(Checkout $checkout, PaymentMethod $method, array $details = []): PaymentResult + { + $reference = 'mock_'.Str::lower(Str::random(24)); + if ($method === PaymentMethod::BankTransfer) { + return new PaymentResult(true, $reference, 'pending', raw: ['provider' => 'mock', 'method' => $method->value]); + } + if ($method === PaymentMethod::Paypal) { + return new PaymentResult(true, $reference, 'captured', raw: ['provider' => 'mock', 'method' => $method->value]); + } + + $number = preg_replace('/\D/', '', (string) ($details['card_number'] ?? $details['number'] ?? '')); + if ($number === '4000000000000002') { + return new PaymentResult(false, $reference, 'failed', 'card_declined', 'Your card was declined.'); + } + if ($number === '4000000000009995') { + return new PaymentResult(false, $reference, 'failed', 'insufficient_funds', 'Your card has insufficient funds.'); + } + + return new PaymentResult(true, $reference, 'captured', raw: ['provider' => 'mock', 'method' => $method->value]); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + if ($amount < 1) { + throw new \InvalidArgumentException('Refund amount must be positive.'); + } + + return new RefundResult(true, 'mock_refund_'.Str::lower(Str::random(20))); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..46e9f1fb --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,87 @@ +loadMissing(['cart.lines.variant.product.collections', 'store']); + $cart = $checkout->cart; + $subtotal = (int) $cart->lines->sum(fn ($line): int => (int) $line->unit_price_amount * (int) $line->quantity); + $discountResult = new DiscountResult(0); + + if ($checkout->discount_code !== null && $checkout->discount_code !== '') { + $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart); + $discountResult = $this->discounts->calculate($discount, $subtotal, $cart->lines); + $this->applyAllocations($cart->lines, $discount, $discountResult); + } else { + foreach ($cart->lines as $line) { + $line->update(['line_discount_amount' => 0, 'line_total_amount' => $line->line_subtotal_amount]); + } + } + + $shipping = 0; + if ($checkout->shipping_method_id !== null && $this->shipping->requiresShipping($cart)) { + $rate = ShippingRate::query()->find($checkout->shipping_method_id); + $shipping = $rate === null ? 0 : (int) ($this->shipping->calculate($rate, $cart) ?? 0); + } + if ($discountResult->freeShipping) { + $shipping = 0; + } + + $lineAmounts = $cart->lines->map(fn ($line): int => max(0, (int) $line->line_subtotal_amount - (int) ($discountResult->allocations[$line->id] ?? 0)))->all(); + $settings = TaxSettings::withoutGlobalScopes()->where('store_id', $checkout->store_id)->first(); + $taxResult = $this->taxes->calculateLines($lineAmounts, $shipping, $settings, (array) ($checkout->shipping_address_json ?? [])); + $inclusive = (bool) ($settings?->prices_include_tax ?? false); + $discountedSubtotal = max(0, $subtotal - $discountResult->amount); + $total = $discountedSubtotal + $shipping + ($inclusive ? 0 : $taxResult->totalAmount); + + $result = new PricingResult( + subtotal: $subtotal, + discount: $discountResult->amount, + shipping: $shipping, + taxLines: $taxResult->taxLines, + taxTotal: $taxResult->totalAmount, + total: $total, + currency: (string) $cart->currency, + ); + + $checkout->totals_json = $result->toArray(); + $checkout->save(); + + return $result; + } + + /** @param iterable $lines */ + public function calculateSubtotal(iterable $lines): int + { + return (int) collect($lines)->sum(fn (mixed $line): int => is_array($line) + ? (int) ($line['unit_price_amount'] ?? $line['price'] ?? 0) * (int) ($line['quantity'] ?? 0) + : (int) ($line->unit_price_amount ?? $line->price_amount ?? 0) * (int) ($line->quantity ?? 0)); + } + + private function applyAllocations(iterable $lines, Discount $discount, DiscountResult $result): void + { + foreach ($lines as $line) { + $allocation = (int) ($result->allocations[$line->id] ?? 0); + $line->update([ + 'line_discount_amount' => $allocation, + 'line_total_amount' => max(0, (int) $line->line_subtotal_amount - $allocation), + ]); + } + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..aefe54d1 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,151 @@ + $data */ + public function create(Store $store, array $data): Product + { + return DB::transaction(function () use ($store, $data): Product { + $product = Product::withoutGlobalScopes()->create([ + ...Arr::only($data, ['title', 'description_html', 'vendor', 'product_type', 'tags']), + 'store_id' => $store->id, + 'handle' => $data['handle'] ?? $this->handles->generate((string) $data['title'], 'products', $store->id), + 'status' => $data['status'] ?? 'draft', + ]); + + $this->syncOptions($product, (array) ($data['options'] ?? [])); + if ($product->options()->exists()) { + $this->variants->rebuildMatrix($product); + } else { + $variantData = (array) ($data['variant'] ?? ($data['variants'][0] ?? [])); + $variant = $product->variants()->create([ + 'sku' => $variantData['sku'] ?? null, + 'barcode' => $variantData['barcode'] ?? null, + 'price_amount' => (int) ($variantData['price_amount'] ?? 0), + 'compare_at_amount' => $variantData['compare_at_amount'] ?? null, + 'currency' => $variantData['currency'] ?? $store->default_currency, + 'weight_g' => $variantData['weight_g'] ?? null, + 'requires_shipping' => $variantData['requires_shipping'] ?? true, + 'is_default' => true, + 'position' => 0, + 'status' => 'active', + ]); + InventoryItem::withoutGlobalScopes()->firstOrCreate( + ['variant_id' => $variant->id], + ['store_id' => $store->id, 'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0), 'quantity_reserved' => 0, 'policy' => $variantData['policy'] ?? 'deny'], + ); + } + + return $product->refresh()->load(['variants.inventoryItem', 'options.values']); + }); + } + + /** @param array $data */ + public function update(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data): Product { + $fields = Arr::only($data, ['title', 'description_html', 'vendor', 'product_type', 'tags']); + if (array_key_exists('handle', $data)) { + $fields['handle'] = $data['handle']; + } elseif (array_key_exists('title', $data)) { + $fields['handle'] = $this->handles->generate((string) $data['title'], 'products', (int) $product->store_id, (int) $product->id); + } + $product->update($fields); + + if (array_key_exists('options', $data)) { + $this->syncOptions($product, (array) $data['options']); + $this->variants->rebuildMatrix($product); + } + if (array_key_exists('status', $data)) { + $this->transitionStatus($product, $data['status'] instanceof ProductStatus ? $data['status'] : ProductStatus::from((string) $data['status'])); + } + + return $product->refresh()->load(['variants.inventoryItem', 'options.values']); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): void + { + $current = $this->value($product->status); + if ($current === $newStatus->value) { + return; + } + + $allowed = [ + 'draft' => ['active', 'archived'], + 'active' => ['draft', 'archived'], + 'archived' => ['draft', 'active'], + ]; + if (! in_array($newStatus->value, $allowed[$current] ?? [], true)) { + throw new InvalidProductTransitionException("Cannot transition product from {$current} to {$newStatus->value}."); + } + if ($newStatus->value === 'active' && (trim((string) $product->title) === '' || ! $product->variants()->where('price_amount', '>', 0)->exists())) { + throw new InvalidProductTransitionException('An active product needs a title and at least one priced variant.'); + } + if ($newStatus->value === 'draft' && $this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('Products with order references cannot return to draft.'); + } + + $product->status = $newStatus; + if ($newStatus->value === 'active' && $product->published_at === null) { + $product->published_at = now(); + } + $product->save(); + event(new ProductStatusChanged($product, $current, $newStatus->value)); + } + + public function delete(Product $product): void + { + if ($this->value($product->status) !== 'draft' || $this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException('Only unreferenced draft products may be deleted.'); + } + $product->delete(); + } + + /** @param list> $options */ + private function syncOptions(Product $product, array $options): void + { + if (count($options) > 3) { + throw new \InvalidArgumentException('A product may have at most three options.'); + } + $product->options()->delete(); + foreach (array_values($options) as $position => $optionData) { + $option = $product->options()->create(['name' => $optionData['name'], 'position' => $position]); + foreach (array_values((array) ($optionData['values'] ?? [])) as $valuePosition => $value) { + $option->values()->create(['value' => is_array($value) ? $value['value'] : $value, 'position' => $valuePosition]); + } + } + } + + private function hasOrderReferences(Product $product): bool + { + return OrderLine::query()->where('product_id', $product->id) + ->orWhereIn('variant_id', ProductVariant::query()->where('product_id', $product->id)->select('id')) + ->exists(); + } + + private function value(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..a28bf2a8 --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,66 @@ +|null $lines */ + public function create( + Order $order, + Payment $payment, + int $amount, + ?string $reason = null, + bool $restock = false, + ?array $lines = null, + ): Refund { + $refunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); + $remaining = min((int) $order->total_amount, (int) $payment->amount) - $refunded; + if ($amount < 1 || $amount > $remaining) { + throw new DomainException('The refund amount exceeds the refundable balance.'); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $lines): Refund { + $result = $this->provider->refund($payment, $amount); + $refund = $order->refunds()->create([ + 'payment_id' => $payment->id, + 'amount' => $amount, + 'reason' => $reason, + 'status' => $result->success ? 'processed' : 'failed', + 'provider_refund_id' => $result->providerRefundId, + ]); + + $total = (int) $order->refunds()->where('status', 'processed')->sum('amount'); + $full = $total >= $order->total_amount; + $order->update(['financial_status' => $full ? 'refunded' : 'partially_refunded', 'status' => $full ? 'refunded' : $order->status]); + if ($full) { + $payment->update(['status' => 'refunded']); + } + + if ($restock) { + $order->load('lines.variant.inventoryItem'); + foreach ($order->lines as $line) { + $quantity = $lines === null ? (int) $line->quantity : (int) ($lines[$line->id] ?? 0); + if ($quantity > 0 && $line->variant?->inventoryItem !== null) { + $this->inventory->restock($line->variant->inventoryItem, min($quantity, (int) $line->quantity)); + } + } + } + event(new OrderRefunded($order, $refund)); + + return $refund->refresh(); + }); + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..ab798b10 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,108 @@ + $filters */ + public function search(Store $store, string $query, array $filters = [], int $perPage = 12): LengthAwarePaginator + { + $query = trim($query); + $page = max(1, LengthAwarePaginator::resolveCurrentPage()); + $ids = $this->matchingIds($store, $query); + $products = Product::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->whereNotNull('published_at') + ->when($ids !== null, fn ($builder) => $builder->whereIn('id', $ids)) + ->when($ids === null && $query !== '', fn ($builder) => $builder->where(function ($nested) use ($query): void { + $nested->where('title', 'like', "%{$query}%") + ->orWhere('description_html', 'like', "%{$query}%") + ->orWhere('vendor', 'like', "%{$query}%"); + })) + ->when(isset($filters['vendor']), fn ($builder) => $builder->where('vendor', $filters['vendor'])) + ->with(['variants', 'media']) + ->get(); + + if ($ids !== null) { + $order = array_flip($ids); + $products = $products->sortBy(fn (Product $product): int => $order[$product->id] ?? PHP_INT_MAX)->values(); + } + $total = $products->count(); + $items = $products->forPage($page, $perPage)->values(); + + SearchQuery::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'query' => $query, + 'filters_json' => $filters, + 'results_count' => $total, + ]); + + return new LengthAwarePaginator($items, $total, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()]); + } + + /** @return Collection */ + public function autocomplete(Store $store, string $prefix, int $limit = 8): Collection + { + $results = $this->search($store, $prefix, [], $limit); + + return collect($results->items()); + } + + public function syncProduct(Product $product): void + { + try { + DB::table('products_fts')->where('product_id', $product->id)->delete(); + DB::table('products_fts')->insert([ + 'store_id' => $product->store_id, + 'product_id' => $product->id, + 'title' => $product->title, + 'description' => strip_tags((string) $product->description_html), + 'vendor' => (string) $product->vendor, + 'product_type' => (string) $product->product_type, + 'tags' => implode(' ', (array) $product->tags), + ]); + } catch (Throwable) { + // FTS5 can be unavailable in minimal SQLite builds; LIKE remains a safe fallback. + } + } + + public function removeProduct(int $productId): void + { + try { + DB::table('products_fts')->where('product_id', $productId)->delete(); + } catch (Throwable) { + // See syncProduct fallback. + } + } + + /** @return list|null */ + private function matchingIds(Store $store, string $query): ?array + { + if ($query === '') { + return null; + } + + try { + $tokens = preg_split('/\s+/', preg_replace('/[^\pL\pN\s-]+/u', ' ', $query) ?: '') ?: []; + $match = implode(' ', array_map(fn (string $token): string => '"'.str_replace('"', '""', $token).'"*', array_filter($tokens))); + if ($match === '') { + return []; + } + + $rows = DB::select("SELECT product_id FROM products_fts WHERE products_fts MATCH ? AND store_id = ? ORDER BY rank", [$match, $store->id]); + + return array_map(fn (object $row): int => (int) $row->product_id, $rows); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..87cb58e9 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,114 @@ + $address @return Collection */ + public function getAvailableRates(Store $store, array $address, ?Cart $cart = null): Collection + { + if ($cart !== null && ! $this->requiresShipping($cart)) { + return collect(); + } + + $zone = $this->matchingZone($store, $address); + if ($zone === null) { + return collect(); + } + + return $zone->rates()->where('is_active', true)->get() + ->filter(function (ShippingRate $rate) use ($cart): bool { + $amount = $cart === null ? $this->configuredAmount($rate) : $this->calculate($rate, $cart); + if ($amount === null) { + return false; + } + $rate->setAttribute('calculated_amount', $amount); + + return true; + })->values(); + } + + /** @param array $address */ + public function matchingZone(Store $store, array $address): ?ShippingZone + { + $country = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + $region = strtoupper((string) ($address['province_code'] ?? '')); + + return ShippingZone::withoutGlobalScopes()->where('store_id', $store->id)->get() + ->map(function (ShippingZone $zone) use ($country, $region): array { + $countries = array_map('strtoupper', (array) $zone->countries_json); + $regions = array_map('strtoupper', (array) $zone->regions_json); + $countryMatch = in_array($country, $countries, true); + $regionMatch = $region !== '' && in_array($region, $regions, true); + + return ['zone' => $zone, 'specificity' => $countryMatch ? ($regionMatch ? 2 : 1) : -1]; + }) + ->filter(fn (array $match): bool => $match['specificity'] >= 0) + ->sort(function (array $left, array $right): int { + return $right['specificity'] <=> $left['specificity'] ?: $left['zone']->id <=> $right['zone']->id; + }) + ->first()['zone'] ?? null; + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + if (! $this->requiresShipping($cart)) { + return 0; + } + + $config = (array) $rate->config_json; + + return match ($this->value($rate->type)) { + 'flat' => (int) ($config['amount'] ?? 0), + 'weight' => $this->rangeAmount((array) ($config['ranges'] ?? []), $this->weight($cart), 'min_g', 'max_g'), + 'price' => $this->rangeAmount((array) ($config['ranges'] ?? []), (int) $cart->lines->sum('line_subtotal_amount'), 'min_amount', 'max_amount'), + 'carrier' => (int) ($config['fallback_amount'] ?? 999), + default => null, + }; + } + + public function requiresShipping(Cart $cart): bool + { + $cart->loadMissing('lines.variant'); + + return $cart->lines->contains(fn ($line): bool => (bool) $line->variant?->requires_shipping); + } + + private function weight(Cart $cart): int + { + return (int) $cart->lines->sum(fn ($line): int => $line->variant?->requires_shipping + ? (int) ($line->variant->weight_g ?? 0) * (int) $line->quantity + : 0); + } + + /** @param list> $ranges */ + private function rangeAmount(array $ranges, int $value, string $minimumKey, string $maximumKey): ?int + { + foreach ($ranges as $range) { + if ($value >= (int) ($range[$minimumKey] ?? 0) && (! isset($range[$maximumKey]) || $value <= (int) $range[$maximumKey])) { + return (int) ($range['amount'] ?? 0); + } + } + + return null; + } + + private function configuredAmount(ShippingRate $rate): ?int + { + $config = (array) $rate->config_json; + + return $this->value($rate->type) === 'flat' ? (int) ($config['amount'] ?? 0) : 0; + } + + private function value(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } +} diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php new file mode 100644 index 00000000..e3a4a611 --- /dev/null +++ b/app/Services/Tax/ManualTaxProvider.php @@ -0,0 +1,39 @@ +taxSettings === null) { + return new TaxCalculationResult([], 0); + } + + $config = (array) $request->taxSettings->config_json; + $rate = (int) ($config['rate_bps'] ?? $config['default_rate_bps'] ?? $config['rate'] ?? 0); + if ($rate < 1) { + return new TaxCalculationResult([], 0); + } + + $inclusive = (bool) $request->taxSettings->prices_include_tax; + $tax = 0; + foreach ($request->lineItems as $amount) { + $tax += $inclusive + ? $amount - intdiv($amount * 10000, 10000 + $rate) + : (int) round($amount * $rate / 10000); + } + if ($request->shippingAmount > 0) { + $tax += $inclusive + ? $request->shippingAmount - intdiv($request->shippingAmount * 10000, 10000 + $rate) + : (int) round($request->shippingAmount * $rate / 10000); + } + + return new TaxCalculationResult([new TaxLine((string) ($config['label'] ?? 'Tax'), $rate, $tax)], $tax); + } +} diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php new file mode 100644 index 00000000..f3ed4086 --- /dev/null +++ b/app/Services/Tax/StripeTaxProvider.php @@ -0,0 +1,21 @@ +taxSettings?->config_json ?? []); + if (($fallback['fallback'] ?? 'allow') === 'block') { + throw new RuntimeException('The configured tax provider is unavailable.'); + } + + return new TaxCalculationResult([], 0); + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..4bc3b73e --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,45 @@ +calculateLines([$amount], 0, $settings, $address); + } + + /** @param list $lineAmounts @param array $address */ + public function calculateLines(array $lineAmounts, int $shippingAmount, mixed $settings, array $address = []): TaxCalculationResult + { + $request = new TaxCalculationRequest($lineAmounts, $shippingAmount, $address, $settings); + $mode = $settings?->mode instanceof BackedEnum ? $settings->mode->value : ($settings?->mode ?? 'manual'); + + return $mode === 'provider' ? $this->stripe->calculate($request) : $this->manual->calculate($request); + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($grossAmount < 1 || $rateBasisPoints < 1) { + return 0; + } + + return $grossAmount - intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + return $rateBasisPoints < 1 ? 0 : (int) round($netAmount * $rateBasisPoints / 10000); + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php new file mode 100644 index 00000000..0f8064b7 --- /dev/null +++ b/app/Services/ThemeSettingsService.php @@ -0,0 +1,25 @@ + */ + public function forStore(Store $store): array + { + return Cache::remember("theme-settings:{$store->id}", now()->addMinutes(5), function () use ($store): array { + $theme = Theme::withoutGlobalScopes()->where('store_id', $store->id)->where('status', 'published')->with('settings')->first(); + + return (array) ($theme?->settings?->settings_json ?? [ + 'primary_color' => '#0f766e', + 'accent_color' => '#f59e0b', + 'font_heading' => 'Inter', + 'announcement' => 'Free shipping on orders over 75.00 EUR', + ]); + }); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..30ec6ed1 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,93 @@ +load(['options.values', 'variants.optionValues', 'variants.inventoryItem']); + $groups = $product->options->sortBy('position')->map(fn ($option) => $option->values->sortBy('position')->pluck('id')->all())->all(); + $combinations = $groups === [] ? [[]] : $this->cartesian(array_values($groups)); + $existing = $product->variants->keyBy(fn (ProductVariant $variant): string => $this->key($variant->optionValues->modelKeys())); + $template = $product->variants->first(); + $desiredKeys = []; + + foreach ($combinations as $position => $combination) { + $key = $this->key($combination); + $desiredKeys[] = $key; + if ($existing->has($key)) { + $existing[$key]->update(['position' => $position, 'status' => 'active', 'is_default' => $groups === []]); + continue; + } + + $variant = $product->variants()->create([ + 'sku' => null, + 'barcode' => null, + 'price_amount' => (int) ($template?->price_amount ?? 0), + 'compare_at_amount' => $template?->compare_at_amount, + 'currency' => $template?->currency ?? $product->store?->default_currency ?? 'USD', + 'weight_g' => $template?->weight_g, + 'requires_shipping' => $template?->requires_shipping ?? true, + 'is_default' => $groups === [], + 'position' => $position, + 'status' => 'active', + ]); + if ($combination !== []) { + $variant->optionValues()->sync($combination); + } + InventoryItem::withoutGlobalScopes()->create([ + 'store_id' => $product->store_id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]); + } + + foreach ($existing as $key => $variant) { + if (in_array($key, $desiredKeys, true)) { + continue; + } + if (OrderLine::query()->where('variant_id', $variant->id)->exists()) { + $variant->update(['status' => 'archived']); + } else { + $variant->delete(); + } + } + }); + } + + /** @param list> $groups @return list> */ + private function cartesian(array $groups): array + { + $result = [[]]; + foreach ($groups as $group) { + $next = []; + foreach ($result as $prefix) { + foreach ($group as $value) { + $next[] = [...$prefix, $value]; + } + } + $result = $next; + } + + return $result; + } + + /** @param array $ids */ + private function key(array $ids): string + { + sort($ids); + + return implode(':', $ids); + } +} diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 00000000..ec81e96f --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,48 @@ + $payload */ + public function dispatch(Store $store, string $eventType, array $payload): void + { + WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('event_type', $eventType) + ->where('status', 'active') + ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void { + $delivery = $subscription->deliveries()->create([ + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 0, + 'status' => 'pending', + ]); + DeliverWebhook::dispatch($delivery, $eventType, $payload); + }); + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac('sha256', $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + return hash_equals($this->sign($payload, $secret), $signature); + } + + public function recordFailure(WebhookDelivery $delivery): void + { + $subscription = $delivery->subscription; + $recent = $subscription->deliveries()->latest('id')->limit(5)->get()->pluck('status'); + if ($recent->count() === 5 && $recent->every(fn (mixed $status): bool => ($status instanceof \BackedEnum ? $status->value : $status) === 'failed')) { + $subscription->update(['status' => 'paused']); + } + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..64f24989 --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,37 @@ +where('store_id', $storeId) + ->where('handle', $candidate); + + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + + if (! $query->exists()) { + return $candidate; + } + + $candidate = $base.'-'.(++$suffix); + } while (true); + } +} diff --git a/app/ValueObjects/Address.php b/app/ValueObjects/Address.php new file mode 100644 index 00000000..28fbbe9b --- /dev/null +++ b/app/ValueObjects/Address.php @@ -0,0 +1,71 @@ + $data */ + public static function fromArray(array $data): self + { + return new self( + firstName: (string) ($data['first_name'] ?? ''), + lastName: (string) ($data['last_name'] ?? ''), + address1: (string) ($data['address1'] ?? ''), + city: (string) ($data['city'] ?? ''), + countryCode: strtoupper((string) ($data['country_code'] ?? $data['country'] ?? '')), + postalCode: (string) ($data['postal_code'] ?? $data['zip'] ?? ''), + company: self::nullableString($data['company'] ?? null), + address2: self::nullableString($data['address2'] ?? null), + province: self::nullableString($data['province'] ?? null), + provinceCode: self::nullableString($data['province_code'] ?? null), + phone: self::nullableString($data['phone'] ?? null), + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'first_name' => $this->firstName, + 'last_name' => $this->lastName, + 'company' => $this->company, + 'address1' => $this->address1, + 'address2' => $this->address2, + 'city' => $this->city, + 'province' => $this->province, + 'province_code' => $this->provinceCode, + 'country' => $this->countryCode, + 'country_code' => $this->countryCode, + 'postal_code' => $this->postalCode, + 'zip' => $this->postalCode, + 'phone' => $this->phone, + ]; + } + + /** @return array */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + private static function nullableString(mixed $value): ?string + { + return $value === null || $value === '' ? null : (string) $value; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..c4a2ce04 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,13 @@ + $allocations */ + public function __construct( + public int $amount, + public array $allocations = [], + public bool $freeShipping = false, + ) {} +} diff --git a/app/ValueObjects/DiscountValidationResult.php b/app/ValueObjects/DiscountValidationResult.php new file mode 100644 index 00000000..36262db0 --- /dev/null +++ b/app/ValueObjects/DiscountValidationResult.php @@ -0,0 +1,15 @@ + $raw */ + public function __construct( + public bool $success, + public string $referenceId, + public string $status, + public ?string $errorCode = null, + public ?string $errorMessage = null, + public array $raw = [], + ) {} +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..cbba4798 --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,41 @@ + $taxLines */ + public function __construct( + public int $subtotal, + public int $discount, + public int $shipping, + public array $taxLines, + public int $taxTotal, + public int $total, + public string $currency, + ) {} + + /** @return array{subtotal: int, discount: int, discounted_subtotal: int, shipping: int, tax_lines: list, tax_total: int, tax: int, total: int, currency: string} */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'discounted_subtotal' => max(0, $this->subtotal - $this->discount), + 'shipping' => $this->shipping, + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'tax_total' => $this->taxTotal, + 'tax' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + ]; + } + + /** @return array */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..77bee4ea --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,12 @@ + $lineItems @param array $address */ + public function __construct( + public array $lineItems, + public int $shippingAmount, + public array $address, + public ?TaxSettings $taxSettings, + ) {} +} diff --git a/app/ValueObjects/TaxCalculationResult.php b/app/ValueObjects/TaxCalculationResult.php new file mode 100644 index 00000000..a6833f94 --- /dev/null +++ b/app/ValueObjects/TaxCalculationResult.php @@ -0,0 +1,20 @@ + $taxLines */ + public function __construct(public array $taxLines, public int $totalAmount) {} + + /** @return array{tax_lines: list, total_amount: int} */ + public function jsonSerialize(): array + { + return [ + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'total_amount' => $this->totalAmount, + ]; + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..c5dab54c --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,26 @@ + $this->name, 'rate' => $this->rate, 'amount' => $this->amount]; + } + + /** @return array{name: string, rate: int, amount: int} */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/app/ValueObjects/WebhookResult.php b/app/ValueObjects/WebhookResult.php new file mode 100644 index 00000000..3b76ed88 --- /dev/null +++ b/app/ValueObjects/WebhookResult.php @@ -0,0 +1,8 @@ +withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'store.resolve' => ResolveStore::class, + 'role.check' => CheckStoreRole::class, + 'customer.auth' => CustomerAuthenticate::class, + 'abilities' => CheckAbilities::class, + ]); + $middleware->group('storefront', [ResolveStore::class]); + $middleware->group('admin.store', [ResolveStore::class]); + $middleware->redirectGuestsTo(fn ($request): string => $request->is('account/*') ? '/account/login' : '/admin/login'); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/composer.json b/composer.json index 1f848aaf..51265525 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.9.0", "livewire/livewire": "^4.0" diff --git a/composer.lock b/composer.lock index e4255dbd..53f07a3b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e4aa7ad38dac6834e5ff6bf65b1cdf23", + "content-hash": "4585a2e36ea09924c8fdf8a7b3daaaf1", "packages": [ { "name": "bacon/bacon-qr-code", @@ -9974,5 +9974,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..fdce91d2 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,10 @@ 'driver' => 'session', 'provider' => 'users', ], + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], ], /* @@ -64,6 +68,10 @@ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), ], + 'customers' => [ + 'driver' => 'tenant-customers', + 'model' => App\Models\Customer::class, + ], // 'users' => [ // 'driver' => 'database', @@ -97,6 +105,12 @@ 'expire' => 60, 'throttle' => 60, ], + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/database.php b/config/database.php index df933e7f..90f77fcf 100644 --- a/config/database.php +++ b/config/database.php @@ -34,12 +34,14 @@ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'database' => (($sqlitePath = env('DB_DATABASE')) === null || $sqlitePath === '') + ? database_path('database.sqlite') + : (($sqlitePath === ':memory:' || str_starts_with($sqlitePath, DIRECTORY_SEPARATOR)) ? $sqlitePath : base_path($sqlitePath)), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, + 'busy_timeout' => 5000, + 'journal_mode' => 'WAL', + 'synchronous' => 'NORMAL', 'transaction_mode' => 'DEFERRED', ], diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..8f963768 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,51 @@ + Store::factory(), + 'type' => fake()->randomElement([ + 'page_view', 'product_view', 'add_to_cart', 'remove_from_cart', + 'checkout_started', 'checkout_completed', 'search', + ]), + 'session_id' => (string) Str::uuid(), + 'customer_id' => null, + 'properties_json' => [ + 'url' => '/'.fake()->slug(), + 'referrer' => fake()->boolean(40) ? fake()->url() : null, + ], + 'client_event_id' => (string) Str::uuid(), + 'occurred_at' => fake()->dateTimeBetween('-7 days'), + 'created_at' => fake()->dateTimeBetween('-7 days'), + ]; + } + + public function pageView(): static + { + return $this->state(fn (array $attributes) => ['type' => 'page_view']); + } + + public function productView(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'product_view', + 'properties_json' => ['product_id' => 1, 'product_title' => fake()->words(3, true)], + ]); + } + + public function addToCart(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'add_to_cart', + 'properties_json' => ['variant_id' => 1, 'quantity' => 1], + ]); + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..2ee834f8 --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,38 @@ + Store::factory(), + 'customer_id' => null, + 'currency' => 'EUR', + 'cart_version' => 1, + 'status' => 'active', + ]; + } + + public function forCustomer(?Customer $customer = null): static + { + return $this->state(fn (array $attributes) => [ + 'customer_id' => $customer?->getKey() ?? Customer::factory(), + ]); + } + + public function converted(): static + { + return $this->state(fn (array $attributes) => ['status' => 'converted']); + } + + public function abandoned(): static + { + return $this->state(fn (array $attributes) => ['status' => 'abandoned']); + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..4aa7eb67 --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,23 @@ + Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => fake()->numberBetween(1, 5), + 'unit_price_amount' => fake()->numberBetween(999, 19999), + 'line_subtotal_amount' => fn (array $attributes): int => $attributes['unit_price_amount'] * $attributes['quantity'], + 'line_discount_amount' => 0, + 'line_total_amount' => fn (array $attributes): int => $attributes['line_subtotal_amount'] - $attributes['line_discount_amount'], + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..53f6bffd --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,61 @@ + Store::factory(), + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => 'started', + 'email' => fake()->safeEmail(), + 'shipping_address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'country_code' => 'DE', + 'zip' => fake()->postcode(), + ], + 'billing_address_json' => null, + 'shipping_method_id' => null, + 'payment_method' => null, + 'discount_code' => null, + 'tax_provider_snapshot_json' => null, + 'totals_json' => null, + 'expires_at' => now()->addDay(), + ]; + } + + public function completed(): static + { + return $this->state(fn (array $attributes) => ['status' => 'completed']); + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => ['status' => 'expired', 'expires_at' => now()->subHour()]); + } + + public function withCreditCard(): static + { + return $this->state(fn (array $attributes) => ['payment_method' => 'credit_card']); + } + + public function withPaypal(): static + { + return $this->state(fn (array $attributes) => ['payment_method' => 'paypal']); + } + + public function withBankTransfer(): static + { + return $this->state(fn (array $attributes) => ['payment_method' => 'bank_transfer']); + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..85c01c11 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,39 @@ +unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'title' => Str::title($title), + 'handle' => Str::slug($title), + 'description_html' => '

'.fake()->sentence().'

', + 'type' => 'manual', + 'status' => 'active', + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => ['status' => 'draft']); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => ['status' => 'archived']); + } + + public function automated(): static + { + return $this->state(fn (array $attributes) => ['type' => 'automated']); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..d7491913 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,37 @@ + Customer::factory(), + 'label' => fake()->randomElement(['Home', 'Work', 'Other']), + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => '', + 'address1' => fake()->streetAddress(), + 'address2' => '', + 'city' => fake()->city(), + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ], + 'is_default' => false, + ]; + } + + public function default(): static + { + return $this->state(fn (array $attributes) => ['is_default' => true]); + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..4cb2a185 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,33 @@ + Store::factory(), + 'email' => fake()->unique()->safeEmail(), + 'password_hash' => static::$password ??= Hash::make('password'), + 'name' => fake()->name(), + 'marketing_opt_in' => fake()->boolean(30), + ]; + } + + public function guest(): static + { + return $this->state(fn (array $attributes) => ['password_hash' => null]); + } + + public function optedInMarketing(): static + { + return $this->state(fn (array $attributes) => ['marketing_opt_in' => true]); + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..56f72e9e --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,63 @@ + Store::factory(), + 'type' => 'code', + 'code' => fake()->unique()->bothify('????##'), + 'value_type' => 'percent', + 'value_amount' => 10, + 'starts_at' => now()->subMonth(), + 'ends_at' => now()->addYear(), + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => 'active', + ]; + } + + public function fixed(int $amountCents): static + { + return $this->state(fn (array $attributes) => ['value_type' => 'fixed', 'value_amount' => $amountCents]); + } + + public function freeShipping(): static + { + return $this->state(fn (array $attributes) => ['value_type' => 'free_shipping', 'value_amount' => 0]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'starts_at' => now()->subYear(), 'ends_at' => now()->subDay(), 'status' => 'expired', + ]); + } + + public function maxedOut(): static + { + return $this->state(fn (array $attributes) => ['usage_limit' => 5, 'usage_count' => 5]); + } + + public function automatic(): static + { + return $this->state(fn (array $attributes) => ['type' => 'automatic', 'code' => null]); + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => ['status' => 'draft']); + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => ['status' => 'disabled', 'starts_at' => now()->subDay()]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..0ed59df2 --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,35 @@ +bothify('??########'); + + return [ + 'order_id' => Order::factory(), + 'status' => 'shipped', + 'tracking_company' => fake()->randomElement(['DHL', 'UPS', 'FedEx', 'DPD']), + 'tracking_number' => $trackingNumber, + 'tracking_url' => 'https://tracking.example.com/'.$trackingNumber, + 'shipped_at' => now(), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'pending', 'tracking_number' => null, 'tracking_url' => null, 'shipped_at' => null, + ]); + } + + public function delivered(): static + { + return $this->state(fn (array $attributes) => ['status' => 'delivered']); + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..08263bea --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,19 @@ + Fulfillment::factory(), + 'order_line_id' => OrderLine::factory(), + 'quantity' => 1, + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..98b13f25 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,36 @@ + Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => fake()->numberBetween(0, 100), + 'quantity_reserved' => 0, + 'policy' => 'deny', + ]; + } + + public function outOfStock(): static + { + return $this->state(fn (array $attributes) => ['quantity_on_hand' => 0]); + } + + public function continuePolicy(): static + { + return $this->state(fn (array $attributes) => ['policy' => 'continue']); + } + + public function lowStock(): static + { + return $this->state(fn (array $attributes) => ['quantity_on_hand' => fake()->numberBetween(1, 3)]); + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..24d4f68b --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,36 @@ + NavigationMenu::factory(), + 'type' => 'link', + 'label' => fake()->words(2, true), + 'url' => '/', + 'resource_id' => null, + 'position' => 0, + ]; + } + + public function page(int $pageId): static + { + return $this->state(fn (array $attributes) => ['type' => 'page', 'url' => null, 'resource_id' => $pageId]); + } + + public function collection(int $collectionId): static + { + return $this->state(fn (array $attributes) => ['type' => 'collection', 'url' => null, 'resource_id' => $collectionId]); + } + + public function product(int $productId): static + { + return $this->state(fn (array $attributes) => ['type' => 'product', 'url' => null, 'resource_id' => $productId]); + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..a4c8a060 --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,18 @@ + Store::factory(), + 'handle' => fake()->unique()->slug(2), + 'title' => fake()->words(2, true), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..c0cf8fcb --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,95 @@ + fake()->firstName(), + 'last_name' => fake()->lastName(), + 'company' => '', + 'address1' => fake()->streetAddress(), + 'address2' => '', + 'city' => fake()->city(), + 'province' => '', + 'province_code' => '', + 'country' => 'Germany', + 'country_code' => 'DE', + 'zip' => fake()->postcode(), + 'phone' => fake()->phoneNumber(), + ]; + + return [ + 'store_id' => Store::factory(), + 'customer_id' => Customer::factory(), + 'order_number' => '#'.fake()->unique()->numberBetween(1001, 999999), + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'currency' => 'EUR', + 'subtotal_amount' => 4998, + 'discount_amount' => 0, + 'shipping_amount' => 499, + 'tax_amount' => 798, + 'total_amount' => 5497, + 'email' => fake()->safeEmail(), + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => now(), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => ['status' => 'pending', 'financial_status' => 'pending']); + } + + public function pendingBankTransfer(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'pending', 'financial_status' => 'pending', 'payment_method' => 'bank_transfer', + ]); + } + + public function fulfilled(): static + { + return $this->state(fn (array $attributes) => ['status' => 'fulfilled', 'fulfillment_status' => 'fulfilled']); + } + + public function cancelled(): static + { + return $this->state(fn (array $attributes) => ['status' => 'cancelled', 'financial_status' => 'refunded']); + } + + public function refunded(): static + { + return $this->state(fn (array $attributes) => ['status' => 'refunded', 'financial_status' => 'refunded']); + } + + public function partiallyFulfilled(): static + { + return $this->state(fn (array $attributes) => ['fulfillment_status' => 'partial']); + } + + public function creditCard(): static + { + return $this->state(fn (array $attributes) => ['payment_method' => 'credit_card']); + } + + public function paypal(): static + { + return $this->state(fn (array $attributes) => ['payment_method' => 'paypal']); + } + + public function bankTransfer(): static + { + return $this->state(fn (array $attributes) => ['payment_method' => 'bank_transfer']); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..8ae3a737 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,27 @@ + Order::factory(), + 'product_id' => Product::factory(), + 'variant_id' => ProductVariant::factory(), + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => fake()->bothify('SKU-####-???'), + 'quantity' => fake()->numberBetween(1, 3), + 'unit_price_amount' => fake()->numberBetween(999, 19999), + 'total_amount' => fn (array $attributes): int => $attributes['unit_price_amount'] * $attributes['quantity'], + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..19afbfdb --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,16 @@ + fake()->company(), + 'billing_email' => fake()->unique()->companyEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..b96a9e07 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,34 @@ +unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => Str::title($title), + 'handle' => Str::slug($title), + 'body_html' => '

'.fake()->sentence().'

'.fake()->paragraphs(3, true).'

', + 'status' => 'published', + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => ['status' => 'draft', 'published_at' => null]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => ['status' => 'archived']); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..2ce36439 --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,53 @@ + Order::factory(), + 'provider' => 'mock', + 'method' => 'credit_card', + 'provider_payment_id' => 'mock_'.fake()->regexify('[A-Za-z0-9]{20}'), + 'status' => 'captured', + 'amount' => fake()->numberBetween(999, 99999), + 'currency' => 'EUR', + 'raw_json_encrypted' => null, + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => ['status' => 'pending']); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => ['status' => 'failed']); + } + + public function refunded(): static + { + return $this->state(fn (array $attributes) => ['status' => 'refunded']); + } + + public function creditCard(): static + { + return $this->state(fn (array $attributes) => ['method' => 'credit_card']); + } + + public function paypal(): static + { + return $this->state(fn (array $attributes) => ['method' => 'paypal']); + } + + public function bankTransfer(): static + { + return $this->state(fn (array $attributes) => ['method' => 'bank_transfer']); + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..9245df40 --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,53 @@ +unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => Str::title($title), + 'handle' => Str::slug($title), + 'status' => 'active', + 'description_html' => '

'.fake()->paragraph().'

'.fake()->paragraph().'

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']), + 'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)), + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => ['status' => 'draft', 'published_at' => null]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => ['status' => 'archived']); + } + + public function withVariants(int $count = 1): static + { + return $this->afterCreating(function (Product $product) use ($count): void { + ProductVariant::factory()->count($count)->for($product)->create(); + }); + } + + public function withDefaultVariant(int $price = 1000): static + { + return $this->afterCreating(function (Product $product) use ($price): void { + ProductVariant::factory()->default()->for($product)->create(['price_amount' => $price]); + }); + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..8f98df7a --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,49 @@ + Product::factory(), + 'sku' => fake()->unique()->bothify('SKU-####-???'), + 'barcode' => fake()->ean13(), + 'price_amount' => fake()->numberBetween(999, 19999), + 'compare_at_amount' => null, + 'currency' => 'EUR', + 'weight_g' => fake()->numberBetween(100, 5000), + 'requires_shipping' => true, + 'is_default' => false, + 'position' => 0, + 'status' => 'active', + ]; + } + + public function onSale(): static + { + return $this->state(fn (array $attributes) => [ + 'price_amount' => fake()->numberBetween(9999, 19999), + 'compare_at_amount' => fake()->numberBetween(20000, 39999), + ]); + } + + public function digital(): static + { + return $this->state(fn (array $attributes) => ['requires_shipping' => false, 'weight_g' => 0]); + } + + public function default(): static + { + return $this->state(fn (array $attributes) => ['is_default' => true]); + } + + public function archived(): static + { + return $this->state(fn (array $attributes) => ['status' => 'archived']); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..bb9213bb --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,32 @@ + Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => fake()->numberBetween(999, 19999), + 'reason' => fake()->sentence(), + 'status' => 'processed', + 'provider_refund_id' => 'mock_re_'.fake()->regexify('[A-Za-z0-9]{20}'), + ]; + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => ['status' => 'pending', 'provider_refund_id' => null]); + } + + public function failed(): static + { + return $this->state(fn (array $attributes) => ['status' => 'failed']); + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..4aebe032 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,37 @@ + ShippingZone::factory(), + 'name' => fake()->randomElement(['Standard', 'Express', 'Economy']), + 'type' => 'flat', + 'config_json' => ['amount' => 499], + 'is_active' => true, + ]; + } + + public function inactive(): static + { + return $this->state(fn (array $attributes) => ['is_active' => false]); + } + + public function weightBased(): static + { + return $this->state(fn (array $attributes) => [ + 'type' => 'weight', + 'config_json' => ['ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 399], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 699], + ['min_g' => 2001, 'amount' => 1299], + ]], + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..9d95d609 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,19 @@ + Store::factory(), + 'name' => fake()->country(), + 'countries_json' => ['DE'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..3911354f --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,35 @@ + Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => 'storefront', + 'is_primary' => true, + 'tls_mode' => 'managed', + ]; + } + + public function admin(): static + { + return $this->state(fn (array $attributes) => ['type' => 'admin']); + } + + public function api(): static + { + return $this->state(fn (array $attributes) => ['type' => 'api']); + } + + public function secondary(): static + { + return $this->state(fn (array $attributes) => ['is_primary' => false]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..f6c3fa88 --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,27 @@ + Organization::factory(), + 'name' => fake()->company().' Store', + 'handle' => fake()->unique()->slug(2), + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]; + } + + public function suspended(): static + { + return $this->state(fn (array $attributes) => ['status' => 'suspended']); + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..1ccad6d6 --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,25 @@ + Store::factory(), + 'name' => 'Default Theme', + 'version' => '1.0.0', + 'status' => 'published', + 'published_at' => now(), + ]; + } + + public function draft(): static + { + return $this->state(fn (array $attributes) => ['status' => 'draft', 'published_at' => null]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 80da5ac7..6add344f 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -27,7 +27,9 @@ public function definition(): array 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password_hash' => static::$password ??= Hash::make('password'), + 'status' => 'active', + 'last_login_at' => fake()->dateTimeBetween('-30 days'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, @@ -56,4 +58,11 @@ public function withTwoFactor(): static 'two_factor_confirmed_at' => now(), ]); } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => 'disabled', + ]); + } } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9e..7b45db4a 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -13,12 +13,17 @@ public function up(): void { Schema::create('users', function (Blueprint $table) { $table->id(); + $table->string('email'); + $table->string('password_hash'); $table->string('name'); - $table->string('email')->unique(); + $table->enum('status', ['active', 'disabled'])->default('active'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->timestamp('last_login_at')->nullable(); $table->rememberToken(); $table->timestamps(); + + $table->unique('email', 'idx_users_email'); + $table->index('status', 'idx_users_status'); }); Schema::create('password_reset_tokens', function (Blueprint $table) { diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php index 187d974d..a008f488 100644 --- a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php +++ b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php @@ -12,7 +12,7 @@ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_secret')->after('password_hash')->nullable(); $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); }); diff --git a/database/migrations/2026_01_01_000100_create_foundation_tables.php b/database/migrations/2026_01_01_000100_create_foundation_tables.php new file mode 100644 index 00000000..bfd97271 --- /dev/null +++ b/database/migrations/2026_01_01_000100_create_foundation_tables.php @@ -0,0 +1,103 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + + Schema::create('apps', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->enum('status', ['active', 'disabled'])->default('active'); + $table->timestamp('created_at')->nullable(); + $table->index('status', 'idx_apps_status'); + }); + + Schema::create('stores', function (Blueprint $table) { + $table->id(); + $table->foreignId('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('handle'); + $table->enum('status', ['active', 'suspended'])->default('active'); + $table->string('default_currency')->default('USD'); + $table->string('default_locale')->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + $table->unique('handle', 'idx_stores_handle'); + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + + Schema::create('store_domains', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('hostname'); + $table->enum('type', ['storefront', 'admin', 'api'])->default('storefront'); + $table->boolean('is_primary')->default(false); + $table->enum('tls_mode', ['managed', 'bring_your_own'])->default('managed'); + $table->timestamp('created_at')->nullable(); + $table->unique('hostname', 'idx_store_domains_hostname'); + $table->index('store_id', 'idx_store_domains_store_id'); + $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary'); + }); + + Schema::create('store_users', function (Blueprint $table) { + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->enum('role', ['owner', 'admin', 'staff', 'support'])->default('staff'); + $table->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'user_id']); + $table->index('user_id', 'idx_store_users_user_id'); + $table->index(['store_id', 'role'], 'idx_store_users_role'); + }); + + Schema::create('store_settings', function (Blueprint $table) { + $table->unsignedBigInteger('store_id')->primary(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + $table->foreign('store_id')->references('id')->on('stores')->cascadeOnDelete(); + }); + + Schema::create('customer_password_reset_tokens', function (Blueprint $table) { + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + $table->primary(['store_id', 'email']); + }); + + Schema::create('personal_access_tokens', function (Blueprint $table) { + $table->id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + Schema::dropIfExists('customer_password_reset_tokens'); + Schema::dropIfExists('store_settings'); + Schema::dropIfExists('store_users'); + Schema::dropIfExists('store_domains'); + Schema::dropIfExists('stores'); + Schema::dropIfExists('apps'); + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_01_01_000200_create_tenant_root_tables.php b/database/migrations/2026_01_01_000200_create_tenant_root_tables.php new file mode 100644 index 00000000..08c8b230 --- /dev/null +++ b/database/migrations/2026_01_01_000200_create_tenant_root_tables.php @@ -0,0 +1,177 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('password_hash')->nullable(); + $table->string('name')->nullable(); + $table->boolean('marketing_opt_in')->default(false); + $table->timestamps(); + $table->unique(['store_id', 'email'], 'idx_customers_store_email'); + $table->index('store_id', 'idx_customers_store_id'); + }); + + Schema::create('themes', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->enum('status', ['draft', 'published'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->index('store_id', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + + Schema::create('pages', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('body_html')->nullable(); + $table->enum('status', ['draft', 'published', 'archived'])->default('draft'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'handle'], 'idx_pages_store_handle'); + $table->index('store_id', 'idx_pages_store_id'); + $table->index(['store_id', 'status'], 'idx_pages_store_status'); + }); + + Schema::create('navigation_menus', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('handle'); + $table->string('title'); + $table->timestamps(); + $table->unique(['store_id', 'handle'], 'idx_navigation_menus_store_handle'); + $table->index('store_id', 'idx_navigation_menus_store_id'); + }); + + Schema::create('search_settings', function (Blueprint $table) { + $table->unsignedBigInteger('store_id')->primary(); + $table->text('synonyms_json')->default('[]'); + $table->text('stop_words_json')->default('[]'); + $table->timestamp('updated_at')->nullable(); + $table->foreign('store_id')->references('id')->on('stores')->cascadeOnDelete(); + }); + + Schema::create('shipping_zones', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + $table->index('store_id', 'idx_shipping_zones_store_id'); + }); + + Schema::create('tax_settings', function (Blueprint $table) { + $table->unsignedBigInteger('store_id')->primary(); + $table->enum('mode', ['manual', 'provider'])->default('manual'); + $table->enum('provider', ['stripe_tax', 'none'])->default('none'); + $table->boolean('prices_include_tax')->default(false); + $table->text('config_json')->default('{}'); + $table->foreign('store_id')->references('id')->on('stores')->cascadeOnDelete(); + }); + + Schema::create('discounts', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['code', 'automatic'])->default('code'); + $table->string('code')->nullable(); + $table->enum('value_type', ['fixed', 'percent', 'free_shipping']); + $table->integer('value_amount')->default(0); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active'); + $table->timestamps(); + $table->unique(['store_id', 'code'], 'idx_discounts_store_code'); + $table->index('store_id', 'idx_discounts_store_id'); + $table->index(['store_id', 'status'], 'idx_discounts_store_status'); + $table->index(['store_id', 'type'], 'idx_discounts_store_type'); + }); + + Schema::create('products', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->enum('status', ['draft', 'active', 'archived'])->default('draft'); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('product_type')->nullable(); + $table->text('tags')->default('[]'); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'handle'], 'idx_products_store_handle'); + $table->index('store_id', 'idx_products_store_id'); + $table->index(['store_id', 'status'], 'idx_products_store_status'); + $table->index(['store_id', 'published_at'], 'idx_products_published_at'); + $table->index(['store_id', 'vendor'], 'idx_products_vendor'); + $table->index(['store_id', 'product_type'], 'idx_products_product_type'); + }); + + Schema::create('collections', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->enum('type', ['manual', 'automated'])->default('manual'); + $table->enum('status', ['draft', 'active', 'archived'])->default('active'); + $table->timestamps(); + $table->unique(['store_id', 'handle'], 'idx_collections_store_handle'); + $table->index('store_id', 'idx_collections_store_id'); + $table->index(['store_id', 'status'], 'idx_collections_store_status'); + }); + + Schema::create('app_installations', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->text('scopes_json')->default('[]'); + $table->enum('status', ['active', 'suspended', 'uninstalled'])->default('active'); + $table->timestamp('installed_at')->nullable(); + $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app'); + $table->index('store_id', 'idx_app_installations_store_id'); + $table->index('app_id', 'idx_app_installations_app_id'); + }); + + Schema::create('oauth_clients', function (Blueprint $table) { + $table->id(); + $table->foreignId('app_id')->constrained()->cascadeOnDelete(); + $table->string('client_id'); + $table->text('client_secret_encrypted'); + $table->text('redirect_uris_json')->default('[]'); + $table->unique('client_id', 'idx_oauth_clients_client_id'); + $table->index('app_id', 'idx_oauth_clients_app_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + Schema::dropIfExists('app_installations'); + Schema::dropIfExists('collections'); + Schema::dropIfExists('products'); + Schema::dropIfExists('discounts'); + Schema::dropIfExists('tax_settings'); + Schema::dropIfExists('shipping_zones'); + Schema::dropIfExists('search_settings'); + Schema::dropIfExists('navigation_menus'); + Schema::dropIfExists('pages'); + Schema::dropIfExists('themes'); + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_01_01_000300_create_dependent_tables.php b/database/migrations/2026_01_01_000300_create_dependent_tables.php new file mode 100644 index 00000000..7738b5ac --- /dev/null +++ b/database/migrations/2026_01_01_000300_create_dependent_tables.php @@ -0,0 +1,185 @@ +id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->integer('position')->default(0); + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + + Schema::create('product_variants', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->integer('price_amount')->default(0); + $table->integer('compare_at_amount')->nullable(); + $table->string('currency')->default('USD'); + $table->integer('weight_g')->nullable(); + $table->boolean('requires_shipping')->default(true); + $table->boolean('is_default')->default(false); + $table->integer('position')->default(0); + $table->enum('status', ['active', 'archived'])->default('active'); + $table->timestamps(); + $table->index('product_id', 'idx_product_variants_product_id'); + $table->index('sku', 'idx_product_variants_sku'); + $table->index('barcode', 'idx_product_variants_barcode'); + $table->index(['product_id', 'position'], 'idx_product_variants_product_position'); + $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default'); + }); + + Schema::create('product_media', function (Blueprint $table) { + $table->id(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->enum('type', ['image', 'video'])->default('image'); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->integer('width')->nullable(); + $table->integer('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->integer('byte_size')->nullable(); + $table->integer('position')->default(0); + $table->enum('status', ['processing', 'ready', 'failed'])->default('processing'); + $table->timestamp('created_at')->nullable(); + $table->index('product_id', 'idx_product_media_product_id'); + $table->index(['product_id', 'position'], 'idx_product_media_product_position'); + $table->index('status', 'idx_product_media_status'); + }); + + Schema::create('collection_products', function (Blueprint $table) { + $table->foreignId('collection_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->integer('position')->default(0); + $table->primary(['collection_id', 'product_id']); + $table->index('product_id', 'idx_collection_products_product_id'); + $table->index(['collection_id', 'position'], 'idx_collection_products_position'); + }); + + Schema::create('customer_addresses', function (Blueprint $table) { + $table->id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->boolean('is_default')->default(false); + $table->index('customer_id', 'idx_customer_addresses_customer_id'); + $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default'); + }); + + Schema::create('carts', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('currency')->default('USD'); + $table->integer('cart_version')->default(1); + $table->enum('status', ['active', 'converted', 'abandoned'])->default('active'); + $table->timestamps(); + $table->index('store_id', 'idx_carts_store_id'); + $table->index('customer_id', 'idx_carts_customer_id'); + $table->index(['store_id', 'status'], 'idx_carts_store_status'); + }); + + Schema::create('navigation_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->enum('type', ['link', 'page', 'collection', 'product'])->default('link'); + $table->string('label'); + $table->string('url')->nullable(); + $table->unsignedBigInteger('resource_id')->nullable(); + $table->integer('position')->default(0); + $table->index('menu_id', 'idx_navigation_items_menu_id'); + $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position'); + }); + + Schema::create('theme_files', function (Blueprint $table) { + $table->id(); + $table->foreignId('theme_id')->constrained()->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256'); + $table->integer('byte_size')->default(0); + $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path'); + $table->index('theme_id', 'idx_theme_files_theme_id'); + }); + + Schema::create('theme_settings', function (Blueprint $table) { + $table->unsignedBigInteger('theme_id')->primary(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete(); + }); + + Schema::create('search_queries', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->text('query'); + $table->text('filters_json')->nullable(); + $table->integer('results_count')->default(0); + $table->timestamp('created_at')->nullable(); + $table->index('store_id', 'idx_search_queries_store_id'); + $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created'); + $table->index(['store_id', 'query'], 'idx_search_queries_store_query'); + }); + + Schema::create('shipping_rates', function (Blueprint $table) { + $table->id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['flat', 'weight', 'price', 'carrier'])->default('flat'); + $table->text('config_json')->default('{}'); + $table->boolean('is_active')->default(true); + $table->index('zone_id', 'idx_shipping_rates_zone_id'); + $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active'); + }); + + Schema::create('oauth_tokens', function (Blueprint $table) { + $table->id(); + $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete(); + $table->string('access_token_hash'); + $table->string('refresh_token_hash')->nullable(); + $table->timestamp('expires_at'); + $table->index('installation_id', 'idx_oauth_tokens_installation_id'); + $table->unique('access_token_hash', 'idx_oauth_tokens_access_hash'); + $table->index('expires_at', 'idx_oauth_tokens_expires_at'); + }); + + Schema::create('webhook_subscriptions', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->enum('status', ['active', 'paused', 'disabled'])->default('active'); + $table->index('store_id', 'idx_webhook_subscriptions_store_id'); + $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event'); + $table->index('app_installation_id', 'idx_webhook_subscriptions_installation'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_subscriptions'); + Schema::dropIfExists('oauth_tokens'); + Schema::dropIfExists('shipping_rates'); + Schema::dropIfExists('search_queries'); + Schema::dropIfExists('theme_settings'); + Schema::dropIfExists('theme_files'); + Schema::dropIfExists('navigation_items'); + Schema::dropIfExists('carts'); + Schema::dropIfExists('customer_addresses'); + Schema::dropIfExists('collection_products'); + Schema::dropIfExists('product_media'); + Schema::dropIfExists('product_variants'); + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_01_01_000400_create_transaction_tables.php b/database/migrations/2026_01_01_000400_create_transaction_tables.php new file mode 100644 index 00000000..ab90d79b --- /dev/null +++ b/database/migrations/2026_01_01_000400_create_transaction_tables.php @@ -0,0 +1,155 @@ +id(); + $table->foreignId('product_option_id')->constrained()->cascadeOnDelete(); + $table->string('value'); + $table->integer('position')->default(0); + $table->index('product_option_id', 'idx_product_option_values_option_id'); + $table->unique(['product_option_id', 'position'], 'idx_product_option_values_option_position'); + }); + + Schema::create('inventory_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->enum('policy', ['deny', 'continue'])->default('deny'); + $table->unique('variant_id', 'idx_inventory_items_variant_id'); + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + + Schema::create('cart_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('line_subtotal_amount')->default(0); + $table->integer('line_discount_amount')->default(0); + $table->integer('line_total_amount')->default(0); + $table->index('cart_id', 'idx_cart_lines_cart_id'); + $table->unique(['cart_id', 'variant_id'], 'idx_cart_lines_cart_variant'); + }); + + Schema::create('checkouts', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable(); + $table->string('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->unsignedBigInteger('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + $table->index('store_id', 'idx_checkouts_store_id'); + $table->index('cart_id', 'idx_checkouts_cart_id'); + $table->index('customer_id', 'idx_checkouts_customer_id'); + $table->index(['store_id', 'status'], 'idx_checkouts_status'); + $table->index('expires_at', 'idx_checkouts_expires_at'); + }); + + Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->string('order_number'); + $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer']); + $table->enum('status', ['pending', 'paid', 'fulfilled', 'cancelled', 'refunded'])->default('pending'); + $table->enum('financial_status', ['pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided'])->default('pending'); + $table->enum('fulfillment_status', ['unfulfilled', 'partial', 'fulfilled'])->default('unfulfilled'); + $table->string('currency')->default('USD'); + $table->integer('subtotal_amount')->default(0); + $table->integer('discount_amount')->default(0); + $table->integer('shipping_amount')->default(0); + $table->integer('tax_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->string('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->timestamp('placed_at')->nullable(); + $table->timestamps(); + $table->unique(['store_id', 'order_number'], 'idx_orders_store_order_number'); + $table->index('store_id', 'idx_orders_store_id'); + $table->index('customer_id', 'idx_orders_customer_id'); + $table->index(['store_id', 'status'], 'idx_orders_store_status'); + $table->index(['store_id', 'financial_status'], 'idx_orders_store_financial'); + $table->index(['store_id', 'fulfillment_status'], 'idx_orders_store_fulfillment'); + $table->index(['store_id', 'placed_at'], 'idx_orders_placed_at'); + }); + + Schema::create('analytics_events', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->text('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->timestamp('occurred_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->index('store_id', 'idx_analytics_events_store_id'); + $table->index(['store_id', 'type'], 'idx_analytics_events_store_type'); + $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created'); + $table->index('session_id', 'idx_analytics_events_session'); + $table->index('customer_id', 'idx_analytics_events_customer'); + $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event'); + }); + + Schema::create('analytics_daily', function (Blueprint $table) { + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->date('date'); + $table->integer('orders_count')->default(0); + $table->integer('revenue_amount')->default(0); + $table->integer('aov_amount')->default(0); + $table->integer('visits_count')->default(0); + $table->integer('add_to_cart_count')->default(0); + $table->integer('checkout_started_count')->default(0); + $table->integer('checkout_completed_count')->default(0); + $table->primary(['store_id', 'date']); + $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date'); + }); + + Schema::create('webhook_deliveries', function (Blueprint $table) { + $table->id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->integer('attempt_count')->default(1); + $table->enum('status', ['pending', 'success', 'failed'])->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->integer('response_code')->nullable(); + $table->text('response_body_snippet')->nullable(); + $table->index('subscription_id', 'idx_webhook_deliveries_subscription_id'); + $table->index('event_id', 'idx_webhook_deliveries_event_id'); + $table->index('status', 'idx_webhook_deliveries_status'); + $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt'); + }); + } + + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + Schema::dropIfExists('analytics_daily'); + Schema::dropIfExists('analytics_events'); + Schema::dropIfExists('orders'); + Schema::dropIfExists('checkouts'); + Schema::dropIfExists('cart_lines'); + Schema::dropIfExists('inventory_items'); + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_01_01_000500_create_order_detail_tables.php b/database/migrations/2026_01_01_000500_create_order_detail_tables.php new file mode 100644 index 00000000..69c654e7 --- /dev/null +++ b/database/migrations/2026_01_01_000500_create_order_detail_tables.php @@ -0,0 +1,74 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete(); + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + + Schema::create('order_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('sku_snapshot')->nullable(); + $table->integer('quantity')->default(1); + $table->integer('unit_price_amount')->default(0); + $table->integer('total_amount')->default(0); + $table->text('tax_lines_json')->default('[]'); + $table->text('discount_allocations_json')->default('[]'); + $table->index('order_id', 'idx_order_lines_order_id'); + $table->index('product_id', 'idx_order_lines_product_id'); + $table->index('variant_id', 'idx_order_lines_variant_id'); + }); + + Schema::create('payments', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('provider', ['mock'])->default('mock'); + $table->enum('method', ['credit_card', 'paypal', 'bank_transfer']); + $table->string('provider_payment_id')->nullable(); + $table->enum('status', ['pending', 'captured', 'failed', 'refunded'])->default('pending'); + $table->integer('amount')->default(0); + $table->string('currency')->default('USD'); + $table->text('raw_json_encrypted')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->index('order_id', 'idx_payments_order_id'); + $table->index(['provider', 'provider_payment_id'], 'idx_payments_provider_id'); + $table->index('method', 'idx_payments_method'); + $table->index('status', 'idx_payments_status'); + }); + + Schema::create('fulfillments', function (Blueprint $table) { + $table->id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->enum('status', ['pending', 'shipped', 'delivered'])->default('pending'); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('shipped_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->index('order_id', 'idx_fulfillments_order_id'); + $table->index('status', 'idx_fulfillments_status'); + $table->index(['tracking_company', 'tracking_number'], 'idx_fulfillments_tracking'); + }); + } + + public function down(): void + { + Schema::dropIfExists('fulfillments'); + Schema::dropIfExists('payments'); + Schema::dropIfExists('order_lines'); + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_01_01_000600_create_refund_and_fulfillment_line_tables.php b/database/migrations/2026_01_01_000600_create_refund_and_fulfillment_line_tables.php new file mode 100644 index 00000000..cc4d7e86 --- /dev/null +++ b/database/migrations/2026_01_01_000600_create_refund_and_fulfillment_line_tables.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained()->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->text('reason')->nullable(); + $table->enum('status', ['pending', 'processed', 'failed'])->default('pending'); + $table->string('provider_refund_id')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->index('order_id', 'idx_refunds_order_id'); + $table->index('payment_id', 'idx_refunds_payment_id'); + $table->index('status', 'idx_refunds_status'); + }); + + Schema::create('fulfillment_lines', function (Blueprint $table) { + $table->id(); + $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->integer('quantity')->default(1); + $table->index('fulfillment_id', 'idx_fulfillment_lines_fulfillment_id'); + $table->unique(['fulfillment_id', 'order_line_id'], 'idx_fulfillment_lines_fulfillment_order_line'); + }); + } + + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_01_01_000700_create_products_fts_table.php b/database/migrations/2026_01_01_000700_create_products_fts_table.php new file mode 100644 index 00000000..4d08daad --- /dev/null +++ b/database/migrations/2026_01_01_000700_create_products_fts_table.php @@ -0,0 +1,27 @@ +where('handle', 'acme-fashion')->firstOrFail(); + foreach (range(29, 0) as $offset) { + $orders = 3 + (($offset * 7) % 10); + $revenue = $orders * (6500 + (($offset * 137) % 4000)); + DB::table('analytics_daily')->updateOrInsert(['store_id' => $store->id, 'date' => now()->subDays($offset)->toDateString()], [ + 'orders_count' => $orders, 'revenue_amount' => $revenue, 'aov_amount' => intdiv($revenue, $orders), + 'visits_count' => 90 + (($offset * 17) % 120), 'add_to_cart_count' => 20 + (($offset * 5) % 35), + 'checkout_started_count' => 10 + (($offset * 3) % 20), 'checkout_completed_count' => $orders, + ]); + } + } +} diff --git a/database/seeders/CollectionSeeder.php b/database/seeders/CollectionSeeder.php new file mode 100644 index 00000000..022ec0d5 --- /dev/null +++ b/database/seeders/CollectionSeeder.php @@ -0,0 +1,31 @@ + [ + ['New Arrivals', 'new-arrivals', 'Discover the latest additions to our store.'], + ['T-Shirts', 't-shirts', 'Premium cotton tees for every occasion.'], + ['Pants & Jeans', 'pants-jeans', 'Find the perfect fit from our denim and trouser range.'], + ['Sale', 'sale', 'Great deals on selected items.'], + ], + 'acme-electronics' => [['Featured', 'featured', 'Premium technology for work and play.'], ['Accessories', 'accessories', 'Essential accessories for every setup.']], + ]; + foreach ($sets as $handle => $collections) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + foreach ($collections as [$title, $collectionHandle, $description]) { + Collection::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'handle' => $collectionHandle], [ + 'title' => $title, 'description_html' => "

{$description}

", 'type' => 'manual', 'status' => 'active', + ]); + } + } + } +} diff --git a/database/seeders/CustomerSeeder.php b/database/seeders/CustomerSeeder.php new file mode 100644 index 00000000..3bfb0217 --- /dev/null +++ b/database/seeders/CustomerSeeder.php @@ -0,0 +1,64 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + $fashionCustomers = [ + ['customer@acme.test', 'John Doe', true], ['jane@example.com', 'Jane Smith', false], + ['michael@example.com', 'Michael Brown', true], ['sarah@example.com', 'Sarah Wilson', false], + ['david@example.com', 'David Lee', true], ['emma@example.com', 'Emma Garcia', false], + ['james@example.com', 'James Taylor', false], ['lisa@example.com', 'Lisa Anderson', true], + ['robert@example.com', 'Robert Martinez', false], ['anna@example.com', 'Anna Thomas', true], + ]; + foreach ($fashionCustomers as $index => [$email, $name, $marketing]) { + $customer = $this->customer($fashion, $email, $name, $marketing); + $this->address($customer, 'Home', true, [ + 'first_name' => str($name)->before(' ')->toString(), 'last_name' => str($name)->after(' ')->toString(), + 'company' => null, 'address1' => $index === 0 ? 'Hauptstrasse 1' : ($index === 1 ? 'Schillerstrasse 45' : 'Musterstrasse '.($index + 10)), + 'address2' => null, 'city' => $index === 1 ? 'Munich' : 'Berlin', 'province' => $index === 1 ? 'Bavaria' : 'Berlin', + 'province_code' => $index === 1 ? 'BY' : 'BE', 'country' => 'DE', 'country_code' => 'DE', + 'postal_code' => $index === 1 ? '80336' : '10115', 'zip' => $index === 1 ? '80336' : '10115', + 'phone' => $index === 0 ? '+49 30 12345678' : null, + ]); + if ($index === 0) { + $this->address($customer, 'Work', false, [ + 'first_name' => 'John', 'last_name' => 'Doe', 'company' => 'Acme Corp', 'address1' => 'Friedrichstrasse 100', + 'address2' => '3rd Floor', 'city' => 'Berlin', 'province' => 'Berlin', 'province_code' => 'BE', + 'country' => 'DE', 'country_code' => 'DE', 'postal_code' => '10117', 'zip' => '10117', 'phone' => '+49 30 87654321', + ]); + } + } + + foreach ([['techfan@example.com', 'Tech Fan'], ['gadgetlover@example.com', 'Gadget Lover']] as $index => [$email, $name]) { + $customer = $this->customer($electronics, $email, $name, false); + $this->address($customer, 'Home', true, [ + 'first_name' => str($name)->before(' ')->toString(), 'last_name' => str($name)->after(' ')->toString(), + 'address1' => 'Technikweg '.($index + 1), 'city' => 'Berlin', 'province' => 'Berlin', 'province_code' => 'BE', + 'country' => 'DE', 'country_code' => 'DE', 'postal_code' => '10115', 'zip' => '10115', + ]); + } + } + + private function customer(Store $store, string $email, string $name, bool $marketing): Customer + { + return Customer::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'email' => $email], [ + 'name' => $name, 'password_hash' => Hash::make('password'), 'marketing_opt_in' => $marketing, + ]); + } + + /** @param array $address */ + private function address(Customer $customer, string $label, bool $default, array $address): void + { + $customer->addresses()->updateOrCreate(['label' => $label], ['address_json' => $address, 'is_default' => $default]); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..84f69730 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,8 +2,6 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -13,11 +11,25 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + $this->call([ + OrganizationSeeder::class, + StoreSeeder::class, + StoreDomainSeeder::class, + UserSeeder::class, + StoreUserSeeder::class, + StoreSettingsSeeder::class, + TaxSettingsSeeder::class, + ShippingSeeder::class, + CollectionSeeder::class, + ProductSeeder::class, + DiscountSeeder::class, + CustomerSeeder::class, + OrderSeeder::class, + ThemeSeeder::class, + PageSeeder::class, + NavigationSeeder::class, + AnalyticsSeeder::class, + SearchSettingsSeeder::class, ]); } } diff --git a/database/seeders/DiscountSeeder.php b/database/seeders/DiscountSeeder.php new file mode 100644 index 00000000..720ab591 --- /dev/null +++ b/database/seeders/DiscountSeeder.php @@ -0,0 +1,28 @@ +where('handle', 'acme-fashion')->firstOrFail(); + foreach ([ + ['WELCOME10', 'percent', 10, '2025-01-01', '2027-12-31', null, 3, ['min_purchase_amount' => 2000], 'active'], + ['FLAT5', 'fixed', 500, '2025-01-01', '2027-12-31', null, 0, [], 'active'], + ['FREESHIP', 'free_shipping', 0, '2025-01-01', '2027-12-31', null, 1, [], 'active'], + ['EXPIRED20', 'percent', 20, '2024-01-01', '2024-12-31', null, 0, [], 'expired'], + ['MAXED', 'percent', 10, '2025-01-01', '2027-12-31', 5, 5, [], 'active'], + ] as [$code, $valueType, $value, $starts, $ends, $limit, $count, $rules, $status]) { + Discount::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'code' => $code], [ + 'type' => 'code', 'value_type' => $valueType, 'value_amount' => $value, + 'starts_at' => $starts, 'ends_at' => $ends, 'usage_limit' => $limit, + 'usage_count' => $count, 'rules_json' => $rules, 'status' => $status, + ]); + } + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..79d57e96 --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,35 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + $main = NavigationMenu::withoutGlobalScopes()->updateOrCreate(['store_id' => $fashion->id, 'handle' => 'main-menu'], ['title' => 'Main Menu']); + $this->replace($main, [['Home', 'link', '/', null], ...collect(['new-arrivals' => 'New Arrivals', 't-shirts' => 'T-Shirts', 'pants-jeans' => 'Pants & Jeans', 'sale' => 'Sale'])->map(fn ($label, $handle) => [$label, 'collection', null, Collection::withoutGlobalScopes()->where('store_id', $fashion->id)->where('handle', $handle)->value('id')])->values()->all()]); + + $footer = NavigationMenu::withoutGlobalScopes()->updateOrCreate(['store_id' => $fashion->id, 'handle' => 'footer-menu'], ['title' => 'Footer Menu']); + $this->replace($footer, collect(['about' => 'About Us', 'faq' => 'FAQ', 'shipping-returns' => 'Shipping & Returns', 'privacy-policy' => 'Privacy Policy', 'terms' => 'Terms of Service'])->map(fn ($label, $handle) => [$label, 'page', null, Page::withoutGlobalScopes()->where('store_id', $fashion->id)->where('handle', $handle)->value('id')])->values()->all()); + + $electronicsMain = NavigationMenu::withoutGlobalScopes()->updateOrCreate(['store_id' => $electronics->id, 'handle' => 'main-menu'], ['title' => 'Main Menu']); + $this->replace($electronicsMain, [['Home', 'link', '/', null], ...collect(['featured' => 'Featured', 'accessories' => 'Accessories'])->map(fn ($label, $handle) => [$label, 'collection', null, Collection::withoutGlobalScopes()->where('store_id', $electronics->id)->where('handle', $handle)->value('id')])->values()->all()]); + } + + /** @param list $items */ + private function replace(NavigationMenu $menu, array $items): void + { + $menu->items()->delete(); + foreach ($items as $position => [$label, $type, $url, $resourceId]) { + $menu->items()->create(['type' => $type, 'label' => $label, 'url' => $url, 'resource_id' => $resourceId, 'position' => $position]); + } + } +} diff --git a/database/seeders/OrderSeeder.php b/database/seeders/OrderSeeder.php new file mode 100644 index 00000000..be841f28 --- /dev/null +++ b/database/seeders/OrderSeeder.php @@ -0,0 +1,106 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $customers = Customer::withoutGlobalScopes()->where('store_id', $fashion->id)->get()->keyBy('email'); + $definitions = [ + ['#1001', 'customer@acme.test', 'credit_card', 'paid', 'paid', 'unfulfilled', 5497, 'classic-cotton-t-shirt', 2, 2], + ['#1002', 'customer@acme.test', 'credit_card', 'fulfilled', 'paid', 'fulfilled', 8997, 'premium-slim-fit-jeans', 1, 5], + ['#1003', 'jane@example.com', 'credit_card', 'paid', 'paid', 'partial', 11997, 'organic-hoodie', 2, 4], + ['#1004', 'customer@acme.test', 'credit_card', 'cancelled', 'refunded', 'unfulfilled', 2998, 'graphic-print-tee', 1, 12], + ['#1005', 'jane@example.com', 'bank_transfer', 'pending', 'pending', 'unfulfilled', 3998, 'chino-shorts', 1, 1], + ['#1006', 'michael@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 12498, 'running-sneakers', 1, 3], + ['#1007', 'sarah@example.com', 'paypal', 'fulfilled', 'paid', 'fulfilled', 10496, 'cargo-pants', 2, 8], + ['#1008', 'david@example.com', 'credit_card', 'paid', 'partially_refunded', 'fulfilled', 8997, 'premium-slim-fit-jeans', 1, 15], + ['#1009', 'emma@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 4997, 'leather-belt', 1, 7], + ['#1010', 'customer@acme.test', 'paypal', 'paid', 'paid', 'unfulfilled', 50498, 'cashmere-overcoat', 1, 6], + ['#1011', 'james@example.com', 'credit_card', 'paid', 'paid', 'fulfilled', 3298, 'canvas-tote-bag', 1, 10], + ['#1012', 'lisa@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 8497, 'striped-polo-shirt', 3, 9], + ['#1013', 'robert@example.com', 'credit_card', 'paid', 'paid', 'unfulfilled', 8497, 'wide-leg-trousers', 1, 11], + ['#1014', 'anna@example.com', 'credit_card', 'fulfilled', 'paid', 'fulfilled', 5000, 'gift-card', 1, 14], + ['#1015', 'customer@acme.test', 'bank_transfer', 'paid', 'paid', 'unfulfilled', 5447, 'classic-cotton-t-shirt', 2, 2], + ]; + foreach ($definitions as $definition) { + [$number, $email, $method, $status, $financial, $fulfillment, $total, $handle, $quantity, $days] = $definition; + $this->seedOrder($fashion, $customers[$email], $number, $method, $status, $financial, $fulfillment, $total, $handle, $quantity, $days); + } + + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + $tech = Customer::withoutGlobalScopes()->where('store_id', $electronics->id)->where('email', 'techfan@example.com')->firstOrFail(); + $gadget = Customer::withoutGlobalScopes()->where('store_id', $electronics->id)->where('email', 'gadgetlover@example.com')->firstOrFail(); + $this->seedOrder($electronics, $tech, '#5001', 'credit_card', 'fulfilled', 'paid', 'fulfilled', 121298, 'pro-laptop-15', 1, 6); + $this->seedOrder($electronics, $gadget, '#5002', 'credit_card', 'paid', 'paid', 'unfulfilled', 14999, 'wireless-headphones', 1, 3); + $this->seedOrder($electronics, $tech, '#5003', 'bank_transfer', 'pending', 'pending', 'unfulfilled', 4999, 'monitor-stand', 1, 1); + } + + private function seedOrder( + Store $store, + Customer $customer, + string $number, + string $method, + string $status, + string $financial, + string $fulfillmentStatus, + int $total, + string $productHandle, + int $quantity, + int $daysAgo, + ): void { + $address = $customer->addresses()->where('is_default', true)->value('address_json') ?? []; + $shipping = $store->handle === 'acme-fashion' && $total !== 5000 ? 499 : 0; + $subtotal = max(0, $total - $shipping); + $order = Order::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'order_number' => $number], [ + 'customer_id' => $customer->id, 'payment_method' => $method, 'status' => $status, + 'financial_status' => $financial, 'fulfillment_status' => $fulfillmentStatus, 'currency' => 'EUR', + 'subtotal_amount' => $subtotal, 'discount_amount' => 0, 'shipping_amount' => $shipping, + 'tax_amount' => 0, 'total_amount' => $total, 'email' => $customer->email, + 'billing_address_json' => $address, 'shipping_address_json' => $address, 'placed_at' => now()->subDays($daysAgo), + ]); + if ($order->lines()->exists()) { + return; + } + + $product = Product::withoutGlobalScopes()->where('store_id', $store->id)->where('handle', $productHandle)->with('variants.inventoryItem')->firstOrFail(); + $variant = $product->variants->when($productHandle === 'gift-card', fn ($variants) => $variants->where('price_amount', 5000))->first() ?? $product->variants->firstOrFail(); + $line = $order->lines()->create([ + 'product_id' => $product->id, 'variant_id' => $variant->id, 'title_snapshot' => $product->title, + 'sku_snapshot' => $variant->sku, 'quantity' => $quantity, 'unit_price_amount' => intdiv($subtotal, max(1, $quantity)), + 'total_amount' => $subtotal, 'tax_lines_json' => [['title' => 'VAT', 'rate' => 1900, 'amount' => 0]], 'discount_allocations_json' => [], + ]); + $payment = $order->payments()->create([ + 'provider' => 'mock', 'method' => $method, 'provider_payment_id' => 'mock_seed_'.ltrim($number, '#'), + 'status' => $financial === 'pending' ? 'pending' : ($financial === 'refunded' ? 'refunded' : 'captured'), + 'amount' => $total, 'currency' => 'EUR', 'raw_json_encrypted' => ['seeded' => true], + ]); + + if ($financial === 'pending' && $variant->inventoryItem !== null) { + $variant->inventoryItem->increment('quantity_reserved', $quantity); + } + if ($fulfillmentStatus !== 'unfulfilled') { + $shipment = $order->fulfillments()->create([ + 'status' => $fulfillmentStatus === 'fulfilled' ? 'delivered' : 'shipped', + 'tracking_company' => 'DHL', 'tracking_number' => 'DHL'.ltrim($number, '#'), + 'tracking_url' => 'https://example.test/track/'.ltrim($number, '#'), 'shipped_at' => now()->subDays(max(0, $daysAgo - 1)), + ]); + $shipment->lines()->create(['order_line_id' => $line->id, 'quantity' => $fulfillmentStatus === 'partial' ? max(1, $quantity - 1) : $quantity]); + } + if ($financial === 'refunded' || $financial === 'partially_refunded') { + $order->refunds()->create([ + 'payment_id' => $payment->id, 'amount' => $financial === 'refunded' ? $total : min(2000, $total - 1), + 'reason' => $financial === 'refunded' ? 'Customer requested cancellation' : 'Partial item refund', + 'status' => 'processed', 'provider_refund_id' => 'mock_refund_seed_'.ltrim($number, '#'), + ]); + } + } +} diff --git a/database/seeders/OrganizationSeeder.php b/database/seeders/OrganizationSeeder.php new file mode 100644 index 00000000..619d07a3 --- /dev/null +++ b/database/seeders/OrganizationSeeder.php @@ -0,0 +1,14 @@ +updateOrCreate(['billing_email' => 'billing@acme.test'], ['name' => 'Acme Corp']); + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..d5f7e4ec --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,26 @@ +where('handle', 'acme-fashion')->firstOrFail(); + foreach ([ + ['About Us', 'about', '

Our Story

Acme Fashion creates modern essentials with purpose from our Berlin studio.

Our Values

Ethical sourcing, sustainability and fair labour guide every decision.

Our Team

Our Berlin-based designers build lasting wardrobes, not passing trends.

'], + ['FAQ', 'faq', '

Frequently Asked Questions

How long does shipping take?

Germany orders arrive in 2-4 days, express in 1-2 days, and EU orders in 5-7 days.

What is your return policy?

Return unworn items in original packaging within 30 days.

Do you ship internationally?

We ship across the EU and to the US, UK, Canada and Australia.

How do I track an order?

We email your tracking number as soon as it ships.

'], + ['Shipping & Returns', 'shipping-returns', '

Shipping & Returns

Shipping rates

  • Germany standard: 4.99 EUR
  • Germany express: 9.99 EUR
  • EU: 8.99 EUR
  • International: 14.99 EUR

Returns are accepted for 30 days. Customers pay return postage unless an item is defective.

'], + ['Privacy Policy', 'privacy-policy', '

Information We Collect

We collect the details needed to fulfil your order.

How We Use Your Information

We use data only to provide and improve our service.

Cookies

Essential cookies keep your cart and account secure.

Contact

privacy@acme-fashion.test

'], + ['Terms of Service', 'terms', '

Orders and Payments

Orders are charged in EUR and prices include tax.

Product Descriptions

Screen colours may vary slightly from physical items.

Limitation of Liability

Liability is limited as permitted by law.

Governing Law

The laws of the Federal Republic of Germany apply.

'], + ] as [$title, $handle, $body]) { + Page::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'handle' => $handle], [ + 'title' => $title, 'body_html' => $body, 'status' => 'published', 'published_at' => now()->subMonths(3), + ]); + } + } +} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php new file mode 100644 index 00000000..2621e458 --- /dev/null +++ b/database/seeders/ProductSeeder.php @@ -0,0 +1,134 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + + $products = [ + ['Classic Cotton T-Shirt', 'classic-cotton-t-shirt', 'Acme Basics', 'T-Shirts', ['new', 'popular'], 2499, null, 200, 15, 'deny', true, ['Size' => ['S', 'M', 'L', 'XL'], 'Color' => ['White', 'Black', 'Navy']], ['new-arrivals', 't-shirts'], 'A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.'], + ['Premium Slim Fit Jeans', 'premium-slim-fit-jeans', 'Acme Denim', 'Pants', ['new', 'sale'], 7999, 9999, 800, 8, 'deny', true, ['Size' => ['28', '30', '32', '34', '36'], 'Color' => ['Blue', 'Black']], ['new-arrivals', 'pants-jeans', 'sale'], 'Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette.'], + ['Organic Hoodie', 'organic-hoodie', 'Acme Basics', 'Hoodies', ['new', 'trending'], 5999, null, 500, 20, 'deny', true, ['Size' => ['S', 'M', 'L', 'XL']], ['new-arrivals'], 'Made from 100% organic cotton. Warm, soft, and sustainably produced.'], + ['Leather Belt', 'leather-belt', 'Acme Accessories', 'Accessories', ['popular'], 3499, null, 150, 25, 'deny', true, ['Size' => ['S/M', 'L/XL'], 'Color' => ['Brown', 'Black']], [], 'Genuine leather belt with brushed metal buckle. A wardrobe essential.'], + ['Running Sneakers', 'running-sneakers', 'Acme Sport', 'Shoes', ['trending'], 11999, null, 600, 5, 'deny', true, ['Size' => ['EU 38', 'EU 39', 'EU 40', 'EU 41', 'EU 42', 'EU 43', 'EU 44'], 'Color' => ['White', 'Black']], ['new-arrivals'], 'Lightweight running sneakers with responsive cushioning and breathable mesh upper.'], + ['Graphic Print Tee', 'graphic-print-tee', 'Acme Basics', 'T-Shirts', ['new'], 2999, null, 210, 18, 'deny', true, ['Size' => ['S', 'M', 'L', 'XL']], ['t-shirts'], 'Bold graphic print on soft cotton. Express yourself with this statement piece.'], + ['V-Neck Linen Tee', 'v-neck-linen-tee', 'Acme Basics', 'T-Shirts', ['popular'], 3499, null, 180, 12, 'deny', true, ['Size' => ['S', 'M', 'L'], 'Color' => ['Beige', 'Olive', 'Sky Blue']], ['t-shirts'], 'Lightweight linen blend v-neck. Perfect for warm summer days.'], + ['Striped Polo Shirt', 'striped-polo-shirt', 'Acme Basics', 'T-Shirts', ['sale'], 2799, 3999, 250, 10, 'deny', true, ['Size' => ['S', 'M', 'L', 'XL']], ['t-shirts', 'sale'], 'Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket.'], + ['Cargo Pants', 'cargo-pants', 'Acme Workwear', 'Pants', ['popular'], 5499, null, 700, 14, 'deny', true, ['Size' => ['30', '32', '34', '36'], 'Color' => ['Khaki', 'Olive', 'Black']], ['pants-jeans'], 'Utility cargo pants with multiple pockets. Durable cotton twill construction.'], + ['Chino Shorts', 'chino-shorts', 'Acme Basics', 'Pants', ['new', 'trending'], 3999, null, 350, 16, 'deny', true, ['Size' => ['30', '32', '34', '36'], 'Color' => ['Navy', 'Sand']], ['pants-jeans', 'new-arrivals'], 'Tailored chino shorts. Comfortable stretch fabric with a clean silhouette.'], + ['Wide Leg Trousers', 'wide-leg-trousers', 'Acme Denim', 'Pants', ['sale'], 4999, 6999, 550, 7, 'deny', true, ['Size' => ['S', 'M', 'L']], ['pants-jeans', 'sale'], 'Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric.'], + ['Wool Scarf', 'wool-scarf', 'Acme Accessories', 'Accessories', ['popular'], 2999, null, 120, 30, 'deny', true, ['Color' => ['Grey', 'Burgundy', 'Navy']], [], 'Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating.'], + ['Canvas Tote Bag', 'canvas-tote-bag', 'Acme Accessories', 'Accessories', ['trending'], 1999, null, 300, 40, 'deny', true, ['Color' => ['Natural', 'Black']], [], 'Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials.'], + ['Bucket Hat', 'bucket-hat', 'Acme Accessories', 'Accessories', ['new', 'trending'], 2499, null, 80, 22, 'deny', true, ['Size' => ['S/M', 'L/XL'], 'Color' => ['Beige', 'Black', 'Olive']], ['new-arrivals'], 'Lightweight bucket hat for sun protection. Packable design, washed cotton twill.'], + ['Unreleased Winter Jacket', 'unreleased-winter-jacket', 'Acme Outerwear', 'Jackets', ['limited'], 14999, null, 900, 0, 'deny', true, ['Size' => ['S', 'M', 'L', 'XL']], [], 'Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell.', 'draft'], + ['Discontinued Raincoat', 'discontinued-raincoat', 'Acme Outerwear', 'Jackets', [], 8999, null, 400, 3, 'deny', true, ['Size' => ['M', 'L']], [], 'Lightweight waterproof raincoat. This product has been discontinued.', 'archived'], + ['Limited Edition Sneakers', 'limited-edition-sneakers', 'Acme Sport', 'Shoes', ['limited'], 15999, null, 650, 0, 'deny', true, ['Size' => ['EU 40', 'EU 42', 'EU 44']], [], 'Limited edition collaboration sneakers. Once they are gone, they are gone.'], + ['Backorder Denim Jacket', 'backorder-denim-jacket', 'Acme Denim', 'Jackets', ['popular'], 9999, null, 750, 0, 'continue', true, ['Size' => ['S', 'M', 'L', 'XL']], [], 'Classic denim jacket. Currently on backorder - ships within 2-3 weeks.'], + ['Gift Card', 'gift-card', 'Acme Fashion', 'Gift Cards', ['popular'], [2500, 5000, 10000], null, 0, 9999, 'deny', false, ['Amount' => ['25 EUR', '50 EUR', '100 EUR']], [], 'Digital gift card delivered via email. The perfect gift when you are not sure what to choose.'], + ['Cashmere Overcoat', 'cashmere-overcoat', 'Acme Premium', 'Jackets', ['limited', 'new'], 49999, null, 1200, 3, 'deny', true, ['Size' => ['S', 'M', 'L'], 'Color' => ['Camel', 'Charcoal']], ['new-arrivals'], 'Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining.'], + ]; + + foreach ($products as $position => $definition) { + $this->seedProduct($fashion, $definition, $position); + } + + foreach ([ + ['Pro Laptop 15', 'pro-laptop-15', 'TechCorp', 'Laptops', ['featured'], [99999, 119999, 149999], null, 1800, 10, 'deny', true, ['Storage' => ['256GB', '512GB', '1TB']], ['featured'], 'Professional laptop with a brilliant 15-inch display.'], + ['Wireless Headphones', 'wireless-headphones', 'AudioMax', 'Audio', ['featured'], 14999, null, 250, 25, 'deny', true, ['Color' => ['Black', 'Silver']], ['featured'], 'Immersive wireless audio with active noise cancellation.'], + ['USB-C Cable 2m', 'usb-c-cable-2m', 'CablePro', 'Cables', ['accessory'], 1299, null, 50, 200, 'deny', true, [], ['accessories'], 'Durable two-metre USB-C charging and data cable.'], + ['Mechanical Keyboard', 'mechanical-keyboard', 'KeyTech', 'Peripherals', ['featured'], 12999, null, 1100, 15, 'deny', true, ['Switch Type' => ['Red', 'Blue', 'Brown']], ['featured'], 'Precision mechanical keyboard for work and gaming.'], + ['Monitor Stand', 'monitor-stand', 'DeskGear', 'Accessories', ['accessory'], 4999, null, 2500, 30, 'deny', true, [], ['accessories'], 'Ergonomic monitor stand with integrated storage.'], + ] as $position => $definition) { + $this->seedProduct($electronics, $definition, $position); + } + } + + /** @param array $definition */ + private function seedProduct(Store $store, array $definition, int $productPosition): void + { + [$title, $handle, $vendor, $type, $tags, $prices, $compareAt, $weight, $inventory, $policy, $requiresShipping, $options, $collections, $description, $status] = array_pad($definition, 16, 'active'); + $product = Product::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'handle' => $handle], [ + 'title' => $title, + 'status' => $status, + 'description_html' => '

'.$description.'

', + 'vendor' => $vendor, + 'product_type' => $type, + 'tags' => $tags, + 'published_at' => $status === 'draft' ? null : ($status === 'archived' ? now()->subMonths(6) : now()), + ]); + + if (! $product->variants()->exists()) { + $groups = []; + foreach ($options as $optionPosition => $values) { + $option = $product->options()->create(['name' => $optionPosition, 'position' => count($groups)]); + $groups[] = collect($values)->values()->map(fn (string $value, int $valuePosition) => $option->values()->create(['value' => $value, 'position' => $valuePosition]))->all(); + } + $combinations = $groups === [] ? [[]] : $this->cartesian($groups); + foreach ($combinations as $position => $combination) { + $price = is_array($prices) ? (int) ($prices[$position] ?? end($prices)) : (int) $prices; + $sku = $handle === 'gift-card' + ? ['ACME-GIFT-25', 'ACME-GIFT-50', 'ACME-GIFT-100'][$position] + : strtoupper('ACME-'.substr(preg_replace('/[^a-z0-9]/', '', $handle), 0, 8).'-'.($position + 1)); + $variant = $product->variants()->create([ + 'sku' => $sku, + 'barcode' => null, + 'price_amount' => $price, + 'compare_at_amount' => $compareAt, + 'currency' => 'EUR', + 'weight_g' => $weight, + 'requires_shipping' => $requiresShipping, + 'is_default' => $position === 0, + 'position' => $position, + 'status' => 'active', + ]); + if ($combination !== []) { + $variant->optionValues()->sync(collect($combination)->pluck('id')->all()); + } + InventoryItem::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'variant_id' => $variant->id, + 'quantity_on_hand' => $inventory, + 'quantity_reserved' => 0, + 'policy' => $policy, + ]); + } + } + + $collectionIds = Collection::withoutGlobalScopes()->where('store_id', $store->id)->whereIn('handle', $collections)->pluck('id', 'handle'); + $sync = []; + foreach ($collections as $position => $collectionHandle) { + if (isset($collectionIds[$collectionHandle])) { + $sync[$collectionIds[$collectionHandle]] = ['position' => $productPosition]; + } + } + $product->collections()->sync($sync); + } + + /** @param list> $groups @return list> */ + private function cartesian(array $groups): array + { + $result = [[]]; + foreach ($groups as $group) { + $next = []; + foreach ($result as $prefix) { + foreach ($group as $value) { + $next[] = [...$prefix, $value]; + } + } + $result = $next; + } + + return $result; + } +} diff --git a/database/seeders/SearchSettingsSeeder.php b/database/seeders/SearchSettingsSeeder.php new file mode 100644 index 00000000..70732d7a --- /dev/null +++ b/database/seeders/SearchSettingsSeeder.php @@ -0,0 +1,21 @@ + [[['tee', 't-shirt', 'tshirt'], ['pants', 'trousers', 'jeans'], ['sneakers', 'trainers', 'shoes'], ['hoodie', 'sweatshirt']], ['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'is']], + 'acme-electronics' => [[['laptop', 'notebook', 'computer'], ['headphones', 'earphones', 'earbuds'], ['cable', 'cord', 'wire']], ['the', 'a', 'an', 'and', 'or']], + ] as $handle => [$synonyms, $stopWords]) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + SearchSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id], ['synonyms_json' => $synonyms, 'stop_words_json' => $stopWords]); + } + } +} diff --git a/database/seeders/ShippingSeeder.php b/database/seeders/ShippingSeeder.php new file mode 100644 index 00000000..d3d56a7f --- /dev/null +++ b/database/seeders/ShippingSeeder.php @@ -0,0 +1,28 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + $zones = [ + [$fashion, 'Domestic', ['DE'], [['Standard Shipping', 499], ['Express Shipping', 999]]], + [$fashion, 'EU', ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], [['EU Standard', 899]]], + [$fashion, 'Rest of World', ['US', 'GB', 'CA', 'AU'], [['International', 1499]]], + [$electronics, 'Germany', ['DE'], [['Standard', 0]]], + ]; + foreach ($zones as [$store, $name, $countries, $rates]) { + $zone = ShippingZone::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'name' => $name], ['countries_json' => $countries, 'regions_json' => []]); + foreach ($rates as [$rateName, $amount]) { + $zone->rates()->updateOrCreate(['name' => $rateName], ['type' => 'flat', 'config_json' => ['amount' => $amount], 'is_active' => true]); + } + } + } +} diff --git a/database/seeders/StoreDomainSeeder.php b/database/seeders/StoreDomainSeeder.php new file mode 100644 index 00000000..0294e3ea --- /dev/null +++ b/database/seeders/StoreDomainSeeder.php @@ -0,0 +1,29 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + foreach ([ + [$fashion, 'shop.test', 'storefront', true], + [$fashion, 'acme-fashion.test', 'storefront', true], + [$fashion, 'admin.acme-fashion.test', 'admin', false], + [$electronics, 'acme-electronics.test', 'storefront', true], + ] as [$store, $hostname, $type, $primary]) { + StoreDomain::query()->updateOrCreate(['hostname' => $hostname], [ + 'store_id' => $store->id, + 'type' => $type, + 'is_primary' => $primary, + 'tls_mode' => 'managed', + ]); + } + } +} diff --git a/database/seeders/StoreSeeder.php b/database/seeders/StoreSeeder.php new file mode 100644 index 00000000..3c413e33 --- /dev/null +++ b/database/seeders/StoreSeeder.php @@ -0,0 +1,28 @@ +where('billing_email', 'billing@acme.test')->firstOrFail(); + foreach ([ + ['name' => 'Acme Fashion', 'handle' => 'acme-fashion'], + ['name' => 'Acme Electronics', 'handle' => 'acme-electronics'], + ] as $data) { + Store::query()->updateOrCreate(['handle' => $data['handle']], [ + 'organization_id' => $organization->id, + 'name' => $data['name'], + 'status' => 'active', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ]); + } + } +} diff --git a/database/seeders/StoreSettingsSeeder.php b/database/seeders/StoreSettingsSeeder.php new file mode 100644 index 00000000..01e3d028 --- /dev/null +++ b/database/seeders/StoreSettingsSeeder.php @@ -0,0 +1,21 @@ + ['store_name' => 'Acme Fashion', 'contact_email' => 'hello@acme-fashion.test', 'order_number_prefix' => '#', 'order_number_start' => 1001, 'bank_transfer_cancel_days' => 7], + 'acme-electronics' => ['store_name' => 'Acme Electronics', 'contact_email' => 'hello@acme-electronics.test', 'order_number_prefix' => '#', 'order_number_start' => 5001, 'bank_transfer_cancel_days' => 7], + ] as $handle => $settings) { + $store = Store::query()->where('handle', $handle)->firstOrFail(); + StoreSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id], ['settings_json' => $settings]); + } + } +} diff --git a/database/seeders/StoreUserSeeder.php b/database/seeders/StoreUserSeeder.php new file mode 100644 index 00000000..5dc822e6 --- /dev/null +++ b/database/seeders/StoreUserSeeder.php @@ -0,0 +1,30 @@ +where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + foreach ([ + ['admin@acme.test', $fashion, 'owner'], + ['staff@acme.test', $fashion, 'staff'], + ['support@acme.test', $fashion, 'support'], + ['manager@acme.test', $fashion, 'admin'], + ['admin2@acme.test', $electronics, 'owner'], + ] as [$email, $store, $role]) { + $user = User::query()->where('email', $email)->firstOrFail(); + DB::table('store_users')->updateOrInsert( + ['store_id' => $store->id, 'user_id' => $user->id], + ['role' => $role, 'created_at' => now()], + ); + } + } +} diff --git a/database/seeders/TaxSettingsSeeder.php b/database/seeders/TaxSettingsSeeder.php new file mode 100644 index 00000000..4ae7d19b --- /dev/null +++ b/database/seeders/TaxSettingsSeeder.php @@ -0,0 +1,18 @@ +each(fn (Store $store) => TaxSettings::withoutGlobalScopes()->updateOrCreate( + ['store_id' => $store->id], + ['mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => true, 'config_json' => ['default_rate_bps' => 1900, 'label' => 'VAT']], + )); + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..b7967901 --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,41 @@ +where('handle', $handle)->firstOrFail(); + $fashion = $handle === 'acme-fashion'; + $theme = Theme::withoutGlobalScopes()->updateOrCreate(['store_id' => $store->id, 'name' => 'Default Theme'], [ + 'version' => '1.0.0', 'status' => 'published', 'published_at' => now(), + ]); + $settings = [ + 'primary_color' => $fashion ? '#1a1a2e' : '#0f172a', + 'secondary_color' => $fashion ? '#e94560' : '#3b82f6', + 'font_family' => 'Inter, sans-serif', + 'hero_heading' => $fashion ? 'Welcome to Acme Fashion' : 'Acme Electronics', + 'hero_subheading' => $fashion ? 'Discover our curated collection of modern essentials' : 'Premium tech for professionals', + 'hero_cta_text' => $fashion ? 'Shop New Arrivals' : 'Shop Featured', + 'hero_cta_link' => $fashion ? '/collections/new-arrivals' : '/collections/featured', + 'featured_collection_handles' => $fashion ? ['new-arrivals', 't-shirts', 'sale'] : ['featured'], + 'footer_text' => $fashion ? '2025 Acme Fashion. All rights reserved.' : '2025 Acme Electronics. All rights reserved.', + 'show_announcement_bar' => $fashion, + 'announcement_text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', + 'products_per_page' => 12, + 'show_vendor' => true, + 'show_quantity_selector' => true, + 'colors' => ['primary' => $fashion ? '#1a1a2e' : '#0f172a', 'secondary' => $fashion ? '#e94560' : '#3b82f6', 'accent' => '#f59e0b'], + 'announcement' => ['enabled' => $fashion, 'text' => 'Free shipping on orders over 50 EUR - Use code FREESHIP', 'background' => '#18181b'], + 'home' => ['hero' => ['enabled' => true, 'heading' => $fashion ? 'Welcome to Acme Fashion' : 'Acme Electronics', 'subheading' => $fashion ? 'Discover our curated collection of modern essentials' : 'Premium tech for professionals', 'cta_label' => $fashion ? 'Shop New Arrivals' : 'Shop Featured', 'cta_url' => $fashion ? '/collections/new-arrivals' : '/collections/featured']], + ]; + $theme->settings()->updateOrCreate(['theme_id' => $theme->id], ['settings_json' => $settings]); + } + } +} diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..1a8c284e --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,29 @@ +subDays(2)], + ['support@acme.test', 'Support User', now()->subDay()], + ['manager@acme.test', 'Store Manager', now()->subDay()], + ['admin2@acme.test', 'Admin Two', now()->subDay()], + ] as [$email, $name, $lastLogin]) { + User::query()->updateOrCreate(['email' => $email], [ + 'name' => $name, + 'password_hash' => Hash::make('password'), + 'status' => 'active', + 'email_verified_at' => now(), + 'last_login_at' => $lastLogin, + ]); + } + } +} diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..326ea580 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,91 @@ +middleware(['store.resolve', 'throttle:api.storefront'])->group(function (): void { + Route::post('carts', [CartController::class, 'store']); + Route::get('carts/{cartId}', [CartController::class, 'show']); + Route::post('carts/{cartId}/lines', [CartController::class, 'addLine']); + Route::put('carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine']); + Route::delete('carts/{cartId}/lines/{lineId}', [CartController::class, 'removeLine']); + + Route::middleware('throttle:checkout')->group(function (): void { + Route::post('checkouts', [CheckoutController::class, 'store']); + Route::get('checkouts/{checkoutId}', [CheckoutController::class, 'show']); + Route::put('checkouts/{checkoutId}/address', [CheckoutController::class, 'address']); + Route::put('checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'shipping']); + Route::put('checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'payment']); + Route::post('checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'discount']); + Route::delete('checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount']); + Route::post('checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay']); + }); + + Route::get('search', [SearchController::class, 'index'])->middleware('throttle:search'); + Route::get('search/suggest', [SearchController::class, 'suggest'])->middleware('throttle:search'); + Route::post('analytics/events', [AnalyticsController::class, 'store'])->middleware('throttle:analytics'); + Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show']); +}); + +Route::prefix('admin/v1')->middleware(['auth:sanctum', 'throttle:api.admin'])->group(function (): void { + Route::post('platform/organizations', [PlatformController::class, 'organization'])->middleware('abilities:manage-platform'); + Route::post('platform/stores', [PlatformController::class, 'store'])->middleware('abilities:manage-platform'); + + Route::prefix('stores/{storeId}')->middleware('store.resolve')->group(function (): void { + Route::get('me', [PlatformController::class, 'me']); + Route::post('invites', [PlatformController::class, 'invite'])->middleware('abilities:manage-platform'); + + Route::get('products', [AdminProductController::class, 'index'])->middleware('abilities:read-products'); + Route::post('products', [AdminProductController::class, 'store'])->middleware('abilities:write-products'); + Route::get('products/{productId}', [AdminProductController::class, 'show'])->middleware('abilities:read-products'); + Route::put('products/{productId}', [AdminProductController::class, 'update'])->middleware('abilities:write-products'); + Route::delete('products/{productId}', [AdminProductController::class, 'destroy'])->middleware('abilities:write-products'); + + Route::get('collections', [AdminCollectionController::class, 'index'])->middleware('abilities:read-collections'); + Route::post('collections', [AdminCollectionController::class, 'store'])->middleware('abilities:write-collections'); + Route::put('collections/{collectionId}', [AdminCollectionController::class, 'update'])->middleware('abilities:write-collections'); + Route::delete('collections/{collectionId}', [AdminCollectionController::class, 'destroy'])->middleware('abilities:write-collections'); + + Route::get('orders', [AdminOrderController::class, 'index'])->middleware('abilities:read-orders'); + Route::get('orders/{orderId}', [AdminOrderController::class, 'show'])->middleware('abilities:read-orders'); + Route::post('orders/{orderId}/fulfillments', [AdminOrderController::class, 'fulfill'])->middleware('abilities:write-orders'); + Route::post('orders/{orderId}/refunds', [AdminOrderController::class, 'refund'])->middleware('abilities:write-orders'); + Route::post('orders/{orderId}/confirm-payment', [AdminOrderController::class, 'confirmPayment'])->middleware('abilities:write-orders'); + + Route::get('discounts', [AdminDiscountController::class, 'index'])->middleware('abilities:read-discounts'); + Route::post('discounts', [AdminDiscountController::class, 'store'])->middleware('abilities:write-discounts'); + Route::put('discounts/{discountId}', [AdminDiscountController::class, 'update'])->middleware('abilities:write-discounts'); + Route::delete('discounts/{discountId}', [AdminDiscountController::class, 'destroy'])->middleware('abilities:write-discounts'); + + Route::get('shipping/zones', [AdminSettingsController::class, 'zones'])->middleware('abilities:read-settings'); + Route::post('shipping/zones', [AdminSettingsController::class, 'storeZone'])->middleware('abilities:write-settings'); + Route::put('shipping/zones/{zoneId}', [AdminSettingsController::class, 'updateZone'])->middleware('abilities:write-settings'); + Route::post('shipping/zones/{zoneId}/rates', [AdminSettingsController::class, 'storeRate'])->middleware('abilities:write-settings'); + Route::get('tax/settings', [AdminSettingsController::class, 'tax'])->middleware('abilities:read-settings'); + Route::put('tax/settings', [AdminSettingsController::class, 'updateTax'])->middleware('abilities:write-settings'); + + Route::get('pages', [AdminContentController::class, 'pages'])->middleware('abilities:read-content'); + Route::post('pages', [AdminContentController::class, 'storePage'])->middleware('abilities:write-content'); + Route::put('pages/{pageId}', [AdminContentController::class, 'updatePage'])->middleware('abilities:write-content'); + Route::delete('pages/{pageId}', [AdminContentController::class, 'destroyPage'])->middleware('abilities:write-content'); + Route::post('themes', [AdminContentController::class, 'storeTheme'])->middleware('abilities:write-themes'); + Route::post('themes/{themeId}/publish', [AdminContentController::class, 'publishTheme'])->middleware('abilities:write-themes'); + Route::put('themes/{themeId}/settings', [AdminContentController::class, 'updateThemeSettings'])->middleware('abilities:write-themes'); + Route::post('search/reindex', [AdminContentController::class, 'reindex'])->middleware('abilities:write-settings'); + Route::get('search/status', [AdminContentController::class, 'searchStatus'])->middleware('abilities:read-settings'); + Route::get('analytics/summary', [AdminAnalyticsController::class, 'summary'])->middleware('abilities:read-analytics'); + Route::post('exports/orders', [AdminAnalyticsController::class, 'exportOrders'])->middleware('abilities:read-orders'); + }); +}); diff --git a/specs/progress.md b/specs/progress.md index 85aed49d..fa0c6eb7 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -10,9 +10,9 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro | Phase | Status | Verification | | --- | --- | --- | -| 0. Specification traceability and repository audit | In progress | Specs and starter audited by backend, frontend, and QA roles | -| 1. Foundation, tenancy, authentication, authorization | Pending | Pest + migration/seed smoke | -| 2. Catalog, inventory, collections, media | Pending | Pest unit/feature | +| 0. Specification traceability and repository audit | Complete | Specs and starter audited by backend, frontend, and QA roles | +| 1. Foundation, tenancy, authentication, authorization | Complete | All migrations pass; tenancy/auth infrastructure and factories are present | +| 2. Catalog, inventory, collections, media | In progress | Schema/models/factories/demo catalog complete; behavior tests pending | | 3. Themes, CMS, navigation, storefront shell | Pending | Pest feature + Chrome | | 4. Cart, checkout, discounts, shipping, taxes | Pending | Pest unit/feature + Chrome | | 5. Payments, orders, refunds, fulfillment | Pending | Pest unit/feature + Chrome | @@ -32,20 +32,30 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro - Started independent backend architecture, frontend architecture, and QA traceability reviews. - Began mapping the strict dependency order from `09-IMPLEMENTATION-ROADMAP.md` to implementation commits. +### Iteration 1 - Domain foundation and deterministic demo data + +- Added all 46 application tables, Laravel support tables, personal access tokens, customer reset tokens, and SQLite FTS5 in dependency-safe migrations. +- Added the full enum/model/relationship/cast layer, tenant global scope, automatic `store_id`, membership roles, password-hash compatibility, and 27 factories. +- Added tenant resolution, customer auth provider, role middleware, policies, API rate limits, and Sanctum ability boundaries. +- Implemented the core catalog, inventory, cart, checkout, pricing, discount, shipping, tax, mock payment, order, refund, fulfillment, search, analytics, webhook, media, navigation, and customer services. +- Added deterministic seed orchestration for two stores, all named credentials, detailed fashion/electronics catalogs and variants, inventory edge cases, discounts, customers/addresses, orders, themes, pages, navigation, analytics, and search settings. +- Added `shop.test` as an explicit Acme Fashion storefront domain while retaining the specification's demo domains. +- Verified a fresh migrate-and-seed and a second idempotent seed execution. + ## Verification Ledger | Check | Latest Result | | --- | --- | | Working tree at start | Clean | | Existing shop code reused | No | -| Pest suite | Not run yet | +| Pest suite | Baseline not yet run against integrated shop | | Vite production build | Not run yet | -| Fresh migrate and seed | Not run yet | +| Fresh migrate and seed | Passed (all migrations and all 18 seed stages) | | Laravel code-quality checker | Not installed yet | | Chrome acceptance review | Not run yet | ## Open Risks -- SQLite FTS5 availability must be verified in the local PHP SQLite build. -- `shop.test` must resolve to the seeded storefront while admin tenant selection remains session-based. +- SQLite FTS5 is available and its migration passes locally. +- `shop.test`, `acme-fashion.test`, and `acme-electronics.test` are seeded; live browser host routing remains to be verified. - The specification spans roughly 320-380 files; traceability and staged verification are required to prevent late integration defects. From 2328f9308308e57c21605046e094992ff7ec777d Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 22:26:48 +0200 Subject: [PATCH 04/10] feat: deliver complete customer storefront --- app/Auth/CustomerPasswordBroker.php | 48 ++ app/Auth/TenantDatabaseTokenRepository.php | 28 ++ .../Storefront/Account/Addresses/Index.php | 140 ++++++ .../Account/Auth/ForgotPassword.php | 32 ++ .../Storefront/Account/Auth/Login.php | 81 ++++ .../Storefront/Account/Auth/Register.php | 74 +++ .../Storefront/Account/Auth/ResetPassword.php | 62 +++ .../Storefront/Account/CustomerComponent.php | 20 + app/Livewire/Storefront/Account/Dashboard.php | 37 ++ .../Storefront/Account/Orders/Index.php | 30 ++ .../Storefront/Account/Orders/Show.php | 28 ++ app/Livewire/Storefront/Cart/Show.php | 26 ++ app/Livewire/Storefront/CartDrawer.php | 44 ++ .../Storefront/Checkout/Confirmation.php | 55 +++ app/Livewire/Storefront/Checkout/Show.php | 430 ++++++++++++++++++ app/Livewire/Storefront/Collections/Index.php | 27 ++ app/Livewire/Storefront/Collections/Show.php | 140 ++++++ .../Storefront/Concerns/ManagesCart.php | 236 ++++++++++ .../Storefront/Concerns/QuickAddsProducts.php | 36 ++ app/Livewire/Storefront/FeaturedProducts.php | 35 ++ app/Livewire/Storefront/Home.php | 31 ++ app/Livewire/Storefront/NewsletterSignup.php | 34 ++ app/Livewire/Storefront/Pages/Show.php | 32 ++ app/Livewire/Storefront/Products/Show.php | 143 ++++++ app/Livewire/Storefront/Search/Index.php | 148 ++++++ app/Livewire/Storefront/Search/Modal.php | 87 ++++ .../Storefront/StorefrontComponent.php | 132 ++++++ app/Models/Customer.php | 6 +- app/Providers/AppServiceProvider.php | 6 + bootstrap/app.php | 4 +- resources/css/app.css | 396 ++++++++++++++++ .../storefront/account-shell.blade.php | 2 + .../storefront/address-form.blade.php | 163 +++++++ .../components/storefront/badge.blade.php | 34 ++ .../storefront/breadcrumbs.blade.php | 59 +++ .../components/storefront/layout.blade.php | 92 ++++ .../storefront/order-summary.blade.php | 179 ++++++++ .../storefront/pagination.blade.php | 125 +++++ .../components/storefront/price.blade.php | 49 ++ .../storefront/product-card.blade.php | 177 +++++++ .../storefront/quantity-selector.blade.php | 75 +++ .../components/storefront/rich-text.blade.php | 87 ++++ resources/views/errors/404.blade.php | 1 + resources/views/errors/503.blade.php | 1 + .../account/addresses/index.blade.php | 5 + .../account/auth/forgot-password.blade.php | 1 + .../storefront/account/auth/login.blade.php | 14 + .../account/auth/register.blade.php | 1 + .../account/auth/reset-password.blade.php | 1 + .../storefront/account/dashboard.blade.php | 4 + .../storefront/account/orders/index.blade.php | 6 + .../storefront/account/orders/show.blade.php | 9 + .../views/storefront/cart/_discount.blade.php | 9 + .../views/storefront/cart/show.blade.php | 30 ++ .../checkout/_address-fields.blade.php | 50 ++ .../storefront/checkout/_summary.blade.php | 17 + .../checkout/confirmation.blade.php | 16 + .../views/storefront/checkout/show.blade.php | 104 +++++ .../storefront/collections/_filters.blade.php | 20 + .../storefront/collections/index.blade.php | 38 ++ .../storefront/collections/show.blade.php | 74 +++ .../components/cart-drawer.blade.php | 40 ++ .../components/featured-products.blade.php | 7 + .../components/newsletter-signup.blade.php | 23 + .../product-grid-skeleton.blade.php | 9 + .../components/search-modal.blade.php | 19 + .../views/storefront/errors/404.blade.php | 2 + .../views/storefront/errors/503.blade.php | 2 + resources/views/storefront/home.blade.php | 77 ++++ .../views/storefront/layouts/app.blade.php | 100 ++++ .../views/storefront/pages/show.blade.php | 5 + .../views/storefront/products/show.blade.php | 134 ++++++ .../storefront/search/_filters.blade.php | 1 + .../views/storefront/search/index.blade.php | 23 + routes/web.php | 54 ++- specs/progress.md | 15 +- 76 files changed, 4573 insertions(+), 9 deletions(-) create mode 100644 app/Auth/CustomerPasswordBroker.php create mode 100644 app/Auth/TenantDatabaseTokenRepository.php create mode 100644 app/Livewire/Storefront/Account/Addresses/Index.php create mode 100644 app/Livewire/Storefront/Account/Auth/ForgotPassword.php create mode 100644 app/Livewire/Storefront/Account/Auth/Login.php create mode 100644 app/Livewire/Storefront/Account/Auth/Register.php create mode 100644 app/Livewire/Storefront/Account/Auth/ResetPassword.php create mode 100644 app/Livewire/Storefront/Account/CustomerComponent.php create mode 100644 app/Livewire/Storefront/Account/Dashboard.php create mode 100644 app/Livewire/Storefront/Account/Orders/Index.php create mode 100644 app/Livewire/Storefront/Account/Orders/Show.php create mode 100644 app/Livewire/Storefront/Cart/Show.php create mode 100644 app/Livewire/Storefront/CartDrawer.php create mode 100644 app/Livewire/Storefront/Checkout/Confirmation.php create mode 100644 app/Livewire/Storefront/Checkout/Show.php create mode 100644 app/Livewire/Storefront/Collections/Index.php create mode 100644 app/Livewire/Storefront/Collections/Show.php create mode 100644 app/Livewire/Storefront/Concerns/ManagesCart.php create mode 100644 app/Livewire/Storefront/Concerns/QuickAddsProducts.php create mode 100644 app/Livewire/Storefront/FeaturedProducts.php create mode 100644 app/Livewire/Storefront/Home.php create mode 100644 app/Livewire/Storefront/NewsletterSignup.php create mode 100644 app/Livewire/Storefront/Pages/Show.php create mode 100644 app/Livewire/Storefront/Products/Show.php create mode 100644 app/Livewire/Storefront/Search/Index.php create mode 100644 app/Livewire/Storefront/Search/Modal.php create mode 100644 app/Livewire/Storefront/StorefrontComponent.php create mode 100644 resources/views/components/storefront/account-shell.blade.php create mode 100644 resources/views/components/storefront/address-form.blade.php create mode 100644 resources/views/components/storefront/badge.blade.php create mode 100644 resources/views/components/storefront/breadcrumbs.blade.php create mode 100644 resources/views/components/storefront/layout.blade.php create mode 100644 resources/views/components/storefront/order-summary.blade.php create mode 100644 resources/views/components/storefront/pagination.blade.php create mode 100644 resources/views/components/storefront/price.blade.php create mode 100644 resources/views/components/storefront/product-card.blade.php create mode 100644 resources/views/components/storefront/quantity-selector.blade.php create mode 100644 resources/views/components/storefront/rich-text.blade.php create mode 100644 resources/views/errors/404.blade.php create mode 100644 resources/views/errors/503.blade.php create mode 100644 resources/views/storefront/account/addresses/index.blade.php create mode 100644 resources/views/storefront/account/auth/forgot-password.blade.php create mode 100644 resources/views/storefront/account/auth/login.blade.php create mode 100644 resources/views/storefront/account/auth/register.blade.php create mode 100644 resources/views/storefront/account/auth/reset-password.blade.php create mode 100644 resources/views/storefront/account/dashboard.blade.php create mode 100644 resources/views/storefront/account/orders/index.blade.php create mode 100644 resources/views/storefront/account/orders/show.blade.php create mode 100644 resources/views/storefront/cart/_discount.blade.php create mode 100644 resources/views/storefront/cart/show.blade.php create mode 100644 resources/views/storefront/checkout/_address-fields.blade.php create mode 100644 resources/views/storefront/checkout/_summary.blade.php create mode 100644 resources/views/storefront/checkout/confirmation.blade.php create mode 100644 resources/views/storefront/checkout/show.blade.php create mode 100644 resources/views/storefront/collections/_filters.blade.php create mode 100644 resources/views/storefront/collections/index.blade.php create mode 100644 resources/views/storefront/collections/show.blade.php create mode 100644 resources/views/storefront/components/cart-drawer.blade.php create mode 100644 resources/views/storefront/components/featured-products.blade.php create mode 100644 resources/views/storefront/components/newsletter-signup.blade.php create mode 100644 resources/views/storefront/components/product-grid-skeleton.blade.php create mode 100644 resources/views/storefront/components/search-modal.blade.php create mode 100644 resources/views/storefront/errors/404.blade.php create mode 100644 resources/views/storefront/errors/503.blade.php create mode 100644 resources/views/storefront/home.blade.php create mode 100644 resources/views/storefront/layouts/app.blade.php create mode 100644 resources/views/storefront/pages/show.blade.php create mode 100644 resources/views/storefront/products/show.blade.php create mode 100644 resources/views/storefront/search/_filters.blade.php create mode 100644 resources/views/storefront/search/index.blade.php diff --git a/app/Auth/CustomerPasswordBroker.php b/app/Auth/CustomerPasswordBroker.php new file mode 100644 index 00000000..4e56e13e --- /dev/null +++ b/app/Auth/CustomerPasswordBroker.php @@ -0,0 +1,48 @@ + $credentials */ + public function sendResetLink(array $credentials, ?Closure $callback = null): string + { + return $this->broker()->sendResetLink($credentials, $callback); + } + + /** @param array $credentials */ + public function reset(array $credentials, Closure $callback): string + { + return $this->broker()->reset($credentials, $callback); + } + + private function broker(): PasswordBroker + { + $config = config('auth.passwords.customers'); + $key = (string) config('app.key'); + if (str_starts_with($key, 'base64:')) { + $key = (string) base64_decode(substr($key, 7)); + } + $repository = new TenantDatabaseTokenRepository( + $this->app['db']->connection($config['connection'] ?? null), + $this->app['hash'], + $config['table'], + $key, + ($config['expire'] ?? 60) * 60, + $config['throttle'] ?? 0, + ); + + return new PasswordBroker( + $repository, + $this->app['auth']->createUserProvider($config['provider']), + $this->app['events'], + timeboxDuration: (int) config('auth.timebox_duration', 200000), + ); + } +} diff --git a/app/Auth/TenantDatabaseTokenRepository.php b/app/Auth/TenantDatabaseTokenRepository.php new file mode 100644 index 00000000..e971e52e --- /dev/null +++ b/app/Auth/TenantDatabaseTokenRepository.php @@ -0,0 +1,28 @@ + */ + protected function getPayload($email, #[\SensitiveParameter] $token): array + { + return ['store_id' => $this->storeId(), ...parent::getPayload($email, $token)]; + } + + protected function getTable() + { + return parent::getTable()->where('store_id', $this->storeId()); + } + + private function storeId(): int + { + if (! app()->bound('current_store')) { + throw new \LogicException('Customer password reset requires a current store.'); + } + + return (int) app('current_store')->id; + } +} diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php new file mode 100644 index 00000000..38c01b22 --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses/Index.php @@ -0,0 +1,140 @@ + */ + public array $address = [ + 'first_name' => '', 'last_name' => '', 'company' => '', 'address1' => '', 'address2' => '', + 'city' => '', 'province' => '', 'province_code' => '', 'postal_code' => '', 'country' => 'DE', 'phone' => '', + ]; + + public bool $isDefault = false; + + public function mount(): void + { + $this->authenticatedCustomer(); + } + + #[Computed] + public function addresses(): mixed + { + return CustomerAddress::query() + ->where('customer_id', $this->authenticatedCustomer()->getAuthIdentifier()) + ->orderByDesc('is_default') + ->get(); + } + + public function createAddress(): void + { + $this->resetForm(); + $this->formOpen = true; + } + + public function editAddress(int $addressId): void + { + $address = $this->ownedAddress($addressId); + $data = is_string($address->address_json) ? json_decode($address->address_json, true) : (array) $address->address_json; + $data['postal_code'] = $data['postal_code'] ?? $data['zip'] ?? ''; + $data['country'] = $data['country_code'] ?? $data['country'] ?? 'DE'; + + $this->editingId = $address->getKey(); + $this->label = (string) $address->label; + $this->address = array_replace($this->address, $data); + $this->isDefault = (bool) $address->is_default; + $this->formOpen = true; + } + + public function saveAddress(): void + { + $this->validate([ + 'label' => ['nullable', 'string', 'max:100'], + 'address.first_name' => ['required', 'string', 'max:100'], + 'address.last_name' => ['required', 'string', 'max:100'], + 'address.company' => ['nullable', 'string', 'max:150'], + 'address.address1' => ['required', 'string', 'max:255'], + 'address.address2' => ['nullable', 'string', 'max:255'], + 'address.city' => ['required', 'string', 'max:120'], + 'address.province' => ['nullable', 'string', 'max:120'], + 'address.postal_code' => ['required', 'string', 'max:20'], + 'address.country' => ['required', 'string', 'size:2'], + 'address.phone' => ['nullable', 'string', 'max:40'], + 'isDefault' => ['boolean'], + ]); + + $customer = $this->authenticatedCustomer(); + DB::transaction(function () use ($customer): void { + if ($this->isDefault || ! CustomerAddress::query()->where('customer_id', $customer->getAuthIdentifier())->exists()) { + CustomerAddress::query()->where('customer_id', $customer->getAuthIdentifier())->update(['is_default' => false]); + $this->isDefault = true; + } + + $payload = [...$this->address, 'zip' => $this->address['postal_code'], 'country_code' => $this->address['country']]; + CustomerAddress::query()->updateOrCreate( + ['id' => $this->editingId, 'customer_id' => $customer->getAuthIdentifier()], + ['label' => $this->label ?: null, 'address_json' => $payload, 'is_default' => $this->isDefault], + ); + }); + + unset($this->addresses); + $this->formOpen = false; + $this->dispatch('toast', type: 'success', message: 'Address saved'); + } + + public function deleteAddress(int $addressId): void + { + $address = $this->ownedAddress($addressId); + $wasDefault = (bool) $address->is_default; + $address->delete(); + + if ($wasDefault) { + CustomerAddress::query()->where('customer_id', $this->authenticatedCustomer()->getAuthIdentifier())->first()?->update(['is_default' => true]); + } + + unset($this->addresses); + $this->dispatch('toast', type: 'success', message: 'Address removed'); + } + + public function setDefault(int $addressId): void + { + $address = $this->ownedAddress($addressId); + DB::transaction(function () use ($address): void { + CustomerAddress::query()->where('customer_id', $this->authenticatedCustomer()->getAuthIdentifier())->update(['is_default' => false]); + $address->update(['is_default' => true]); + }); + unset($this->addresses); + } + + public function render(): View + { + return $this->storefront(view('storefront.account.addresses.index'), 'Your addresses - '.$this->currentStore()->name); + } + + private function ownedAddress(int $addressId): CustomerAddress + { + return CustomerAddress::query()->where('customer_id', $this->authenticatedCustomer()->getAuthIdentifier())->findOrFail($addressId); + } + + private function resetForm(): void + { + $this->reset('editingId', 'label', 'isDefault'); + $this->resetValidation(); + $this->address = [ + 'first_name' => '', 'last_name' => '', 'company' => '', 'address1' => '', 'address2' => '', + 'city' => '', 'province' => '', 'province_code' => '', 'postal_code' => '', 'country' => 'DE', 'phone' => '', + ]; + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ForgotPassword.php b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php new file mode 100644 index 00000000..68571eff --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php @@ -0,0 +1,32 @@ +validate(['email' => ['required', 'email:rfc', 'max:255']]); + + app(CustomerPasswordBroker::class)->sendResetLink([ + 'email' => $this->email, + 'store_id' => $this->currentStore()->getKey(), + ]); + + $this->sent = true; + } + + public function render(): View + { + return $this->storefront(view('storefront.account.auth.forgot-password'), 'Reset your password - '.$this->currentStore()->name); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php new file mode 100644 index 00000000..43b5252b --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,81 @@ +check()) { + return $this->redirect(url('/account'), navigate: true); + } + + return null; + } + + public function login(): mixed + { + $this->validate([ + 'email' => ['required', 'email:rfc', 'max:255'], + 'password' => ['required', 'string'], + ]); + + $key = 'customer-login:'.Str::lower($this->email).'|'.request()->ip(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $seconds = RateLimiter::availableIn($key); + $this->addError('email', "Too many attempts. Try again in {$seconds} seconds."); + + return null; + } + + if (! Auth::guard('customer')->attempt(['email' => $this->email, 'password' => $this->password])) { + RateLimiter::hit($key, 60); + $this->addError('credentials', 'Invalid credentials'); + + return null; + } + + RateLimiter::clear($key); + request()->session()->regenerate(); + $this->mergeGuestCart(); + + return $this->redirectIntended(default: url('/account'), navigate: true); + } + + public function render(): View + { + return $this->storefront(view('storefront.account.auth.login'), 'Log in - '.$this->currentStore()->name); + } + + private function mergeGuestCart(): void + { + $guestCartId = session('cart_id'); + if (! $guestCartId) { + return; + } + + $guestCart = Cart::query()->where('store_id', $this->currentStore()->getKey())->whereKey($guestCartId)->whereNull('customer_id')->first(); + if (! $guestCart) { + return; + } + + $service = app(CartService::class); + $customer = Auth::guard('customer')->user(); + $customerCart = Cart::query()->where('store_id', $this->currentStore()->getKey())->where('customer_id', $customer->getAuthIdentifier())->where('status', 'active')->first() + ?: $service->create($this->currentStore(), $customer); + $service->mergeOnLogin($guestCart, $customerCart); + session(['cart_id' => $customerCart->getKey()]); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..8a29f5d1 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,74 @@ +check() ? $this->redirect(url('/account'), navigate: true) : null; + } + + public function register(): mixed + { + $store = $this->currentStore(); + $this->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email:rfc', 'max:255', Rule::unique('customers', 'email') + ->where('store_id', $store->getKey()) + ->whereNotNull('password_hash')], + 'password' => ['required', 'string', 'min:8', 'same:passwordConfirmation'], + 'passwordConfirmation' => ['required', 'string'], + 'marketingOptIn' => ['boolean'], + ], ['password.same' => 'The passwords do not match.']); + + $customer = Customer::withoutGlobalScopes()->firstOrNew([ + 'store_id' => $store->getKey(), + 'email' => mb_strtolower($this->email), + ]); + $customer->fill([ + 'name' => $this->name, + 'password_hash' => Hash::make($this->password), + 'marketing_opt_in' => $this->marketingOptIn, + ])->save(); + + Auth::guard('customer')->login($customer); + request()->session()->regenerate(); + + if ($cartId = session('cart_id')) { + Cart::withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereKey($cartId) + ->whereNull('customer_id') + ->where('status', 'active') + ->update(['customer_id' => $customer->getKey()]); + } + + $this->dispatch('toast', type: 'success', message: 'Account created successfully'); + + return $this->redirect(url('/account'), navigate: true); + } + + public function render(): View + { + return $this->storefront(view('storefront.account.auth.register'), 'Create an account - '.$this->currentStore()->name); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ResetPassword.php b/app/Livewire/Storefront/Account/Auth/ResetPassword.php new file mode 100644 index 00000000..25676903 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ResetPassword.php @@ -0,0 +1,62 @@ +token = $token; + $this->email = (string) request()->query('email', ''); + } + + public function resetPassword(): mixed + { + $this->validate([ + 'token' => ['required'], + 'email' => ['required', 'email:rfc'], + 'password' => ['required', 'string', 'min:8', 'same:passwordConfirmation'], + 'passwordConfirmation' => ['required'], + ], ['password.same' => 'The passwords do not match.']); + + $status = app(CustomerPasswordBroker::class)->reset([ + 'email' => $this->email, + 'store_id' => $this->currentStore()->getKey(), + 'password' => $this->password, + 'password_confirmation' => $this->passwordConfirmation, + 'token' => $this->token, + ], function (Customer $customer): void { + $customer->forceFill(['password_hash' => Hash::make($this->password)])->save(); + event(new PasswordReset($customer)); + }); + + if ($status !== Password::PASSWORD_RESET) { + $this->addError('email', __($status)); + + return null; + } + + return $this->redirect(url('/account/login'), navigate: true); + } + + public function render(): View + { + return $this->storefront(view('storefront.account.auth.reset-password'), 'Choose a new password - '.$this->currentStore()->name); + } +} diff --git a/app/Livewire/Storefront/Account/CustomerComponent.php b/app/Livewire/Storefront/Account/CustomerComponent.php new file mode 100644 index 00000000..64c827af --- /dev/null +++ b/app/Livewire/Storefront/Account/CustomerComponent.php @@ -0,0 +1,20 @@ +user(); + abort_unless($customer, 401); + abort_unless((int) $customer->store_id === (int) $this->currentStore()->getKey(), 403); + + return $customer; + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..4aa06578 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,37 @@ +authenticatedCustomer(); + } + + public function logout(): mixed + { + Auth::guard('customer')->logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return $this->redirect(url('/account/login'), navigate: true); + } + + public function render(): View + { + $customer = $this->authenticatedCustomer(); + $orders = Order::query() + ->where('store_id', $this->currentStore()->getKey()) + ->where('customer_id', $customer->getAuthIdentifier()) + ->latest('placed_at') + ->limit(5) + ->get(); + + return $this->storefront(view('storefront.account.dashboard', compact('customer', 'orders')), 'Your account - '.$this->currentStore()->name); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..59fb5097 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,30 @@ +authenticatedCustomer(); + } + + public function render(): View + { + $customer = $this->authenticatedCustomer(); + $orders = Order::query() + ->where('store_id', $this->currentStore()->getKey()) + ->where('customer_id', $customer->getAuthIdentifier()) + ->latest('placed_at') + ->paginate(10); + + return $this->storefront(view('storefront.account.orders.index', compact('orders')), 'Order history - '.$this->currentStore()->name); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..8fa1c5e2 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,28 @@ +authenticatedCustomer(); + $this->order = Order::query() + ->where('store_id', $this->currentStore()->getKey()) + ->where('customer_id', $customer->getAuthIdentifier()) + ->where('order_number', $orderNumber) + ->with(['lines.product.media', 'payments', 'fulfillments.lines.orderLine']) + ->firstOrFail(); + } + + public function render(): View + { + return $this->storefront(view('storefront.account.orders.show'), 'Order '.$this->order->order_number.' - '.$this->currentStore()->name); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..88ef27c5 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,26 @@ +initializeCartDiscount(); + } + + public function render(): View + { + return $this->storefront( + view('storefront.cart.show'), + 'Your Cart - '.$this->currentStore()->name, + 'Review the items in your shopping cart.', + ); + } +} diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php new file mode 100644 index 00000000..e4f6c213 --- /dev/null +++ b/app/Livewire/Storefront/CartDrawer.php @@ -0,0 +1,44 @@ +initializeCartDiscount(); + } + + #[On('open-cart-drawer')] + public function openDrawer(): void + { + unset($this->cart, $this->cartSubtotal, $this->cartTotal, $this->cartItemCount); + $this->open = true; + } + + #[On('close-cart-drawer')] + public function closeDrawer(): void + { + $this->open = false; + } + + #[On('cart-updated')] + public function cartUpdated(): void + { + unset($this->cart, $this->cartSubtotal, $this->cartTotal, $this->cartItemCount); + $this->open = true; + } + + public function render(): View + { + return view('storefront.components.cart-drawer'); + } +} diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php new file mode 100644 index 00000000..4e0c4c54 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -0,0 +1,55 @@ +checkout = ($checkoutId instanceof Checkout + ? Checkout::query()->whereKey($checkoutId->getKey()) + : Checkout::query()->whereKey($checkoutId)) + ->where('store_id', $this->currentStore()->getKey()) + ->with('cart.lines.variant.product.media') + ->firstOrFail(); + + abort_unless($this->enumValue($this->checkout->status) === 'completed', 404); + $customerId = Auth::guard('customer')->id(); + abort_unless( + session('checkout_access.'.$this->checkout->getKey()) + || ($customerId && (int) $customerId === (int) $this->checkout->customer_id), + 403, + ); + + $this->order = Order::query() + ->where('store_id', $this->currentStore()->getKey()) + ->when(session('last_order_id'), fn ($query, $id) => $query->whereKey($id)) + ->when(! session('last_order_id'), fn ($query) => $query->where('email', $this->checkout->email)->latest('placed_at')) + ->with(['lines.product.media', 'payments', 'fulfillments']) + ->firstOrFail(); + } + + public function render(): View + { + return $this->storefront( + view('storefront.checkout.confirmation'), + 'Order '.$this->order->order_number.' confirmed - '.$this->currentStore()->name, + 'Thank you for your order.', + ); + } + + private function enumValue(mixed $value): mixed + { + return $value instanceof \BackedEnum ? $value->value : $value; + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..f119c98d --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,430 @@ + */ + public array $shipping = [ + 'first_name' => '', 'last_name' => '', 'company' => '', 'address1' => '', 'address2' => '', + 'city' => '', 'province' => '', 'province_code' => '', 'postal_code' => '', 'country' => 'DE', 'phone' => '', + ]; + + /** @var array */ + public array $billing = [ + 'first_name' => '', 'last_name' => '', 'company' => '', 'address1' => '', 'address2' => '', + 'city' => '', 'province' => '', 'province_code' => '', 'postal_code' => '', 'country' => 'DE', 'phone' => '', + ]; + + public bool $billingSameAsShipping = true; + + /** @var array> */ + public array $shippingRates = []; + + public ?int $shippingRateId = null; + + public string $paymentMethod = 'credit_card'; + + public string $cardNumber = ''; + + public string $cardholderName = ''; + + public string $cardExpiry = ''; + + public string $cardCvc = ''; + + public string $discountCode = ''; + + public ?string $discountError = null; + + public ?string $paymentError = null; + + public bool $summaryOpen = false; + + public bool $requiresShipping = true; + + public function mount(int|string|Checkout $checkoutId): void + { + $this->checkout = ($checkoutId instanceof Checkout + ? Checkout::query()->whereKey($checkoutId->getKey()) + : Checkout::query()->whereKey($checkoutId)) + ->where('store_id', $this->currentStore()->getKey()) + ->with(['cart.lines.variant.product.media', 'cart.lines.variant.optionValues.option']) + ->firstOrFail(); + + $this->authorizeCheckoutAccess(); + abort_if($this->status() === 'completed', 409, 'This checkout is already complete.'); + abort_if($this->status() === 'expired', 410, 'This checkout has expired.'); + abort_if($this->checkout->cart->lines->isEmpty(), 422, 'Your cart is empty.'); + + $this->email = (string) ($this->checkout->email ?: Auth::guard('customer')->user()?->email); + $this->discountCode = (string) ($this->checkout->discount_code ?: session('cart_discount_code', '')); + $this->paymentMethod = (string) ($this->enumValue($this->checkout->payment_method) ?: 'credit_card'); + $this->shippingRateId = $this->checkout->shipping_method_id ? (int) $this->checkout->shipping_method_id : null; + + $this->fillAddress('shipping', $this->checkout->shipping_address_json); + $this->fillAddress('billing', $this->checkout->billing_address_json); + + $this->step = match ($this->status()) { + 'addressed' => 3, + 'shipping_selected', 'payment_selected' => 4, + default => 1, + }; + + if ($this->step >= 3) { + $this->loadShippingRates(); + } + } + + public function saveContact(): void + { + $this->validate(['email' => ['required', 'email:rfc', 'max:255']]); + $this->email = mb_strtolower($this->email); + $this->checkout->forceFill(['email' => $this->email])->save(); + $this->step = 2; + $this->dispatch('checkout-step-changed', step: 2); + } + + public function useSavedAddress(int $addressId): void + { + $customer = Auth::guard('customer')->user(); + abort_unless($customer, 403); + + $address = CustomerAddress::query()->where('customer_id', $customer->getAuthIdentifier())->findOrFail($addressId); + $data = $this->decodeAddress($address->address_json); + $this->shipping = array_replace($this->shipping, $this->normalizeAddress($data)); + } + + public function saveAddress(): void + { + $rules = $this->addressRules('shipping'); + if (! $this->billingSameAsShipping) { + $rules = [...$rules, ...$this->addressRules('billing')]; + } + + $this->validate($rules, [], $this->addressAttributes()); + + $payload = [ + 'email' => $this->email, + 'shipping_address' => $this->serviceAddress($this->shipping), + 'billing_address' => $this->serviceAddress($this->billingSameAsShipping ? $this->shipping : $this->billing), + ]; + + try { + app(CheckoutService::class)->setAddress($this->checkout, $payload); + $this->checkout->refresh(); + $this->loadShippingRates(); + $this->step = 3; + $this->dispatch('checkout-step-changed', step: 3); + } catch (\Throwable $exception) { + report($exception); + $this->addError('shipping.address1', 'We could not verify this address. Please review it and try again.'); + } + } + + public function chooseShipping(): void + { + if (! $this->requiresShipping) { + app(CheckoutService::class)->setShippingMethod($this->checkout, null); + $this->checkout->refresh(); + $this->step = 4; + $this->dispatch('checkout-step-changed', step: 4); + + return; + } + + if ($this->shippingRates === []) { + $this->addError('shippingRateId', 'No shipping methods are available for this address.'); + + return; + } + + $this->validate(['shippingRateId' => ['required', 'integer', Rule::in(array_column($this->shippingRates, 'id'))]]); + + try { + app(CheckoutService::class)->setShippingMethod($this->checkout, (int) $this->shippingRateId); + $this->checkout->refresh(); + $this->step = 4; + $this->dispatch('checkout-step-changed', step: 4); + } catch (\Throwable $exception) { + report($exception); + $this->addError('shippingRateId', 'That shipping method is no longer available. Please choose another.'); + $this->loadShippingRates(); + } + } + + public function applyDiscount(): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:100']]); + + try { + $discount = app(DiscountService::class)->validate($this->discountCode, $this->currentStore(), $this->checkout->cart); + $this->checkout->forceFill(['discount_code' => strtoupper((string) $discount->code)])->save(); + $this->reprice(); + $this->discountCode = strtoupper((string) $discount->code); + $this->discountError = null; + $this->dispatch('toast', type: 'success', message: 'Discount applied'); + } catch (\Throwable $exception) { + $this->discountError = str_contains(strtolower($exception->getMessage()), 'expired') ? 'This code has expired.' : 'Invalid discount code'; + } + } + + public function removeDiscount(): void + { + $this->checkout->forceFill(['discount_code' => null])->save(); + $this->discountCode = ''; + $this->discountError = null; + $this->reprice(); + } + + public function pay(): mixed + { + $rules = ['paymentMethod' => ['required', Rule::in(['credit_card', 'paypal', 'bank_transfer'])]]; + if ($this->paymentMethod === 'credit_card') { + $rules += [ + 'cardNumber' => ['required', 'string', function ($attribute, $value, $fail): void { + if (strlen(preg_replace('/\D/', '', $value)) !== 16) { + $fail('Enter a valid 16-digit card number.'); + } + }], + 'cardholderName' => ['required', 'string', 'max:255'], + 'cardExpiry' => ['required', 'regex:/^(0[1-9]|1[0-2])\/\d{2}$/', function ($attribute, $value, $fail): void { + [$month, $year] = array_map('intval', explode('/', $value)); + $expiresAt = now()->setYear(2000 + $year)->setMonth($month)->endOfMonth(); + if ($expiresAt->isPast()) { + $fail('Enter a future expiry date.'); + } + }], + 'cardCvc' => ['required', 'regex:/^\d{3,4}$/'], + ]; + } + + $this->validate($rules); + $key = 'checkout-pay:'.$this->checkout->getKey().':'.request()->ip(); + if (RateLimiter::tooManyAttempts($key, 10)) { + $this->paymentError = 'Too many attempts. Please wait a moment and try again.'; + + return null; + } + + RateLimiter::hit($key, 60); + $this->paymentError = null; + + try { + $service = app(CheckoutService::class); + if ($this->status() !== 'payment_selected' || $this->enumValue($this->checkout->payment_method) !== $this->paymentMethod) { + $service->selectPaymentMethod($this->checkout, $this->paymentMethod); + $this->checkout->refresh(); + } + + $order = $service->completeCheckout($this->checkout, [ + 'payment_method' => $this->paymentMethod, + 'card_number' => preg_replace('/\D/', '', $this->cardNumber), + 'cardholder_name' => $this->cardholderName, + 'expiry' => $this->cardExpiry, + 'cvc' => $this->cardCvc, + ]); + + session()->put('last_order_id', $order->getKey()); + session()->put('checkout_access.'.$this->checkout->getKey(), true); + + return $this->redirect(url('/checkout/'.$this->checkout->getRouteKey().'/confirmation'), navigate: true); + } catch (\Throwable $exception) { + report($exception); + $this->checkout->refresh(); + $message = strtolower($exception->getMessage()); + $this->paymentError = str_contains($message, 'insufficient') + ? 'Payment declined: insufficient funds.' + : (str_contains($message, 'declin') ? 'Payment declined: please use another payment method.' : 'Payment could not be processed. Please review your details and try again.'); + } + + return null; + } + + public function editStep(int $step): void + { + if ($step >= 1 && $step < $this->step) { + $this->step = $step; + $this->dispatch('checkout-step-changed', step: $step); + } + } + + public function render(): View + { + return $this->storefront( + view('storefront.checkout.show'), + 'Checkout - '.$this->currentStore()->name, + 'Complete your secure checkout.', + ); + } + + #[Computed] + public function totals(): array + { + $totals = $this->checkout->totals_json; + if (is_string($totals)) { + $totals = json_decode($totals, true); + } + + $subtotal = (int) $this->checkout->cart->lines->sum('line_subtotal_amount'); + + return array_replace([ + 'subtotal' => $subtotal, + 'discount' => 0, + 'shipping' => 0, + 'tax' => 0, + 'total' => $subtotal, + ], is_array($totals) ? $totals : []); + } + + #[Computed] + public function savedAddresses(): mixed + { + $customer = Auth::guard('customer')->user(); + + return $customer + ? CustomerAddress::query()->where('customer_id', $customer->getAuthIdentifier())->orderByDesc('is_default')->get() + : collect(); + } + + private function loadShippingRates(): void + { + try { + $calculator = app(ShippingCalculator::class); + $this->requiresShipping = $calculator->requiresShipping($this->checkout->cart); + if (! $this->requiresShipping) { + $this->shippingRates = []; + + return; + } + + $rates = $calculator->getAvailableRates($this->currentStore(), $this->serviceAddress($this->shipping), $this->checkout->cart); + $this->shippingRates = collect($rates)->map(function (ShippingRate $rate) use ($calculator): array { + $config = is_string($rate->config_json) ? json_decode($rate->config_json, true) : $rate->config_json; + try { + $amount = $calculator->calculate($rate, $this->checkout->cart); + } catch (\Throwable) { + $amount = (int) data_get($config, 'amount', 0); + } + + return ['id' => $rate->getKey(), 'name' => $rate->name, 'amount' => (int) $amount, 'description' => data_get($config, 'description', 'Delivery time shown at dispatch')]; + })->values()->all(); + } catch (\Throwable $exception) { + report($exception); + $this->shippingRates = []; + } + } + + private function reprice(): void + { + try { + $result = app(PricingEngine::class)->calculate($this->checkout); + $totals = method_exists($result, 'toArray') ? $result->toArray() : (array) $result; + $this->checkout->forceFill(['totals_json' => $totals])->save(); + unset($this->totals); + } catch (\Throwable $exception) { + report($exception); + $this->checkout->refresh(); + } + } + + /** @return array> */ + private function addressRules(string $prefix): array + { + return [ + $prefix.'.first_name' => ['required', 'string', 'max:100'], + $prefix.'.last_name' => ['required', 'string', 'max:100'], + $prefix.'.company' => ['nullable', 'string', 'max:150'], + $prefix.'.address1' => ['required', 'string', 'max:255'], + $prefix.'.address2' => ['nullable', 'string', 'max:255'], + $prefix.'.city' => ['required', 'string', 'max:120'], + $prefix.'.province' => ['nullable', 'string', 'max:120'], + $prefix.'.postal_code' => ['required', 'string', 'max:20', 'regex:/^[A-Za-z0-9][A-Za-z0-9 -]{1,18}$/'], + $prefix.'.country' => ['required', 'string', 'size:2'], + $prefix.'.phone' => ['nullable', 'string', 'max:40'], + ]; + } + + /** @return array */ + private function addressAttributes(): array + { + return [ + 'shipping.first_name' => 'first name', 'shipping.last_name' => 'last name', 'shipping.address1' => 'address', + 'shipping.postal_code' => 'postal code', 'billing.first_name' => 'billing first name', + 'billing.last_name' => 'billing last name', 'billing.address1' => 'billing address', 'billing.postal_code' => 'billing postal code', + ]; + } + + /** @return array */ + private function serviceAddress(array $address): array + { + return [...$address, 'zip' => $address['postal_code'] ?? '', 'country_code' => $address['country'] ?? '']; + } + + private function fillAddress(string $property, mixed $raw): void + { + $data = $this->decodeAddress($raw); + if ($data !== []) { + $this->{$property} = array_replace($this->{$property}, $this->normalizeAddress($data)); + } + } + + /** @return array */ + private function decodeAddress(mixed $raw): array + { + if (is_string($raw)) { + return json_decode($raw, true) ?: []; + } + + return is_array($raw) ? $raw : []; + } + + /** @return array */ + private function normalizeAddress(array $data): array + { + $data['postal_code'] = $data['postal_code'] ?? $data['zip'] ?? ''; + $data['country'] = $data['country_code'] ?? $data['country'] ?? 'DE'; + + return $data; + } + + private function authorizeCheckoutAccess(): void + { + $customerId = Auth::guard('customer')->id(); + $allowed = session('checkout_access.'.$this->checkout->getKey()) + || ((int) session('cart_id') === (int) $this->checkout->cart_id) + || ($customerId && (int) $customerId === (int) $this->checkout->customer_id); + + abort_unless($allowed, 403); + } + + private function status(): string + { + return (string) $this->enumValue($this->checkout->status); + } + + private function enumValue(mixed $value): mixed + { + return $value instanceof \BackedEnum ? $value->value : $value; + } +} diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php new file mode 100644 index 00000000..3bf398dc --- /dev/null +++ b/app/Livewire/Storefront/Collections/Index.php @@ -0,0 +1,27 @@ +where('store_id', $this->currentStore()->getKey()) + ->where('status', 'active') + ->withCount(['products' => fn ($query) => $query->where('status', 'active')]) + ->with(['products' => fn ($query) => $query->where('status', 'active')->with('media')->limit(1)]) + ->orderBy('title') + ->get(); + + return $this->storefront( + view('storefront.collections.index', compact('collections')), + 'Collections - '.$this->currentStore()->name, + 'Browse all collections from '.$this->currentStore()->name.'.', + ); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..cbdacb66 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,140 @@ + */ + #[Url(as: 'type', except: [])] + public array $types = []; + + /** @var array */ + #[Url(as: 'vendor', except: [])] + public array $vendors = []; + + public bool $filtersOpen = false; + + public function mount(string|Collection $handle): void + { + $this->collection = $handle instanceof Collection + ? $handle + : Collection::query() + ->where('store_id', $this->currentStore()->getKey()) + ->where('handle', $handle) + ->where('status', 'active') + ->firstOrFail(); + + abort_unless((int) $this->collection->store_id === (int) $this->currentStore()->getKey(), 404); + } + + public function updated(string $property): void + { + if (in_array($property, ['sort', 'inStock', 'minPrice', 'maxPrice', 'types', 'vendors'], true)) { + $this->resetPage(); + } + } + + public function clearFilters(): void + { + $this->reset('inStock', 'minPrice', 'maxPrice', 'types', 'vendors'); + $this->resetPage(); + } + + #[Computed] + public function productTypes(): array + { + return $this->baseProducts()->whereNotNull('product_type')->distinct()->orderBy('product_type')->pluck('product_type')->all(); + } + + #[Computed] + public function availableVendors(): array + { + return $this->baseProducts()->whereNotNull('vendor')->distinct()->orderBy('vendor')->pluck('vendor')->all(); + } + + #[Computed] + public function hasActiveFilters(): bool + { + return $this->inStock || $this->minPrice !== '' || $this->maxPrice !== '' || $this->types !== [] || $this->vendors !== []; + } + + public function render(): View + { + $products = $this->filteredProducts() + ->with(['variants.inventoryItem', 'media']) + ->paginate(12); + + $description = trim(strip_tags((string) $this->collection->description_html)); + + return $this->storefront( + view('storefront.collections.show', compact('products')), + $this->collection->title.' - '.$this->currentStore()->name, + str($description)->limit(160)->toString(), + ); + } + + private function baseProducts(): Builder + { + return Product::query() + ->where('products.store_id', $this->currentStore()->getKey()) + ->where('products.status', 'active') + ->whereNotNull('products.published_at') + ->whereHas('collections', fn (Builder $query) => $query->whereKey($this->collection->getKey())); + } + + private function filteredProducts(): Builder + { + $query = $this->baseProducts(); + + $query->when($this->types !== [], fn (Builder $builder) => $builder->whereIn('product_type', $this->types)); + $query->when($this->vendors !== [], fn (Builder $builder) => $builder->whereIn('vendor', $this->vendors)); + + if ($this->inStock) { + $query->whereHas('variants.inventoryItem', fn (Builder $builder) => $builder + ->where('policy', 'continue') + ->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved')); + } + + if ($this->minPrice !== '') { + $query->whereHas('variants', fn (Builder $builder) => $builder->where('price_amount', '>=', (int) round((float) $this->minPrice * 100))); + } + + if ($this->maxPrice !== '') { + $query->whereHas('variants', fn (Builder $builder) => $builder->where('price_amount', '<=', (int) round((float) $this->maxPrice * 100))); + } + + return match ($this->sort) { + 'price_asc' => $query->orderByRaw('(select min(price_amount) from product_variants where product_variants.product_id = products.id) asc'), + 'price_desc' => $query->orderByRaw('(select max(price_amount) from product_variants where product_variants.product_id = products.id) desc'), + 'newest' => $query->latest('published_at'), + 'best_selling' => $query->withCount('orderLines')->orderByDesc('order_lines_count'), + default => $query->orderBy('products.title'), + }; + } +} diff --git a/app/Livewire/Storefront/Concerns/ManagesCart.php b/app/Livewire/Storefront/Concerns/ManagesCart.php new file mode 100644 index 00000000..ea70ffb7 --- /dev/null +++ b/app/Livewire/Storefront/Concerns/ManagesCart.php @@ -0,0 +1,236 @@ +currentStore(); + $cartId = session('cart_id'); + + $query = Cart::query() + ->where('store_id', $store->getKey()) + ->where('status', 'active') + ->with([ + 'lines.variant.product.media', + 'lines.variant.optionValues.option', + 'lines.variant.inventoryItem', + ]); + + if ($cartId) { + $query->whereKey($cartId); + } elseif ($customer = Auth::guard('customer')->user()) { + $query->where('customer_id', $customer->getAuthIdentifier())->latest('updated_at'); + } else { + return null; + } + + return $query->first(); + } + + #[Computed] + public function cartSubtotal(): int + { + return (int) ($this->cart?->lines->sum('line_subtotal_amount') ?? 0); + } + + #[Computed] + public function cartTotal(): int + { + return max(0, $this->cartSubtotal - $this->discountAmount); + } + + #[Computed] + public function cartItemCount(): int + { + return (int) ($this->cart?->lines->sum('quantity') ?? 0); + } + + public function incrementLine(int $lineId): void + { + $line = $this->cartLine($lineId); + $this->setLineQuantity($lineId, (int) $line->quantity + 1); + } + + public function decrementLine(int $lineId): void + { + $line = $this->cartLine($lineId); + $quantity = (int) $line->quantity - 1; + + if ($quantity < 1) { + $this->removeLine($lineId); + + return; + } + + $this->setLineQuantity($lineId, $quantity); + } + + public function setLineQuantity(int $lineId, int|string $quantity): void + { + $line = $this->cartLine($lineId); + $quantity = (int) $quantity; + + if ($quantity < 1) { + $this->removeLine($lineId); + + return; + } + + try { + app(CartService::class)->updateLineQuantity($this->cart, $line->getKey(), $quantity); + $this->cartChanged('Cart updated'); + } catch (\Throwable $exception) { + report($exception); + $this->dispatch('toast', type: 'error', message: str_contains(strtolower($exception->getMessage()), 'stock') + ? 'Only the available quantity can be added.' + : 'The cart could not be updated.'); + } + } + + public function removeLine(int $lineId): void + { + $line = $this->cartLine($lineId); + app(CartService::class)->removeLine($this->cart, $line->getKey()); + $this->cartChanged('Item removed'); + } + + public function applyDiscount(): void + { + $this->validate(['discountCode' => ['required', 'string', 'max:100']]); + abort_unless($this->cart !== null, 404); + + try { + $discountService = app(DiscountService::class); + $discount = $discountService->validate($this->discountCode, $this->currentStore(), $this->cart); + $result = $discountService->calculate($discount, $this->cartSubtotal, $this->cart->lines->all()); + + $this->discountCode = strtoupper((string) $discount->code); + $this->discountAmount = $this->discountAmountFrom($result, $discount); + $this->freeShippingDiscount = (bool) data_get($result, 'freeShipping', data_get($result, 'free_shipping', $this->enumValue($discount->value_type) === 'free_shipping')); + $this->discountError = null; + session([ + 'cart_discount_code' => $this->discountCode, + 'cart_discount_amount' => $this->discountAmount, + 'cart_free_shipping' => $this->freeShippingDiscount, + ]); + $this->dispatch('toast', type: 'success', message: 'Discount applied'); + } catch (\Throwable $exception) { + $message = strtolower($exception->getMessage()); + $this->discountError = str_contains($message, 'expired') ? 'This code has expired.' + : (str_contains($message, 'limit') || str_contains($message, 'usage') ? 'This code has reached its usage limit.' : 'Invalid discount code'); + $this->discountAmount = 0; + $this->freeShippingDiscount = false; + } + } + + public function removeDiscount(): void + { + $this->reset('discountCode', 'discountError', 'discountAmount', 'freeShippingDiscount'); + session()->forget(['cart_discount_code', 'cart_discount_amount', 'cart_free_shipping']); + $this->dispatch('toast', type: 'info', message: 'Discount removed'); + } + + public function startCheckout(): mixed + { + $cart = $this->cart; + if (! $cart || $cart->lines->isEmpty()) { + $this->dispatch('toast', type: 'info', message: 'Your cart is empty'); + + return null; + } + + $service = app(CheckoutService::class); + if (method_exists($service, 'create')) { + $checkout = $service->create($cart); + } elseif (method_exists($service, 'createFromCart')) { + $checkout = $service->createFromCart($cart, Auth::guard('customer')->user()); + } else { + $checkout = Checkout::query()->firstOrCreate( + ['cart_id' => $cart->getKey(), 'status' => 'started'], + [ + 'store_id' => $this->currentStore()->getKey(), + 'customer_id' => Auth::guard('customer')->id(), + 'email' => Auth::guard('customer')->user()?->email, + 'discount_code' => session('cart_discount_code'), + 'expires_at' => now()->addDay(), + ], + ); + } + + if (session('cart_discount_code') && ! $checkout->discount_code) { + $checkout->forceFill(['discount_code' => session('cart_discount_code')])->save(); + } + + session()->put('checkout_access.'.$checkout->getKey(), true); + + $this->dispatch('close-cart-drawer'); + + return $this->redirect(url('/checkout/'.$checkout->getRouteKey()), navigate: true); + } + + protected function initializeCartDiscount(): void + { + $this->discountCode = (string) session('cart_discount_code', ''); + $this->discountAmount = (int) session('cart_discount_amount', 0); + $this->freeShippingDiscount = (bool) session('cart_free_shipping', false); + } + + private function cartLine(int $lineId): mixed + { + abort_unless($this->cart, 404); + $line = $this->cart->lines->firstWhere('id', $lineId); + abort_unless($line, 404); + + return $line; + } + + private function cartChanged(string $message): void + { + unset($this->cart, $this->cartSubtotal, $this->cartTotal, $this->cartItemCount); + $this->discountAmount = 0; + session()->forget('cart_discount_amount'); + + $cart = $this->cart; + $this->dispatch('cart-updated', cartId: $cart?->getKey(), itemCount: $this->cartItemCount); + $this->dispatch('toast', type: 'success', message: $message); + } + + private function discountAmountFrom(mixed $result, Discount $discount): int + { + $amount = data_get($result, 'amount', data_get($result, 'discountAmount', data_get($result, 'discount_amount'))); + if ($amount !== null) { + return min($this->cartSubtotal, max(0, (int) $amount)); + } + + return match ($this->enumValue($discount->value_type)) { + 'percent' => (int) floor($this->cartSubtotal * ((int) $discount->value_amount / 100)), + 'fixed' => min($this->cartSubtotal, (int) $discount->value_amount), + default => 0, + }; + } + + private function enumValue(mixed $value): mixed + { + return $value instanceof \BackedEnum ? $value->value : $value; + } +} diff --git a/app/Livewire/Storefront/Concerns/QuickAddsProducts.php b/app/Livewire/Storefront/Concerns/QuickAddsProducts.php new file mode 100644 index 00000000..de9f55db --- /dev/null +++ b/app/Livewire/Storefront/Concerns/QuickAddsProducts.php @@ -0,0 +1,36 @@ +whereKey($variantId) + ->where('status', 'active') + ->whereHas('product', fn ($query) => $query + ->where('store_id', $this->currentStore()->getKey()) + ->where('status', 'active') + ->whereNotNull('published_at')) + ->firstOrFail(); + + try { + $service = app(CartService::class); + $cart = $service->getOrCreateForSession($this->currentStore(), Auth::guard('customer')->user()); + $service->addLine($cart, $variant->getKey(), 1); + $cart->load('lines'); + $this->dispatch('cart-updated', cartId: $cart->getKey(), itemCount: (int) $cart->lines->sum('quantity')); + $this->dispatch('toast', type: 'success', message: 'Added to cart'); + } catch (\Throwable $exception) { + report($exception); + $this->dispatch('toast', type: 'error', message: str_contains(strtolower($exception->getMessage()), 'stock') + ? 'This product is currently out of stock' + : 'The item could not be added.'); + } + } +} diff --git a/app/Livewire/Storefront/FeaturedProducts.php b/app/Livewire/Storefront/FeaturedProducts.php new file mode 100644 index 00000000..fec5de90 --- /dev/null +++ b/app/Livewire/Storefront/FeaturedProducts.php @@ -0,0 +1,35 @@ + $this->limit]); + } + + public function render(): View + { + $products = Product::query() + ->where('store_id', $this->currentStore()->getKey()) + ->where('status', 'active') + ->whereNotNull('published_at') + ->with(['variants.inventoryItem', 'media']) + ->latest('published_at') + ->limit($this->limit) + ->get(); + + return view('storefront.components.featured-products', compact('products')); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..5f7a12e4 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,31 @@ +currentStore(); + $settings = $this->themeSettings(); + + $collections = Collection::query() + ->where('store_id', $store->getKey()) + ->where('status', 'active') + ->with(['products' => fn ($query) => $query->where('status', 'active')->with('media')]) + ->limit(4) + ->get(); + + return $this->storefront( + view('storefront.home', compact('settings', 'collections')), + $store->name, + (string) data_get($settings, 'meta_description', 'Shop the latest products from '.$store->name.'.'), + ); + } +} diff --git a/app/Livewire/Storefront/NewsletterSignup.php b/app/Livewire/Storefront/NewsletterSignup.php new file mode 100644 index 00000000..7f31d175 --- /dev/null +++ b/app/Livewire/Storefront/NewsletterSignup.php @@ -0,0 +1,34 @@ +validate(['email' => ['required', 'email:rfc', 'max:255']]); + + $key = 'newsletter:'.request()->ip(); + if (RateLimiter::tooManyAttempts($key, 5)) { + $this->addError('email', 'Please wait before trying again.'); + + return; + } + + RateLimiter::hit($key, 60); + $this->subscribed = true; + $this->reset('email'); + } + + public function render(): View + { + return view('storefront.components.newsletter-signup'); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..fe2d8903 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,32 @@ +page = ($handle instanceof Page + ? Page::query()->whereKey($handle->getKey()) + : Page::query()->where('handle', $handle)) + ->where('store_id', $this->currentStore()->getKey()) + ->where('status', 'published') + ->whereNotNull('published_at') + ->firstOrFail(); + } + + public function render(): View + { + return $this->storefront( + view('storefront.pages.show'), + $this->page->title.' - '.$this->currentStore()->name, + str(strip_tags((string) $this->page->body_html))->squish()->limit(160)->toString(), + ); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..31f44433 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,143 @@ + */ + public array $selectedOptions = []; + + public int $quantity = 1; + + public int $activeMedia = 0; + + public function mount(string|Product $handle): void + { + $this->product = ($handle instanceof Product + ? $handle->newQuery()->whereKey($handle->getKey()) + : Product::query()->where('handle', $handle)) + ->where('store_id', $this->currentStore()->getKey()) + ->where('status', 'active') + ->whereNotNull('published_at') + ->with([ + 'collections', + 'media' => fn ($query) => $query->where('status', 'ready')->orderBy('position'), + 'options' => fn ($query) => $query->with(['values' => fn ($valueQuery) => $valueQuery->orderBy('position')])->orderBy('position'), + 'variants' => fn ($query) => $query->where('status', 'active')->with(['inventoryItem', 'optionValues.option'])->orderBy('position'), + ]) + ->firstOrFail(); + + foreach ($this->product->options as $option) { + $defaultVariant = $this->product->variants->firstWhere('is_default', true) ?: $this->product->variants->first(); + $value = $defaultVariant?->optionValues->first(fn ($value) => (int) $value->product_option_id === (int) $option->id); + $this->selectedOptions[$option->name] = $value?->value ?? (string) optional($option->values->first())->value; + } + } + + public function updatedSelectedOptions(): void + { + $this->quantity = 1; + unset($this->selectedVariant); + $this->dispatch('variant-changed', + variantId: $this->selectedVariant?->getKey(), + price: $this->selectedVariant?->price_amount, + stock: $this->availableQuantity, + ); + } + + #[Computed] + public function selectedVariant(): ?ProductVariant + { + if ($this->product->options->isEmpty()) { + return $this->product->variants->firstWhere('is_default', true) ?: $this->product->variants->first(); + } + + return $this->product->variants->first(function (ProductVariant $variant): bool { + $values = $variant->optionValues->mapWithKeys(function ($value): array { + $optionName = $value->option?->name; + + return $optionName ? [$optionName => $value->value] : []; + })->all(); + + foreach ($this->selectedOptions as $name => $selected) { + if (($values[$name] ?? null) !== $selected) { + return false; + } + } + + return count($values) === count($this->selectedOptions); + }); + } + + #[Computed] + public function availableQuantity(): ?int + { + $inventory = $this->selectedVariant?->inventoryItem; + + if (! $inventory || $this->enumValue($inventory->policy) === 'continue') { + return null; + } + + return max(0, (int) $inventory->quantity_on_hand - (int) $inventory->quantity_reserved); + } + + #[Computed] + public function canAddToCart(): bool + { + return $this->selectedVariant !== null && ($this->availableQuantity === null || $this->availableQuantity > 0); + } + + public function addToCart(): void + { + $variant = $this->selectedVariant; + if (! $variant) { + $this->addError('variant', 'Please choose all options.'); + + return; + } + + $max = $this->availableQuantity; + $this->validate(['quantity' => ['required', 'integer', 'min:1', $max === null ? 'max:999' : 'max:'.$max]]); + + try { + $cartService = app(CartService::class); + $cart = $cartService->getOrCreateForSession($this->currentStore(), Auth::guard('customer')->user()); + $cartService->addLine($cart, $variant->getKey(), $this->quantity); + $cart->load('lines'); + + $this->dispatch('cart-updated', cartId: $cart->getKey(), itemCount: (int) $cart->lines->sum('quantity')); + $this->dispatch('toast', type: 'success', message: 'Added to cart'); + } catch (\Throwable $exception) { + report($exception); + $this->addError('variant', str_contains(strtolower($exception->getMessage()), 'stock') + ? 'This product is currently out of stock.' + : 'We could not add this item. Please try again.'); + } + } + + public function render(): View + { + $description = str(strip_tags((string) $this->product->description_html))->squish()->limit(160)->toString(); + + return $this->storefront( + view('storefront.products.show'), + $this->product->title.' - '.$this->currentStore()->name, + $description, + ); + } + + private function enumValue(mixed $value): mixed + { + return $value instanceof \BackedEnum ? $value->value : $value; + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..a2e264f3 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,148 @@ + */ + #[Url(as: 'type', except: [])] + public array $types = []; + + /** @var array */ + #[Url(as: 'vendor', except: [])] + public array $vendors = []; + + public bool $filtersOpen = false; + + public function updated(string $property): void + { + if (in_array($property, ['query', 'sort', 'inStock', 'minPrice', 'maxPrice', 'types', 'vendors'], true)) { + $this->resetPage(); + } + } + + public function searchNow(): void + { + $this->query = trim($this->query); + $this->validate(['query' => ['required', 'string', 'max:200']]); + $this->resetPage(); + } + + public function clearFilters(): void + { + $this->reset('sort', 'inStock', 'minPrice', 'maxPrice', 'types', 'vendors'); + $this->resetPage(); + } + + #[Computed] + public function productTypes(): array + { + return Product::query()->where('store_id', $this->currentStore()->getKey())->where('status', 'active')->whereNotNull('product_type')->distinct()->orderBy('product_type')->pluck('product_type')->all(); + } + + #[Computed] + public function availableVendors(): array + { + return Product::query()->where('store_id', $this->currentStore()->getKey())->where('status', 'active')->whereNotNull('vendor')->distinct()->orderBy('vendor')->pluck('vendor')->all(); + } + + #[Computed] + public function hasActiveFilters(): bool + { + return $this->sort !== 'relevance' || $this->inStock || $this->minPrice !== '' || $this->maxPrice !== '' || $this->types !== [] || $this->vendors !== []; + } + + public function render(): View + { + $results = $this->results(); + + return $this->storefront( + view('storefront.search.index', compact('results')), + 'Search results - '.$this->currentStore()->name, + 'Search products from '.$this->currentStore()->name.'.', + ); + } + + private function results(): LengthAwarePaginator + { + if (trim($this->query) === '') { + return Product::query()->whereRaw('1 = 0')->paginate(12); + } + + $search = app(SearchService::class)->search($this->currentStore(), trim($this->query), [], 500); + $products = new EloquentCollection($search->items()); + $products->loadMissing(['variants.inventoryItem', 'media']); + + $products = $products + ->when($this->types !== [], fn ($items) => $items->whereIn('product_type', $this->types)) + ->when($this->vendors !== [], fn ($items) => $items->whereIn('vendor', $this->vendors)) + ->filter(function (Product $product): bool { + $variants = $product->variants; + if ($this->inStock && ! $variants->contains(function ($variant): bool { + $inventory = $variant->inventoryItem; + + return ! $inventory + || ($inventory->policy instanceof \BackedEnum ? $inventory->policy->value : $inventory->policy) === 'continue' + || ((int) $inventory->quantity_on_hand - (int) $inventory->quantity_reserved) > 0; + })) { + return false; + } + + $minimum = $this->minPrice === '' ? null : (int) round((float) $this->minPrice * 100); + $maximum = $this->maxPrice === '' ? null : (int) round((float) $this->maxPrice * 100); + + return $variants->contains(fn ($variant): bool => ($minimum === null || (int) $variant->price_amount >= $minimum) + && ($maximum === null || (int) $variant->price_amount <= $maximum)); + }) + ->values(); + + if ($this->sort === 'price_asc') { + $products = $products->sortBy(fn (Product $product): int => (int) $product->variants->min('price_amount'))->values(); + } elseif ($this->sort === 'price_desc') { + $products = $products->sortByDesc(fn (Product $product): int => (int) $product->variants->max('price_amount'))->values(); + } elseif ($this->sort === 'newest') { + $products = $products->sortByDesc('published_at')->values(); + } elseif ($this->sort === 'best_selling') { + $products->loadCount('orderLines'); + $products = $products->sortByDesc('order_lines_count')->values(); + } + + $page = max(1, LengthAwarePaginator::resolveCurrentPage()); + + return new LengthAwarePaginator( + $products->forPage($page, 12)->values(), + $products->count(), + 12, + $page, + ['path' => request()->url(), 'query' => request()->query()], + ); + } +} diff --git a/app/Livewire/Storefront/Search/Modal.php b/app/Livewire/Storefront/Search/Modal.php new file mode 100644 index 00000000..c4365a74 --- /dev/null +++ b/app/Livewire/Storefront/Search/Modal.php @@ -0,0 +1,87 @@ +> */ + public array $products = []; + + /** @var array> */ + public array $collections = []; + + #[On('open-search-modal')] + public function openModal(): void + { + $this->open = true; + } + + #[On('close-search-modal')] + public function closeModal(): void + { + $this->open = false; + } + + public function updatedQuery(): void + { + $query = trim($this->query); + if (mb_strlen($query) < 2) { + $this->reset('products', 'collections'); + + return; + } + + $products = app(SearchService::class)->autocomplete($this->currentStore(), $query, 5); + $this->products = collect($products)->take(5)->map(function ($product): array { + $product->loadMissing(['variants', 'media']); + $variant = $product->variants->firstWhere('is_default', true) ?: $product->variants->first(); + $media = $product->media->first(); + + return [ + 'title' => $product->title, + 'handle' => $product->handle, + 'price' => (int) ($variant?->price_amount ?? 0), + 'currency' => (string) ($variant?->currency ?? $this->currentStore()->default_currency), + 'image' => $media?->storage_key, + ]; + })->values()->all(); + + $this->collections = Collection::query() + ->where('store_id', $this->currentStore()->getKey()) + ->where('status', 'active') + ->where('title', 'like', '%'.$query.'%') + ->limit(5) + ->get(['title', 'handle']) + ->map(fn ($collection) => ['title' => $collection->title, 'handle' => $collection->handle]) + ->all(); + } + + public function goToResults(): mixed + { + $query = trim($this->query); + if ($query === '') { + $this->addError('query', 'Enter a search term.'); + + return null; + } + + $this->open = false; + + return $this->redirect(url('/search').'?q='.rawurlencode($query), navigate: true); + } + + public function render(): View + { + return view('storefront.components.search-modal'); + } +} diff --git a/app/Livewire/Storefront/StorefrontComponent.php b/app/Livewire/Storefront/StorefrontComponent.php new file mode 100644 index 00000000..94fa6d5e --- /dev/null +++ b/app/Livewire/Storefront/StorefrontComponent.php @@ -0,0 +1,132 @@ +bound('current_store'), 404, 'Store not found.'); + + /** @var Store $store */ + $store = app('current_store'); + + return $store; + } + + protected function customer(): mixed + { + return Auth::guard('customer')->user(); + } + + protected function storefront(View $view, string $title = '', string $metaDescription = ''): View + { + $layoutData = $this->layoutData(); + $view->with($layoutData); + + return $view->layout('storefront.layouts.app', [ + 'title' => $title === '' ? $this->currentStore()->name : $title, + 'metaDescription' => $metaDescription, + ...$layoutData, + ]); + } + + /** @return array */ + protected function layoutData(): array + { + $store = $this->currentStore(); + $settings = $this->themeSettings(); + + $menus = NavigationMenu::query() + ->where('store_id', $store->getKey()) + ->whereIn('handle', ['main-menu', 'footer-menu']) + ->with(['items' => fn ($query) => $query->orderBy('position')]) + ->get() + ->keyBy('handle'); + + return [ + 'currentStore' => $store, + 'themeSettings' => $settings, + 'mainNavigation' => $this->navigationItems($menus->get('main-menu')), + 'footerNavigation' => $this->navigationItems($menus->get('footer-menu')), + 'storefrontCustomer' => $this->customer(), + ]; + } + + /** @return array */ + protected function themeSettings(): array + { + $raw = app(ThemeSettingsService::class)->forStore($this->currentStore()); + if (isset($raw['primary_color'])) { + data_set($raw, 'colors.primary', $raw['primary_color']); + } + if (isset($raw['accent_color'])) { + data_set($raw, 'colors.accent', $raw['accent_color']); + } + if (isset($raw['announcement']) && is_string($raw['announcement'])) { + $raw['announcement'] = [ + 'enabled' => $raw['announcement'] !== '', + 'text' => $raw['announcement'], + 'url' => null, + 'background' => '#18181b', + ]; + } + + return array_replace_recursive([ + 'announcement' => ['enabled' => false, 'text' => '', 'url' => null, 'background' => '#18181b'], + 'header' => ['sticky' => true, 'logo_url' => null], + 'colors' => ['primary' => '#1d4ed8', 'secondary' => '#334155', 'accent' => '#f59e0b'], + 'dark_mode' => 'system', + 'home' => [ + 'sections' => ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text'], + 'hero' => [ + 'enabled' => true, + 'heading' => 'Everyday pieces, thoughtfully selected.', + 'subheading' => 'Discover quality essentials made for the way you live.', + 'cta_label' => 'Shop the collection', + 'cta_url' => '/collections', + 'image_url' => null, + ], + ], + 'footer' => ['description' => null, 'social' => []], + ], $raw); + } + + /** @return array> */ + private function navigationItems(mixed $menu): array + { + if (! $menu) { + return []; + } + + return $menu->items->map(function ($item): array { + $type = $item->type instanceof \BackedEnum ? $item->type->value : $item->type; + + return [ + 'label' => $item->label, + 'url' => match ($type) { + 'page' => $this->resourceUrl(\App\Models\Page::class, $item->resource_id, '/pages/'), + 'collection' => $this->resourceUrl(\App\Models\Collection::class, $item->resource_id, '/collections/'), + 'product' => $this->resourceUrl(\App\Models\Product::class, $item->resource_id, '/products/'), + default => $item->url ?: '#', + }, + 'children' => Arr::wrap(data_get($item, 'children', [])), + ]; + })->all(); + } + + private function resourceUrl(string $model, mixed $id, string $prefix): string + { + $handle = $model::query()->whereKey($id)->value('handle'); + + return $handle ? url($prefix.$handle) : '#'; + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 3d053671..27d17244 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -6,11 +6,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Auth\Passwords\CanResetPassword; +use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Notifications\Notifiable; -class Customer extends Authenticatable +class Customer extends Authenticatable implements CanResetPasswordContract { - use BelongsToStore, HasFactory, Notifiable; + use BelongsToStore, CanResetPassword, HasFactory, Notifiable; protected $fillable = ['store_id', 'email', 'password_hash', 'password', 'name', 'marketing_opt_in']; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dfb46b4c..e15491f9 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -38,6 +39,11 @@ public function boot(): void $this->configureAuthentication(); $this->configureRateLimits(); Product::observe(ProductObserver::class); + ResetPassword::createUrlUsing(function ($notifiable, string $token): string { + $path = $notifiable instanceof \App\Models\Customer ? '/reset-password/' : '/reset-password/'; + + return url($path.$token).'?email='.urlencode($notifiable->getEmailForPasswordReset()); + }); if (config('database.default') === 'sqlite') { try { diff --git a/bootstrap/app.php b/bootstrap/app.php index 7dccbd6a..9093f7ea 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,7 +24,9 @@ ]); $middleware->group('storefront', [ResolveStore::class]); $middleware->group('admin.store', [ResolveStore::class]); - $middleware->redirectGuestsTo(fn ($request): string => $request->is('account/*') ? '/account/login' : '/admin/login'); + $middleware->redirectGuestsTo(fn ($request): string => $request->is('account', 'account/*') + ? '/account/login' + : ($request->is('admin', 'admin/*') ? '/admin/login' : '/login')); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeedc..d780f2de 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -64,3 +64,399 @@ select:focus[data-flux-control] { /* \[:where(&)\]:size-4 { @apply size-4; } */ + +/* Storefront theme tokens are intentionally plain CSS variables so active theme + settings can override them at render time without generating Tailwind classes. */ +@layer base { + :root { + --storefront-primary: #2563eb; + --storefront-primary-hover: #1d4ed8; + --storefront-primary-soft: #eff6ff; + --storefront-secondary: #18181b; + --storefront-accent: #ea580c; + } + + html { + scroll-behavior: smooth; + } + + html.dark { + --storefront-primary: #3b82f6; + --storefront-primary-hover: #60a5fa; + --storefront-primary-soft: #172554; + --storefront-secondary: #fafafa; + --storefront-accent: #fb923c; + } + + @media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } + + [x-cloak] { + display: none !important; + } + + input[type='number'].appearance-none::-webkit-inner-spin-button, + input[type='number'].appearance-none::-webkit-outer-spin-button { + margin: 0; + appearance: none; + } +} + +@layer components { + .storefront-shell { + accent-color: var(--storefront-primary); + } + + .storefront-skip-link { + @apply fixed left-4 top-4 z-[100] -translate-y-24 rounded-lg bg-zinc-950 px-4 py-2.5 text-sm font-semibold text-white shadow-xl transition-transform focus:translate-y-0 focus:outline-none dark:bg-white dark:text-zinc-950; + } + + .storefront-skip-link:focus-visible { + outline: 3px solid var(--storefront-primary); + outline-offset: 3px; + } + + .storefront-button-primary, + .storefront-button-secondary { + @apply inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-center text-sm font-semibold transition focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50; + } + + .storefront-button-primary { + background-color: var(--storefront-primary); + color: white; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.08); + } + + .storefront-button-primary:hover:not(:disabled) { + background-color: var(--storefront-primary-hover); + } + + .storefront-button-secondary { + @apply border border-zinc-300 bg-white text-zinc-900 shadow-sm hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-white dark:hover:bg-zinc-800; + } + + .storefront-button-primary:focus-visible, + .storefront-button-secondary:focus-visible, + .storefront-pagination-button:focus-visible { + outline: 2px solid var(--storefront-primary); + outline-offset: 2px; + } + + .storefront-field-label { + @apply mb-1.5 block text-sm font-medium text-zinc-800 dark:text-zinc-200; + } + + .storefront-field-input { + @apply block min-h-11 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2.5 text-base text-zinc-950 shadow-sm transition placeholder:text-zinc-400 focus:outline-none sm:text-sm dark:border-zinc-700 dark:bg-zinc-900 dark:text-white dark:placeholder:text-zinc-500; + } + + .storefront-field-input:focus { + border-color: var(--storefront-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--storefront-primary) 22%, transparent); + } + + .storefront-field-input[aria-invalid='true'] { + @apply border-red-500; + } + + .storefront-field-input[aria-invalid='true']:focus { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgb(220 38 38 / 0.18); + } + + .storefront-field-error { + @apply mt-1.5 text-sm text-red-600 dark:text-red-400; + } + + .storefront-pagination-button { + @apply inline-flex min-h-10 items-center justify-center gap-1 rounded-lg border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-100 hover:text-zinc-950 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white; + } + + .storefront-line-clamp-1, + .storefront-line-clamp-2 { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + } + + .storefront-line-clamp-1 { + -webkit-line-clamp: 1; + } + + .storefront-line-clamp-2 { + -webkit-line-clamp: 2; + } + + .storefront-product-card__secondary { + opacity: 0; + transition: opacity 300ms ease; + } + + .storefront-product-card__quick-add { + opacity: 1; + transform: translateY(0); + } + + @media (hover: hover) and (pointer: fine) { + .storefront-product-card:hover .storefront-product-card__secondary, + .storefront-product-card:focus-within .storefront-product-card__secondary { + opacity: 1; + } + + .storefront-product-card__quick-add { + opacity: 0; + transform: translateY(0.25rem); + transition: opacity 200ms ease, transform 200ms ease; + } + + .storefront-product-card:hover .storefront-product-card__quick-add, + .storefront-product-card:focus-within .storefront-product-card__quick-add { + opacity: 1; + transform: translateY(0); + } + } + + .storefront-prose { + @apply mx-auto max-w-prose text-base leading-7 text-zinc-700 dark:text-zinc-300; + } + + .storefront-prose :where(h1, h2, h3, h4) { + @apply mt-8 scroll-mt-24 font-semibold tracking-tight text-zinc-950 first:mt-0 dark:text-white; + } + + .storefront-prose :where(h1) { + @apply text-3xl; + } + + .storefront-prose :where(h2) { + @apply text-2xl; + } + + .storefront-prose :where(h3, h4) { + @apply text-xl; + } + + .storefront-prose :where(p, ul, ol, blockquote) { + @apply my-5; + } + + .storefront-prose :where(ul, ol) { + @apply space-y-2 pl-6; + } + + .storefront-prose :where(ul) { + @apply list-disc; + } + + .storefront-prose :where(ol) { + @apply list-decimal; + } + + .storefront-prose :where(a) { + color: var(--storefront-primary); + @apply rounded-sm font-medium underline underline-offset-4; + } + + .storefront-prose :where(a):focus-visible { + outline: 2px solid var(--storefront-primary); + outline-offset: 2px; + } + + .storefront-prose :where(blockquote) { + @apply border-l-4 border-zinc-300 pl-4 italic text-zinc-600 dark:border-zinc-700 dark:text-zinc-400; + } + + /* Shared storefront page primitives. The sf-* namespace keeps the customer + surface independent from Flux/admin component styling. */ + .sf-container { + @apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8; + } + + .sf-page-y { + @apply py-8 sm:py-12 lg:py-16; + } + + .sf-section { + @apply py-14 sm:py-20 lg:py-24; + } + + .sf-hero { + background-position: center; + background-size: cover; + } + + .sf-page-title { + @apply text-3xl font-semibold tracking-tight text-slate-950 sm:text-4xl dark:text-white; + } + + .sf-section-title { + @apply mt-1 text-2xl font-semibold tracking-tight text-slate-950 sm:text-3xl dark:text-white; + } + + .sf-eyebrow { + @apply text-xs font-semibold uppercase tracking-[0.16em]; + color: var(--storefront-primary); + } + + .sf-card { + @apply rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900; + } + + .sf-button { + @apply inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-center text-sm font-semibold transition focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50; + } + + .sf-button-primary { + background-color: var(--storefront-primary); + color: white; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.1); + } + + .sf-button-primary:hover:not(:disabled) { + background-color: var(--storefront-primary-hover); + } + + .sf-button-secondary { + @apply border border-slate-300 bg-white text-slate-900 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:hover:bg-slate-800; + } + + .sf-button:focus-visible, + .sf-icon-button:focus-visible, + .sf-text-link:focus-visible, + .sf-account-nav-link:focus-visible, + .sf-filter-pill:focus-visible { + outline: 2px solid var(--storefront-primary); + outline-offset: 2px; + } + + .sf-icon-button { + @apply inline-grid size-11 shrink-0 place-items-center rounded-full text-slate-600 transition hover:bg-slate-100 hover:text-slate-950 focus-visible:outline-none dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white; + } + + .sf-text-link { + @apply inline-flex items-center gap-1 rounded-sm font-medium underline-offset-4 transition hover:underline focus-visible:outline-none; + color: var(--storefront-primary); + } + + .sf-label { + @apply block text-sm font-medium text-slate-800 dark:text-slate-200; + } + + .sf-input, + .sf-select { + @apply min-h-11 rounded-xl border border-slate-300 bg-white px-3 py-2.5 text-base text-slate-950 shadow-sm transition placeholder:text-slate-400 focus:outline-none sm:text-sm dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:placeholder:text-slate-500; + } + + .sf-input { + @apply block w-full; + } + + .sf-select { + @apply block cursor-pointer pr-9; + } + + .sf-input:focus, + .sf-select:focus { + border-color: var(--storefront-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--storefront-primary) 20%, transparent); + } + + .sf-input-error, + .sf-input[aria-invalid='true'], + .sf-select[aria-invalid='true'] { + @apply border-red-500; + } + + .sf-input-error:focus, + .sf-input[aria-invalid='true']:focus, + .sf-select[aria-invalid='true']:focus { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgb(220 38 38 / 0.18); + } + + .sf-field-error { + @apply mt-1.5 text-sm text-red-700 dark:text-red-300; + } + + .sf-checkbox, + .sf-radio { + @apply size-4 shrink-0 border-slate-300 focus-visible:outline-none dark:border-slate-600 dark:bg-slate-900; + accent-color: var(--storefront-primary); + } + + .sf-checkbox { + @apply rounded; + } + + .sf-radio { + @apply rounded-full; + } + + .sf-checkbox:focus-visible, + .sf-radio:focus-visible { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--storefront-primary) 24%, transparent); + } + + .sf-callout { + @apply rounded-xl border px-4 py-3 text-sm leading-6; + } + + .sf-callout-error { + @apply border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200; + } + + .sf-callout-info { + @apply border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/50 dark:text-blue-200; + } + + .sf-callout-warning { + @apply border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/50 dark:text-amber-200; + } + + .sf-empty-state { + @apply rounded-2xl border border-dashed border-slate-300 px-5 py-14 text-center dark:border-slate-700; + } + + .sf-filter-pill { + @apply inline-flex min-h-9 items-center gap-1.5 rounded-full border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:text-slate-950 focus-visible:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white; + } + + .sf-account-nav-link { + @apply flex min-h-11 items-center justify-center rounded-xl px-3 py-2 text-center text-sm font-medium text-slate-600 transition hover:bg-white hover:text-slate-950 focus-visible:outline-none lg:justify-start dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white; + } + + .sf-account-nav-link[aria-current='page'] { + @apply bg-white text-slate-950 shadow-sm dark:bg-slate-800 dark:text-white; + } + + .sf-checkout-step { + @apply border-b border-slate-200 py-8 first:pt-0 last:border-b-0 dark:border-slate-800; + } + + .sf-step-number { + @apply text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400; + } + + .sf-step-title { + @apply mt-1 text-xl font-semibold tracking-tight text-slate-950 dark:text-white; + } +} + +@media (prefers-reduced-motion: reduce) { + .storefront-product-card__secondary, + .storefront-product-card__quick-add { + transition: none; + } +} diff --git a/resources/views/components/storefront/account-shell.blade.php b/resources/views/components/storefront/account-shell.blade.php new file mode 100644 index 00000000..18c977a9 --- /dev/null +++ b/resources/views/components/storefront/account-shell.blade.php @@ -0,0 +1,2 @@ +@props(['heading', 'intro' => null]) + diff --git a/resources/views/components/storefront/address-form.blade.php b/resources/views/components/storefront/address-form.blade.php new file mode 100644 index 00000000..bdf9b3f6 --- /dev/null +++ b/resources/views/components/storefront/address-form.blade.php @@ -0,0 +1,163 @@ +@props([ + 'address' => null, + 'prefix' => '', + 'countries' => null, +]) + +@php + $errors ??= new \Illuminate\Support\ViewErrorBag(); + $address = is_array($address) ? $address : (array) ($address ?? []); + $prefix = trim((string) $prefix, '.'); + $fieldName = static fn (string $field): string => $prefix === '' ? $field : $prefix.'.'.$field; + $idPrefix = trim(preg_replace('/[^a-zA-Z0-9_-]+/', '-', $prefix), '-') ?: 'address'; + $fieldId = static fn (string $field): string => $idPrefix.'-'.str_replace('_', '-', $field); + $autocompleteSection = in_array($prefix, ['shipping', 'billing'], true) ? $prefix.' ' : ''; + $valueFor = static function (string $field, mixed $default = '') use ($address): mixed { + return match ($field) { + 'postal_code' => data_get($address, 'postal_code', data_get($address, 'zip', $default)), + 'country' => data_get($address, 'country_code', data_get($address, 'country', $default)), + default => data_get($address, $field, $default), + }; + }; + + $countries ??= [ + 'DE' => __('Germany'), + 'AT' => __('Austria'), + 'BE' => __('Belgium'), + 'DK' => __('Denmark'), + 'FR' => __('France'), + 'IT' => __('Italy'), + 'NL' => __('Netherlands'), + 'PL' => __('Poland'), + 'PT' => __('Portugal'), + 'ES' => __('Spain'), + 'SE' => __('Sweden'), + 'CH' => __('Switzerland'), + 'GB' => __('United Kingdom'), + 'US' => __('United States'), + 'CA' => __('Canada'), + 'AU' => __('Australia'), + ]; + + $inputClass = 'storefront-field-input'; + $labelClass = 'storefront-field-label'; +@endphp + +
class('grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2') }}> + {{ __('Address') }} + + @foreach ([ + ['first_name', __('First name'), 'given-name', true], + ['last_name', __('Last name'), 'family-name', true], + ] as [$field, $label, $autocomplete, $required]) + @php($model = $fieldName($field)) +
+ + has($model)) aria-invalid="true" @endif + > + @if ($errors->has($model)) +

{{ $errors->first($model) }}

+ @endif +
+ @endforeach + + @php($model = $fieldName('company')) +
+ + +
+ + @foreach ([ + ['address1', __('Address'), 'address-line1', true], + ['address2', __('Apartment, suite, etc.'), 'address-line2', false], + ] as [$field, $label, $autocomplete, $required]) + @php($model = $fieldName($field)) +
+ + has($model)) aria-invalid="true" @endif + > + @if ($errors->has($model)) +

{{ $errors->first($model) }}

+ @endif +
+ @endforeach + + @php($model = $fieldName('city')) +
+ + has($model)) aria-invalid="true" @endif> + @if ($errors->has($model)) +

{{ $errors->first($model) }}

+ @endif +
+ + @php($model = $fieldName('province')) +
+ + has($model)) aria-invalid="true" @endif> + @if ($errors->has($model)) +

{{ $errors->first($model) }}

+ @endif +
+ + @php($model = $fieldName('postal_code')) +
+ + has($model)) aria-invalid="true" @endif> + @if ($errors->has($model)) +

{{ $errors->first($model) }}

+ @endif +
+ + @php($model = $fieldName('country')) +
+ + + @if ($errors->has($model)) +

{{ $errors->first($model) }}

+ @endif +
+ + @php($model = $fieldName('phone')) +
+ + has($model)) aria-invalid="true" @endif> + @if ($errors->has($model)) +

{{ $errors->first($model) }}

+ @endif +
+
diff --git a/resources/views/components/storefront/badge.blade.php b/resources/views/components/storefront/badge.blade.php new file mode 100644 index 00000000..9d14b247 --- /dev/null +++ b/resources/views/components/storefront/badge.blade.php @@ -0,0 +1,34 @@ +@props([ + 'text', + 'variant' => 'default', +]) + +@php + $variant = in_array($variant, ['sale', 'sold-out', 'new', 'default'], true) + ? $variant + : 'default'; + + $variantClasses = [ + 'sale' => 'bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-950/60 dark:text-red-300 dark:ring-red-400/20', + 'sold-out' => 'bg-zinc-100 text-zinc-600 ring-zinc-500/10 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-white/10', + 'new' => 'bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-950/60 dark:text-blue-300 dark:ring-blue-400/20', + 'default' => 'bg-zinc-100 text-zinc-700 ring-zinc-500/10 dark:bg-zinc-800 dark:text-zinc-200 dark:ring-white/10', + ]; + + $accessibleLabels = [ + 'sale' => __('On sale'), + 'sold-out' => __('Sold out'), + 'new' => __('New product'), + 'default' => (string) $text, + ]; +@endphp + +except('aria-label')->class([ + 'inline-flex w-fit items-center rounded-full px-2.5 py-1 text-xs font-medium leading-none ring-1 ring-inset', + $variantClasses[$variant], + ]) }} + aria-label="{{ $attributes->get('aria-label', $accessibleLabels[$variant]) }}" +> + {{ $text }} + diff --git a/resources/views/components/storefront/breadcrumbs.blade.php b/resources/views/components/storefront/breadcrumbs.blade.php new file mode 100644 index 00000000..e75d8741 --- /dev/null +++ b/resources/views/components/storefront/breadcrumbs.blade.php @@ -0,0 +1,59 @@ +@props([ + 'items', +]) + +@php + $items = collect($items)->values(); + + $structuredItems = $items->map(function (mixed $item, int $index): array { + $structuredItem = [ + '@type' => 'ListItem', + 'position' => $index + 1, + 'name' => (string) data_get($item, 'label', ''), + ]; + + if (filled(data_get($item, 'url'))) { + $structuredItem['item'] = (string) data_get($item, 'url'); + } + + return $structuredItem; + })->all(); + + $structuredData = json_encode([ + '@context' => 'https://schema.org', + '@type' => 'BreadcrumbList', + 'itemListElement' => $structuredItems, + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); +@endphp + + + + diff --git a/resources/views/components/storefront/layout.blade.php b/resources/views/components/storefront/layout.blade.php new file mode 100644 index 00000000..fc5a0a0f --- /dev/null +++ b/resources/views/components/storefront/layout.blade.php @@ -0,0 +1,92 @@ +@props([ + 'title' => null, + 'metaDescription' => '', + 'darkMode' => 'system', +]) + +@php + $appName = (string) config('app.name', 'Shop'); + $pageTitle = filled($title) ? (string) $title : $appName; + $documentTitle = $pageTitle === $appName ? $appName : $pageTitle.' β€” '.$appName; + $darkMode = in_array($darkMode, ['system', 'toggle', 'light', 'dark'], true) ? $darkMode : 'system'; +@endphp + + + + + + + + + {{ $documentTitle }} + + + + + + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + @stack('head') + + class('storefront-shell min-h-screen bg-white text-zinc-700 antialiased dark:bg-zinc-950 dark:text-zinc-300') }}> + {{ __('Skip to main content') }} + + @isset($announcement) + {{ $announcement }} + @endisset + + @isset($header) + {{ $header }} + @endisset + +
+ {{ $slot }} +
+ + @isset($footer) + {{ $footer }} + @endisset + + @isset($cart) + {{ $cart }} + @endisset + + @isset($search) + {{ $search }} + @endisset + + @livewireScripts + @stack('scripts') + + diff --git a/resources/views/components/storefront/order-summary.blade.php b/resources/views/components/storefront/order-summary.blade.php new file mode 100644 index 00000000..c41baed8 --- /dev/null +++ b/resources/views/components/storefront/order-summary.blade.php @@ -0,0 +1,179 @@ +@props([ + 'checkout', + 'showDiscountInput' => true, + 'headingId' => 'order-summary-heading', +]) + +@php + $errors ??= new \Illuminate\Support\ViewErrorBag(); + $cart = data_get($checkout, 'cart'); + $lines = collect(data_get($cart, 'lines', [])); + $currency = (string) data_get($cart, 'currency', data_get($checkout, 'currency', 'USD')); + $discountCode = data_get($checkout, 'discount_code'); + $enumValue = static fn (mixed $value): mixed => $value instanceof \BackedEnum ? $value->value : $value; + + $totals = data_get($checkout, 'totals_json', []); + + if (is_string($totals)) { + $totals = json_decode($totals, true) ?: []; + } elseif (is_object($totals)) { + $totals = (array) $totals; + } + + $lineSubtotal = $lines->sum(fn (mixed $line): int => (int) data_get( + $line, + 'line_subtotal_amount', + (int) data_get($line, 'unit_price_amount', 0) * (int) data_get($line, 'quantity', 1), + )); + + $subtotal = (int) data_get($totals, 'subtotal', data_get($totals, 'subtotal_amount', $lineSubtotal)); + $discount = abs((int) data_get($totals, 'discount', data_get($totals, 'discount_amount', 0))); + $hasShippingAmount = array_key_exists('shipping', $totals) || array_key_exists('shipping_amount', $totals); + $shipping = $hasShippingAmount + ? (int) data_get($totals, 'shipping', data_get($totals, 'shipping_amount', 0)) + : null; + $tax = (int) data_get($totals, 'tax', data_get($totals, 'tax_amount', 0)); + $total = (int) data_get( + $totals, + 'total', + data_get($totals, 'total_amount', $subtotal - $discount + ($shipping ?? 0) + $tax), + ); + + $mediaUrl = static function (mixed $item): ?string { + $url = data_get($item, 'url'); + + if (filled($url)) { + return (string) $url; + } + + $storageKey = data_get($item, 'storage_key'); + + if (blank($storageKey)) { + return null; + } + + if (str_starts_with((string) $storageKey, 'http://') || str_starts_with((string) $storageKey, 'https://') || str_starts_with((string) $storageKey, '/')) { + return (string) $storageKey; + } + + return asset('storage/'.ltrim((string) $storageKey, '/')); + }; +@endphp + + diff --git a/resources/views/components/storefront/pagination.blade.php b/resources/views/components/storefront/pagination.blade.php new file mode 100644 index 00000000..a45225e5 --- /dev/null +++ b/resources/views/components/storefront/pagination.blade.php @@ -0,0 +1,125 @@ +@props([ + 'paginator', + 'scrollTo' => 'main', +]) + +@php + $currentPage = $paginator->currentPage(); + $lastPage = $paginator->lastPage(); + $pageName = $paginator->getPageName(); + + if ($lastPage <= 7) { + $pages = range(1, max(1, $lastPage)); + } else { + $pages = [1]; + $windowStart = max(2, $currentPage - 1); + $windowEnd = min($lastPage - 1, $currentPage + 1); + + if ($windowStart > 2) { + $pages[] = 'start-ellipsis'; + } + + foreach (range($windowStart, $windowEnd) as $page) { + $pages[] = $page; + } + + if ($windowEnd < $lastPage - 1) { + $pages[] = 'end-ellipsis'; + } + + $pages[] = $lastPage; + } + + $scrollExpression = $scrollTo === false + ? '' + : "document.querySelector(".Illuminate\Support\Js::from((string) $scrollTo).")?.scrollIntoView({ behavior: 'smooth', block: 'start' })"; +@endphp + +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/components/storefront/price.blade.php b/resources/views/components/storefront/price.blade.php new file mode 100644 index 00000000..e04476f2 --- /dev/null +++ b/resources/views/components/storefront/price.blade.php @@ -0,0 +1,49 @@ +@props([ + 'amount', + 'currency' => 'USD', + 'compareAtAmount' => null, + 'showSaleBadge' => true, +]) + +@php + $amount = (int) $amount; + $currency = strtoupper((string) $currency); + $currency = preg_match('/^[A-Z]{3}$/', $currency) === 1 ? $currency : 'USD'; + $compareAtAmount = filled($compareAtAmount) ? (int) $compareAtAmount : null; + $isOnSale = $compareAtAmount !== null && $compareAtAmount > $amount; + + $formatMoney = static function (int $minorUnits, string $currencyCode): string { + $sign = $minorUnits < 0 ? '-' : ''; + $absoluteAmount = abs($minorUnits); + + return $sign.number_format($absoluteAmount / 100, 2, '.', ',').' '.$currencyCode; + }; + + $formattedAmount = $formatMoney($amount, $currency); + $formattedCompareAtAmount = $compareAtAmount !== null + ? $formatMoney($compareAtAmount, $currency) + : null; + $accessibleLabel = $attributes->get( + 'aria-label', + $isOnSale ? __('Sale price :sale, regular price :regular', ['sale' => $formattedAmount, 'regular' => $formattedCompareAtAmount]) : null, + ); +@endphp + +except('aria-label')->class('inline-flex flex-wrap items-baseline gap-x-2 gap-y-1 tabular-nums') }} + @if (filled($accessibleLabel)) aria-label="{{ $accessibleLabel }}" @endif +> + + {{ $formattedAmount }} + + + @if ($isOnSale) + + + @if ($showSaleBadge) + + @endif + @endif + diff --git a/resources/views/components/storefront/product-card.blade.php b/resources/views/components/storefront/product-card.blade.php new file mode 100644 index 00000000..fa9b75d8 --- /dev/null +++ b/resources/views/components/storefront/product-card.blade.php @@ -0,0 +1,177 @@ +@props([ + 'product', + 'headingLevel' => 'h3', + 'showQuickAdd' => true, + 'quickAddMethod' => 'addToCart', +]) + +@php + $headingLevel = in_array(strtolower((string) $headingLevel), ['h2', 'h3', 'h4', 'h5', 'h6'], true) + ? strtolower((string) $headingLevel) + : 'h3'; + + $title = (string) data_get($product, 'title', __('Untitled product')); + $handle = (string) data_get($product, 'handle', ''); + $productUrl = url('/products/'.rawurlencode($handle)); + $enumValue = static fn (mixed $value): mixed => $value instanceof \BackedEnum ? $value->value : $value; + + $variants = collect(data_get($product, 'variants', [])) + ->filter(fn (mixed $variant): bool => $enumValue(data_get($variant, 'status', 'active')) !== 'archived') + ->sortBy(fn (mixed $variant): int => (int) data_get($variant, 'position', 0)) + ->values(); + + $defaultVariant = $variants->first(fn (mixed $variant): bool => (bool) data_get($variant, 'is_default')) + ?? $variants->first(); + + $amount = (int) data_get($defaultVariant, 'price_amount', 0); + $compareAtAmount = data_get($defaultVariant, 'compare_at_amount'); + $currency = (string) data_get($defaultVariant, 'currency', 'USD'); + $isOnSale = filled($compareAtAmount) && (int) $compareAtAmount > $amount; + + $isVariantUnavailable = static function (mixed $variant) use ($enumValue): bool { + $inventory = data_get($variant, 'inventoryItem', data_get($variant, 'inventory_item')); + + if ($inventory === null) { + return false; + } + + if ($enumValue(data_get($inventory, 'policy', 'deny')) === 'continue') { + return false; + } + + $available = data_get($inventory, 'available_quantity'); + $available ??= (int) data_get($inventory, 'quantity_on_hand', 0) + - (int) data_get($inventory, 'quantity_reserved', 0); + + return (int) $available <= 0; + }; + + $isSoldOut = $variants->isNotEmpty() && $variants->every($isVariantUnavailable); + + $media = collect(data_get($product, 'media', [])) + ->filter(fn (mixed $item): bool => $enumValue(data_get($item, 'type', 'image')) === 'image' + && $enumValue(data_get($item, 'status', 'ready')) === 'ready') + ->sortBy(fn (mixed $item): int => (int) data_get($item, 'position', 0)) + ->values(); + + $mediaUrl = static function (mixed $item): ?string { + $url = data_get($item, 'url'); + + if (filled($url)) { + return (string) $url; + } + + $storageKey = data_get($item, 'storage_key'); + + if (blank($storageKey)) { + return null; + } + + if (str_starts_with((string) $storageKey, 'http://') || str_starts_with((string) $storageKey, 'https://') || str_starts_with((string) $storageKey, '/')) { + return (string) $storageKey; + } + + return asset('storage/'.ltrim((string) $storageKey, '/')); + }; + + $primaryMedia = $media->get(0); + $secondaryMedia = $media->get(1); + $primaryImageUrl = $mediaUrl($primaryMedia); + $secondaryImageUrl = $mediaUrl($secondaryMedia); + $primaryAlt = (string) data_get($primaryMedia, 'alt_text', $title); + $primaryWidth = (int) data_get($primaryMedia, 'width', 600); + $primaryHeight = (int) data_get($primaryMedia, 'height', 600); + $primaryWidth = $primaryWidth > 0 ? $primaryWidth : 600; + $primaryHeight = $primaryHeight > 0 ? $primaryHeight : 600; + + $quickAddMethod = preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', (string) $quickAddMethod) === 1 + ? (string) $quickAddMethod + : 'addToCart'; + $variantId = (int) data_get($defaultVariant, 'id', 0); + $quickAddAction = $quickAddMethod.'('.$variantId.')'; +@endphp + +
class('storefront-product-card group relative flex h-full min-w-0 flex-col') }}> + + + @if ($primaryImageUrl) + {{ $primaryAlt }} + + @if ($secondaryImageUrl) + + @endif + @else + + @endif + + + @if ($isOnSale || $isSoldOut) + + @if ($isOnSale) + + @endif + @if ($isSoldOut) + + @endif + + @endif + + +
+ <{{ $headingLevel }} class="storefront-line-clamp-2 text-sm font-semibold leading-5 text-zinc-950 dark:text-white"> + + {{ $title }} + + + +
+ +
+ + @if ($showQuickAdd) +
+ @if ($isSoldOut || $variants->isEmpty()) + + @elseif ($variants->count() === 1) + + @else + + {{ __('Choose options') }} + + @endif +
+ @endif +
+
diff --git a/resources/views/components/storefront/quantity-selector.blade.php b/resources/views/components/storefront/quantity-selector.blade.php new file mode 100644 index 00000000..d16c17c5 --- /dev/null +++ b/resources/views/components/storefront/quantity-selector.blade.php @@ -0,0 +1,75 @@ +@props([ + 'value' => 1, + 'min' => 1, + 'max' => null, + 'wireModel', + 'compact' => false, + 'disabled' => false, +]) + +@php + $min = (int) $min; + $max = filled($max) ? max($min, (int) $max) : null; + $value = max($min, (int) $value); + $value = $max !== null ? min($value, $max) : $value; + $inputId = $attributes->get('id', 'quantity-'.str_replace(['.', '[', ']'], '-', (string) $wireModel)); + $controlSize = $compact ? 'size-8' : 'size-10'; + $inputSize = $compact ? 'h-8 w-10 text-sm' : 'h-10 w-14'; +@endphp + +
except(['id'])->class('inline-flex items-center overflow-hidden rounded-lg border border-zinc-300 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900') }} +> + + + + + + +
diff --git a/resources/views/components/storefront/rich-text.blade.php b/resources/views/components/storefront/rich-text.blade.php new file mode 100644 index 00000000..9c67564f --- /dev/null +++ b/resources/views/components/storefront/rich-text.blade.php @@ -0,0 +1,87 @@ +@props([ + 'html' => '', +]) + +@php + $source = (string) $html; + $source = preg_replace( + '#<(script|style|iframe|object|embed|template|svg|math)\b[^>]*>.*?#is', + '', + $source, + ) ?? ''; + $source = preg_replace('#<(script|style|iframe|object|embed|template|svg|math)\b[^>]*/?>#is', '', $source) ?? ''; + + $allowedTags = '



    1. '; + $source = strip_tags($source, $allowedTags); + $sanitized = e(strip_tags($source)); + + if (class_exists(\DOMDocument::class) && $source !== '') { + $document = new \DOMDocument('1.0', 'UTF-8'); + $previousSetting = libxml_use_internal_errors(true); + $document->loadHTML( + '
      '.$source.'
      ', + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, + ); + libxml_clear_errors(); + libxml_use_internal_errors($previousSetting); + + $xpath = new \DOMXPath($document); + + foreach ($xpath->query('//*[@*]') ?: [] as $element) { + $tagName = strtolower($element->nodeName); + + foreach (iterator_to_array($element->attributes) as $attribute) { + $attributeName = strtolower($attribute->nodeName); + $isAllowedAnchorAttribute = $tagName === 'a' + && in_array($attributeName, ['href', 'title', 'target', 'rel'], true); + $isAllowedId = $attributeName === 'id' + && preg_match('/^[a-zA-Z][a-zA-Z0-9_:\-.]*$/', $attribute->nodeValue) === 1; + + if (! $isAllowedAnchorAttribute && ! $isAllowedId) { + $element->removeAttributeNode($attribute); + } + } + + if ($tagName !== 'a') { + continue; + } + + $href = trim(html_entity_decode($element->getAttribute('href'), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + $scheme = strtolower((string) parse_url($href, PHP_URL_SCHEME)); + + if ($href === '' || preg_match('/[\x00-\x1F\x7F]/', $href) === 1 || ($scheme !== '' && ! in_array($scheme, ['http', 'https', 'mailto', 'tel'], true))) { + $element->removeAttribute('href'); + } + + if ($element->hasAttribute('target') && $element->getAttribute('target') !== '_blank') { + $element->removeAttribute('target'); + } + + if ($element->getAttribute('target') === '_blank') { + $element->setAttribute('rel', 'noopener noreferrer'); + } else { + $element->removeAttribute('rel'); + } + } + + foreach ($xpath->query('//comment()') ?: [] as $comment) { + $comment->parentNode?->removeChild($comment); + } + + $root = $document->getElementById('storefront-rich-text-root'); + + if ($root) { + $sanitized = ''; + + foreach ($root->childNodes as $child) { + $sanitized .= $document->saveHTML($child); + } + } + } +@endphp + +@if (trim(strip_tags($sanitized)) !== '') +
      class('storefront-prose') }}> + {!! $sanitized !!} +
      +@endif diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..799c74fa --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1 @@ +@include('storefront.errors.404') diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..c5bb725f --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1 @@ +@include('storefront.errors.503') diff --git a/resources/views/storefront/account/addresses/index.blade.php b/resources/views/storefront/account/addresses/index.blade.php new file mode 100644 index 00000000..62b18589 --- /dev/null +++ b/resources/views/storefront/account/addresses/index.blade.php @@ -0,0 +1,5 @@ + +
      + @if($this->addresses->isEmpty())

      No saved addresses

      Add an address to make checkout faster.

      @else
      @foreach($this->addresses as $saved)@php($data = is_string($saved->address_json) ? json_decode($saved->address_json,true) : (array)$saved->address_json)
      @if($saved->is_default)@endif

      {{ $saved->label ?: 'Address' }}

      {{ data_get($data,'first_name') }} {{ data_get($data,'last_name') }}
      {{ data_get($data,'address1') }}@if(data_get($data,'address2'))
      {{ data_get($data,'address2') }}@endif
      {{ data_get($data,'zip',data_get($data,'postal_code')) }} {{ data_get($data,'city') }}
      {{ data_get($data,'country_code',data_get($data,'country')) }}
      @if(!$saved->is_default)@endif
      @endforeach
      @endif + +
      diff --git a/resources/views/storefront/account/auth/forgot-password.blade.php b/resources/views/storefront/account/auth/forgot-password.blade.php new file mode 100644 index 00000000..109019be --- /dev/null +++ b/resources/views/storefront/account/auth/forgot-password.blade.php @@ -0,0 +1 @@ +
      diff --git a/resources/views/storefront/account/auth/login.blade.php b/resources/views/storefront/account/auth/login.blade.php new file mode 100644 index 00000000..9792d1e7 --- /dev/null +++ b/resources/views/storefront/account/auth/login.blade.php @@ -0,0 +1,14 @@ +
      +
      +

      Welcome back

      Log in to your account

      +
      + @error('credentials')@enderror +
      +
      @error('email')

      {{ $message }}

      @enderror
      +
      @error('password')

      {{ $message }}

      @enderror
      + +
      +
      +

      Don’t have an account? Create one

      +
      +
      diff --git a/resources/views/storefront/account/auth/register.blade.php b/resources/views/storefront/account/auth/register.blade.php new file mode 100644 index 00000000..574fe4a4 --- /dev/null +++ b/resources/views/storefront/account/auth/register.blade.php @@ -0,0 +1 @@ +

      Join us

      Create an account

      @error('name')

      {{ $message }}

      @enderror
      @error('email')

      {{ $message }}

      @enderror

      At least 8 characters.

      @error('password')

      {{ $message }}

      @enderror
      @error('passwordConfirmation')

      {{ $message }}

      @enderror

      Already have an account? Log in

      diff --git a/resources/views/storefront/account/auth/reset-password.blade.php b/resources/views/storefront/account/auth/reset-password.blade.php new file mode 100644 index 00000000..914a3624 --- /dev/null +++ b/resources/views/storefront/account/auth/reset-password.blade.php @@ -0,0 +1 @@ +

      Account recovery

      Choose a new password

      @error('email')

      {{ $message }}

      @enderror
      @error('password')

      {{ $message }}

      @enderror
      diff --git a/resources/views/storefront/account/dashboard.blade.php b/resources/views/storefront/account/dashboard.blade.php new file mode 100644 index 00000000..d4e70798 --- /dev/null +++ b/resources/views/storefront/account/dashboard.blade.php @@ -0,0 +1,4 @@ + + +

      Recent Orders

      @if($orders->isNotEmpty())View all@endif
      @if($orders->isEmpty())

      No orders yet

      Start shopping
      @else
      @foreach($orders as $order)@endforeach
      OrderDateStatusTotal
      {{ $order->order_number }}{{ optional($order->placed_at)->format('M j, Y') }}
      @endif
      +
      diff --git a/resources/views/storefront/account/orders/index.blade.php b/resources/views/storefront/account/orders/index.blade.php new file mode 100644 index 00000000..22221b13 --- /dev/null +++ b/resources/views/storefront/account/orders/index.blade.php @@ -0,0 +1,6 @@ + + @if($orders->isEmpty())

      No orders yet

      Once you place an order, you’ll find it here.

      Start shopping
      @else + +
      + @endif +
      diff --git a/resources/views/storefront/account/orders/show.blade.php b/resources/views/storefront/account/orders/show.blade.php new file mode 100644 index 00000000..619ae63a --- /dev/null +++ b/resources/views/storefront/account/orders/show.blade.php @@ -0,0 +1,9 @@ + +
      +

      Items

      @foreach($order->lines as $line)@php($media = optional(optional($line->product)->media->first())->storage_key)
      @if($media)@endif

      {{ $line->title_snapshot }}

      @if($line->sku_snapshot)

      SKU {{ $line->sku_snapshot }}

      @endif

      Quantity {{ $line->quantity }}

      @endforeach
      + @php($shipping = is_string($order->shipping_address_json) ? json_decode($order->shipping_address_json, true) : (array) $order->shipping_address_json) + @php($billing = is_string($order->billing_address_json) ? json_decode($order->billing_address_json, true) : (array) $order->billing_address_json) +

      Shipping Address

      {{ data_get($shipping,'first_name') }} {{ data_get($shipping,'last_name') }}
      {{ data_get($shipping,'address1') }}
      {{ data_get($shipping,'zip',data_get($shipping,'postal_code')) }} {{ data_get($shipping,'city') }}
      {{ data_get($shipping,'country_code',data_get($shipping,'country')) }}

      Billing Address

      {{ data_get($billing,'first_name') }} {{ data_get($billing,'last_name') }}
      {{ data_get($billing,'address1') }}
      {{ data_get($billing,'zip',data_get($billing,'postal_code')) }} {{ data_get($billing,'city') }}

      Payment

      {{ str_replace('_',' ', $order->payment_method instanceof \BackedEnum ? $order->payment_method->value : $order->payment_method) }}

      +
      Subtotal
      @if($order->discount_amount > 0)
      Discount
      -
      @endif
      Shipping
      Tax
      Total
      + @if($order->fulfillments->isNotEmpty())

      Fulfillment

      @foreach($order->fulfillments as $fulfillment)
      {{ $fulfillment->status instanceof \BackedEnum ? $fulfillment->status->value : $fulfillment->status }}@if($fulfillment->tracking_url)Track shipment (opens in a new tab)@endif
      @if($fulfillment->tracking_company)

      {{ $fulfillment->tracking_company }} Β· {{ $fulfillment->tracking_number }}

      @endif
      @endforeach
      @endif +
      diff --git a/resources/views/storefront/cart/_discount.blade.php b/resources/views/storefront/cart/_discount.blade.php new file mode 100644 index 00000000..0b051b07 --- /dev/null +++ b/resources/views/storefront/cart/_discount.blade.php @@ -0,0 +1,9 @@ +
      + @if ($discountCode !== '' && ($discountAmount > 0 || $freeShippingDiscount)) +
      {{ $discountCode }} {{ $freeShippingDiscount ? '(Free shipping)' : '' }}
      + @else +
      + @if ($discountError)@endif + @error('discountCode')

      {{ $message }}

      @enderror + @endif +
      diff --git a/resources/views/storefront/cart/show.blade.php b/resources/views/storefront/cart/show.blade.php new file mode 100644 index 00000000..2e200915 --- /dev/null +++ b/resources/views/storefront/cart/show.blade.php @@ -0,0 +1,30 @@ +
      + +

      Your Cart

      + + @if (! $this->cart || $this->cart->lines->isEmpty()) +

      Your cart is empty

      Find something you love and it will appear here.

      Continue shopping
      + @else +
      +
      + @foreach ($this->cart->lines as $line) + @php($media = optional($line->variant->product->media->first())->storage_key) + + @endforeach +
      + + +
      + @endif +
      diff --git a/resources/views/storefront/checkout/_address-fields.blade.php b/resources/views/storefront/checkout/_address-fields.blade.php new file mode 100644 index 00000000..abb3177a --- /dev/null +++ b/resources/views/storefront/checkout/_address-fields.blade.php @@ -0,0 +1,50 @@ +@php($idPrefix = str_replace('.', '-', $prefix)) +
      +
      + + + @error($prefix.'.first_name')

      {{ $message }}

      @enderror +
      +
      + + + @error($prefix.'.last_name')

      {{ $message }}

      @enderror +
      +
      + + +
      +
      + + + @error($prefix.'.address1')

      {{ $message }}

      @enderror +
      +
      + + +
      +
      + + + @error($prefix.'.postal_code')

      {{ $message }}

      @enderror +
      +
      + + + @error($prefix.'.city')

      {{ $message }}

      @enderror +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      diff --git a/resources/views/storefront/checkout/_summary.blade.php b/resources/views/storefront/checkout/_summary.blade.php new file mode 100644 index 00000000..10446848 --- /dev/null +++ b/resources/views/storefront/checkout/_summary.blade.php @@ -0,0 +1,17 @@ +
      +

      Order Summary

      +
      + @foreach ($checkout->cart->lines as $line) + @php($media = optional($line->variant->product->media->first())->storage_key) +
      @if($media)@endif{{ $line->quantity }}

      {{ $line->variant->product->title }}

      {{ $line->variant->optionValues->pluck('value')->join(' / ') }}

      + @endforeach +
      +
      + @if ($discountCode !== '' && $checkout->discount_code) +
      {{ $discountCode }}
      + @else +
      + @if($discountError)@endif + @endif +
      Subtotal
      @if($this->totals['discount'] > 0)
      Discount
      -
      @endif
      Shipping
      @if($step < 4)Calculated next @elseif($this->totals['shipping'] === 0)Free @else@endif
      Tax
      Total
      +
      diff --git a/resources/views/storefront/checkout/confirmation.blade.php b/resources/views/storefront/checkout/confirmation.blade.php new file mode 100644 index 00000000..580d48fb --- /dev/null +++ b/resources/views/storefront/checkout/confirmation.blade.php @@ -0,0 +1,16 @@ +
      +

      Order {{ $order->order_number }}

      Thank you for your order!

      We’ve sent a confirmation to {{ $order->email }}. We’ll let you know when your order is on its way.

      + + @if (($order->payment_method instanceof \BackedEnum ? $order->payment_method->value : $order->payment_method) === 'bank_transfer') +

      Bank Transfer Instructions

      Please transfer the total amount to the following account:

      Bank
      Mock Bank AG
      IBAN
      DE89 3704 0044 0532 0130 00
      BIC
      COBADEFFXXX
      Amount
      Reference
      {{ $order->order_number }}

      Please complete your transfer within 7 days. Your order will be processed once payment is confirmed.

      + @endif + +

      Order Summary

      @foreach($order->lines as $line)
      @php($media = optional(optional($line->product)->media->first())->storage_key)@if($media)@endif

      {{ $line->title_snapshot }}

      Quantity: {{ $line->quantity }}

      @endforeach
      + + @php($shippingAddress = is_string($order->shipping_address_json) ? json_decode($order->shipping_address_json, true) : (array) $order->shipping_address_json) +

      Shipping Address

      {{ data_get($shippingAddress, 'first_name') }} {{ data_get($shippingAddress, 'last_name') }}
      {{ data_get($shippingAddress, 'address1') }}
      {{ data_get($shippingAddress, 'zip', data_get($shippingAddress, 'postal_code')) }} {{ data_get($shippingAddress, 'city') }}
      {{ data_get($shippingAddress, 'country_code', data_get($shippingAddress, 'country')) }}

      Payment Method

      {{ str_replace('_', ' ', $order->payment_method instanceof \BackedEnum ? $order->payment_method->value : $order->payment_method) }}

      + +
      Subtotal
      @if($order->discount_amount > 0)
      Discount
      -
      @endif
      Shipping
      Tax
      Total
      + +
      Continue shopping@auth('customer')View order@endauth
      +
      diff --git a/resources/views/storefront/checkout/show.blade.php b/resources/views/storefront/checkout/show.blade.php new file mode 100644 index 00000000..82286114 --- /dev/null +++ b/resources/views/storefront/checkout/show.blade.php @@ -0,0 +1,104 @@ +
      +
      + {{ $currentStore->name }} + Secure checkout +
      + + +
      @include('storefront.checkout._summary')
      + +
      +
      +

      Checkout

      + +
      +

      Step 1

      Contact information

      @if($step > 1)@endif
      + @if ($step === 1) +
      + + + @error('email')

      {{ $message }}

      @enderror + @guest('customer')

      Already have an account? Log in

      @endguest + +
      + @elseif ($step > 1) +

      {{ $email }}

      + @else +

      Enter your contact details to continue.

      + @endif +
      + +
      +

      Step 2

      Shipping address

      @if($step > 2)@endif
      + @if ($step === 2) +
      + @if ($this->savedAddresses->isNotEmpty()) +
      + @endif + @include('storefront.checkout._address-fields', ['prefix' => 'shipping']) + + @if (! $billingSameAsShipping)
      Billing address@include('storefront.checkout._address-fields', ['prefix' => 'billing'])
      @endif + +
      + @elseif ($step > 2) +
      {{ $shipping['first_name'] }} {{ $shipping['last_name'] }}
      {{ $shipping['address1'] }}@if($shipping['address2']), {{ $shipping['address2'] }}@endif
      {{ $shipping['postal_code'] }} {{ $shipping['city'] }}, {{ $shipping['country'] }}
      + @else

      Available after contact information.

      @endif +
      + +
      +

      Step 3

      Shipping method

      @if($step > 3)@endif
      + @if ($step === 3) +
      +
      Choose a shipping method + @if (! $requiresShipping) +
      This order contains only digital products, so no shipping is required.
      + @else + @forelse ($shippingRates as $rate) + + @empty + + @endforelse + @endif +
      + @error('shippingRateId')@enderror + @if($shippingRates !== [] || ! $requiresShipping)@endif +
      + @elseif ($step > 3) + @php($chosenRate = collect($shippingRates)->firstWhere('id', $shippingRateId)) +

      {{ data_get($chosenRate, 'name', 'Selected shipping method') }}

      + @else

      Available after your address.

      @endif +
      + +
      +

      Step 4

      Payment method

      + @if ($step === 4) +
      +
      Select a payment method
      + @foreach (['credit_card' => 'Credit Card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank Transfer'] as $value => $label) + + @endforeach +
      + + @if ($paymentMethod === 'credit_card') +
      +
      @error('cardNumber')

      {{ $message }}

      @enderror
      +
      @error('cardholderName')

      {{ $message }}

      @enderror
      +
      @error('cardExpiry')

      {{ $message }}

      @enderror
      +
      @error('cardCvc')

      {{ $message }}

      @enderror
      +
      + @elseif ($paymentMethod === 'paypal') +
      Your PayPal payment will be processed securely by the onsite mock payment provider.
      + @else +
      After placing your order, you will receive bank transfer instructions. We will hold your order for 7 days while awaiting payment.
      + @endif + + @if ($paymentError)@endif + +
      + @else

      Available after shipping.

      @endif +
      +
      + + +
      +
      diff --git a/resources/views/storefront/collections/_filters.blade.php b/resources/views/storefront/collections/_filters.blade.php new file mode 100644 index 00000000..1f36895d --- /dev/null +++ b/resources/views/storefront/collections/_filters.blade.php @@ -0,0 +1,20 @@ +
      +

      Filters

      @if ($this->hasActiveFilters)@endif
      +
      + Availability + +
      +
      + Price range +
      + + +
      +
      + @if ($this->productTypes !== []) +
      Product type
      @foreach ($this->productTypes as $type)@endforeach
      + @endif + @if ($this->availableVendors !== []) +
      Vendor
      @foreach ($this->availableVendors as $vendor)@endforeach
      + @endif +
      diff --git a/resources/views/storefront/collections/index.blade.php b/resources/views/storefront/collections/index.blade.php new file mode 100644 index 00000000..f7aa3415 --- /dev/null +++ b/resources/views/storefront/collections/index.blade.php @@ -0,0 +1,38 @@ +
      + +
      +

      Browse by story

      +

      Collections

      +

      Explore pieces grouped to make finding your next favorite easy.

      +
      + + @if ($collections->isEmpty()) +
      + +

      Collections are coming soon

      +

      Please check back shortly.

      +
      + @else +
      + @foreach ($collections as $collection) + @php($media = optional(optional($collection->products->first())->media->first())->storage_key) + +
      + @if ($media) + {{ $collection->title }} + @else +
      {{ str($collection->title)->substr(0, 1) }}
      + @endif +
      +
      +
      +

      {{ $collection->title }}

      +

      {{ $collection->products_count }} {{ str('product')->plural($collection->products_count) }}

      +
      + +
      +
      + @endforeach +
      + @endif +
      diff --git a/resources/views/storefront/collections/show.blade.php b/resources/views/storefront/collections/show.blade.php new file mode 100644 index 00000000..20c19093 --- /dev/null +++ b/resources/views/storefront/collections/show.blade.php @@ -0,0 +1,74 @@ +
      + +
      +

      Collection

      +

      {{ $collection->title }}

      + @if ($collection->description_html) + + @endif +
      + +
      + + + +
      + + @if ($this->hasActiveFilters) +
      + @if ($inStock)@endif + @if ($minPrice !== '')@endif + @if ($maxPrice !== '')@endif + @foreach ($types as $type)@endforeach + @foreach ($vendors as $vendor)@endforeach + +
      + @endif + +
      + + + + +
      + @if ($products->isEmpty()) +
      + +

      No products found

      +

      Try adjusting your filters or browse the full collection.

      + @if ($this->hasActiveFilters)@endif +
      + @else +
      + @foreach ($products as $product) + + @endforeach +
      +
      + @endif +
      +
      +
      diff --git a/resources/views/storefront/components/cart-drawer.blade.php b/resources/views/storefront/components/cart-drawer.blade.php new file mode 100644 index 00000000..6d1da776 --- /dev/null +++ b/resources/views/storefront/components/cart-drawer.blade.php @@ -0,0 +1,40 @@ +
      + +
      diff --git a/resources/views/storefront/components/featured-products.blade.php b/resources/views/storefront/components/featured-products.blade.php new file mode 100644 index 00000000..39ff4d42 --- /dev/null +++ b/resources/views/storefront/components/featured-products.blade.php @@ -0,0 +1,7 @@ +
      + @forelse ($products as $product) + + @empty +

      Products are coming soon.

      + @endforelse +
      diff --git a/resources/views/storefront/components/newsletter-signup.blade.php b/resources/views/storefront/components/newsletter-signup.blade.php new file mode 100644 index 00000000..5f561176 --- /dev/null +++ b/resources/views/storefront/components/newsletter-signup.blade.php @@ -0,0 +1,23 @@ +
      +
      +

      A note from us

      +

      Stay in the loop

      +

      Subscribe for thoughtful product updates, useful stories, and occasional offers.

      + +
      + @if ($subscribed) +
      Thanks for subscribing!
      + @else +
      + + + +
      + @error('email')

      {{ $message }}

      @enderror + @endif +
      +
      +
      diff --git a/resources/views/storefront/components/product-grid-skeleton.blade.php b/resources/views/storefront/components/product-grid-skeleton.blade.php new file mode 100644 index 00000000..05ffdc99 --- /dev/null +++ b/resources/views/storefront/components/product-grid-skeleton.blade.php @@ -0,0 +1,9 @@ +
      + @for ($index = 0; $index < $count; $index++) +
      +
      +
      +
      +
      + @endfor +
      diff --git a/resources/views/storefront/components/search-modal.blade.php b/resources/views/storefront/components/search-modal.blade.php new file mode 100644 index 00000000..1ee62c6f --- /dev/null +++ b/resources/views/storefront/components/search-modal.blade.php @@ -0,0 +1,19 @@ +
      + +
      diff --git a/resources/views/storefront/errors/404.blade.php b/resources/views/storefront/errors/404.blade.php new file mode 100644 index 00000000..3978a303 --- /dev/null +++ b/resources/views/storefront/errors/404.blade.php @@ -0,0 +1,2 @@ +@php($store = $currentStore ?? (app()->bound('current_store') ? app('current_store') : null)) +Page not found{{ $store ? ' - '.$store->name : '' }}@vite(['resources/css/app.css','resources/js/app.js'])

      Page not found

      The page you’re looking for doesn’t exist or has moved.

      Go to home page
      diff --git a/resources/views/storefront/errors/503.blade.php b/resources/views/storefront/errors/503.blade.php new file mode 100644 index 00000000..c61ba99f --- /dev/null +++ b/resources/views/storefront/errors/503.blade.php @@ -0,0 +1,2 @@ +@php($store = $currentStore ?? (app()->bound('current_store') ? app('current_store') : null)) +We’ll be back soon{{ $store ? ' - '.$store->name : '' }}@vite(['resources/css/app.css','resources/js/app.js'])
      {{ str($store?->name ?? config('app.name'))->substr(0,1) }}

      We’ll be back soon

      We’re currently performing maintenance. Please check back shortly.

      diff --git a/resources/views/storefront/home.blade.php b/resources/views/storefront/home.blade.php new file mode 100644 index 00000000..2da800b2 --- /dev/null +++ b/resources/views/storefront/home.blade.php @@ -0,0 +1,77 @@ +
      + @foreach ((array) data_get($settings, 'home.sections', []) as $section) + @if ($section === 'hero' && data_get($settings, 'home.hero.enabled', true)) +
      + @if (data_get($settings, 'home.hero.image_url')) + + @endif +
      +
      +
      +

      {{ $currentStore->name }}

      +

      + {{ data_get($settings, 'home.hero.heading') }} +

      +

      + {{ data_get($settings, 'home.hero.subheading') }} +

      + + {{ data_get($settings, 'home.hero.cta_label', 'Shop now') }} + + +
      +
      +
      + @elseif ($section === 'featured_collections' && $collections->isNotEmpty()) +
      +
      +
      +
      +

      Curated for you

      + +
      + +
      +
      + @foreach ($collections as $collection) + @php($image = optional(optional($collection->products->first())->media->first())->storage_key) + + @if ($image) + {{ $collection->title }} + @else +
      + @endif +
      +
      +

      {{ $collection->title }}

      + Shop now +
      +
      + @endforeach +
      + View all collections +
      +
      + @elseif ($section === 'featured_products') +
      +
      +
      +
      +

      New and noteworthy

      + +
      +
      + +
      +
      + @elseif ($section === 'newsletter') + + @elseif ($section === 'rich_text' && data_get($settings, 'home.rich_text.content')) +
      +
      + +
      +
      + @endif + @endforeach +
      diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php new file mode 100644 index 00000000..40c5db5b --- /dev/null +++ b/resources/views/storefront/layouts/app.blade.php @@ -0,0 +1,100 @@ +@php + $mode = data_get($themeSettings, 'dark_mode', 'system'); + $mode = in_array($mode, ['system', 'toggle', 'light', 'dark'], true) ? $mode : 'system'; + $primary = data_get($themeSettings, 'colors.primary', '#2563eb'); + $secondary = data_get($themeSettings, 'colors.secondary', '#18181b'); + $accent = data_get($themeSettings, 'colors.accent', '#ea580c'); + $primary = preg_match('/^#[0-9a-f]{6}$/i', (string) $primary) ? $primary : '#2563eb'; + $secondary = preg_match('/^#[0-9a-f]{6}$/i', (string) $secondary) ? $secondary : '#18181b'; + $accent = preg_match('/^#[0-9a-f]{6}$/i', (string) $accent) ? $accent : '#ea580c'; + + $cartCount = 0; + $cartId = session('cart_id'); + if ($cartId && class_exists(\App\Models\Cart::class)) { + $cartCount = (int) \App\Models\CartLine::query()->where('cart_id', $cartId)->sum('quantity'); + } elseif ($storefrontCustomer && class_exists(\App\Models\Cart::class)) { + $cart = \App\Models\Cart::withoutGlobalScopes()->where('store_id', $currentStore->id)->where('customer_id', $storefrontCustomer->getAuthIdentifier())->where('status', 'active')->latest('id')->first(); + $cartCount = $cart ? (int) $cart->lines()->sum('quantity') : 0; + } + + app()->setLocale((string) ($currentStore->default_locale ?: config('app.locale'))); + + $organizationJson = json_encode([ + '@context' => 'https://schema.org', + '@type' => 'Organization', + 'name' => $currentStore->name, + 'url' => url('/'), + ], JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); +@endphp + + + @if (data_get($themeSettings, 'announcement.enabled') && data_get($themeSettings, 'announcement.text')) + +
      + @if(data_get($themeSettings, 'announcement.url')){{ data_get($themeSettings, 'announcement.text') }}@else{{ data_get($themeSettings, 'announcement.text') }}@endif + +
      +
      + @endif + + +
      + + + +
      +
      + + {{ $slot }} + + +
      +
      +
      +

      {{ $currentStore->name }}

      {{ data_get($themeSettings, 'footer.description', 'Quality products and thoughtful service, all in one place.') }}

      + @if($footerNavigation !== [])

      Explore

      @endif + +

      Store

      Secure checkout
      Mock payments for demonstration
      {{ $currentStore->default_currency }} pricing

      +
      +

      © {{ now()->year }} {{ $currentStore->name }}. All rights reserved.

      VisaMastercardPayPal
      +
      + +
      +
      + + + + +
      + +
      +
      diff --git a/resources/views/storefront/pages/show.blade.php b/resources/views/storefront/pages/show.blade.php new file mode 100644 index 00000000..7e5acea4 --- /dev/null +++ b/resources/views/storefront/pages/show.blade.php @@ -0,0 +1,5 @@ +
      + +

      {{ $currentStore->name }}

      {{ $page->title }}

      +
      +
      diff --git a/resources/views/storefront/products/show.blade.php b/resources/views/storefront/products/show.blade.php new file mode 100644 index 00000000..41de19e3 --- /dev/null +++ b/resources/views/storefront/products/show.blade.php @@ -0,0 +1,134 @@ +@php + $seoVariant = $this->selectedVariant ?: $product->variants->first(); + $seoMedia = $product->media->first(); + $seoImage = $seoMedia ? Storage::disk('public')->url($seoMedia->storage_key) : null; + $seoDescription = str(strip_tags((string) $product->description_html))->squish()->limit(160)->toString(); + $productStructuredData = json_encode(array_filter([ + '@context' => 'https://schema.org', + '@type' => 'Product', + 'name' => $product->title, + 'description' => $seoDescription, + 'image' => $seoImage, + 'sku' => $seoVariant?->sku, + 'offers' => $seoVariant ? [ + '@type' => 'Offer', + 'priceCurrency' => $seoVariant->currency, + 'price' => number_format($seoVariant->price_amount / 100, 2, '.', ''), + 'availability' => $this->canAddToCart ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', + 'url' => url('/products/'.$product->handle), + ] : null, + ]), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); +@endphp +@push('head') + + + + + @if($seoImage)@endif + @if($seoVariant)@endif + +@endpush +
      + + +
      +
      + @if ($product->media->isEmpty()) +
      + +
      + @else + +
      + @foreach ($product->media as $index => $media) + {{ $media->alt_text ?: $product->title.' image '.($index + 1) }} + @endforeach +
      + @endif +
      + +
      + @if ($product->vendor)

      {{ $product->vendor }}

      @endif +

      {{ $product->title }}

      + +
      + @if ($this->selectedVariant) + + @else + Choose options to see price + @endif +
      + + @error('variant')@enderror + +
      + @foreach ($product->options as $option) +
      + {{ $option->name }}: {{ $selectedOptions[$option->name] ?? '' }} + @if ($option->values->count() <= 6) +
      + @foreach ($option->values as $value) + + @endforeach +
      + @else + + @endif +
      + @endforeach +
      + + @php($inventory = $this->selectedVariant?->inventoryItem) +
      + @if ($this->selectedVariant && $inventory) + @if ($this->availableQuantity === null && (int) $inventory->quantity_on_hand <= 0) + Available on backorder + @elseif ($this->availableQuantity === 0) + Out of stock + @elseif ($this->availableQuantity !== null && $this->availableQuantity <= 10) + Only {{ $this->availableQuantity }} left in stock + @else + In stock + @endif + @endif +
      + +
      + + +
      + + @if ($product->description_html) +
      + @endif + + @php($tags = is_string($product->tags) ? json_decode($product->tags, true) : $product->tags) + @if (is_array($tags) && $tags !== []) +
      @foreach ($tags as $tag){{ $tag }}@endforeach
      + @endif +
      +
      +
      diff --git a/resources/views/storefront/search/_filters.blade.php b/resources/views/storefront/search/_filters.blade.php new file mode 100644 index 00000000..05883b5d --- /dev/null +++ b/resources/views/storefront/search/_filters.blade.php @@ -0,0 +1 @@ +

      Filters

      @if($this->hasActiveFilters)@endif
      Availability
      Price range
      @if($this->productTypes !== [])
      Product type
      @foreach($this->productTypes as $type)@endforeach
      @endif @if($this->availableVendors !== [])
      Vendor
      @foreach($this->availableVendors as $vendor)@endforeach
      @endif
      diff --git a/resources/views/storefront/search/index.blade.php b/resources/views/storefront/search/index.blade.php new file mode 100644 index 00000000..e9f438ce --- /dev/null +++ b/resources/views/storefront/search/index.blade.php @@ -0,0 +1,23 @@ +
      + +
      +

      {{ $query !== '' ? $results->total().' results for β€œ'.$query.'”' : 'Search products' }}

      +
      + @error('query')

      {{ $message }}

      @enderror +
      + + @if ($query !== '') +
      +
      + + +
      + @if ($results->isEmpty()) +

      No results found for β€œ{{ $query }}”

      Try a different search term or clear your filters.

      @if($this->hasActiveFilters)@endif
      + @else +
      @foreach($results as $product)@endforeach
      + @endif +
      +
      + @endif +
      diff --git a/routes/web.php b/routes/web.php index f755f111..167a997a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,58 @@ name('home'); +Route::middleware('storefront')->group(function (): void { + Route::get('/', Home::class)->name('storefront.home'); + Route::get('/collections', Collections::class)->name('storefront.collections.index'); + Route::get('/collections/{handle}', Collection::class)->name('storefront.collections.show'); + Route::get('/products/{handle}', Product::class)->name('storefront.products.show'); + Route::get('/cart', Cart::class)->name('storefront.cart'); + Route::get('/search', Search::class)->name('storefront.search'); + Route::get('/pages/{handle}', Page::class)->name('storefront.pages.show'); + + Route::get('/account/login', CustomerLogin::class)->name('customer.login'); + Route::get('/account/register', CustomerRegister::class)->name('customer.register'); + Route::get('/forgot-password', CustomerForgotPassword::class)->name('customer.password.request'); + Route::get('/reset-password/{token}', CustomerResetPassword::class)->name('customer.password.reset'); + + Route::middleware('customer.auth')->group(function (): void { + Route::get('/account', AccountDashboard::class)->name('customer.dashboard'); + Route::get('/account/orders', AccountOrders::class)->name('customer.orders.index'); + Route::get('/account/orders/{orderNumber}', AccountOrder::class)->name('customer.orders.show'); + Route::get('/account/addresses', AccountAddresses::class)->name('customer.addresses'); + Route::post('/account/logout', function () { + Auth::guard('customer')->logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect('/account/login'); + })->name('customer.logout'); + }); + + Route::get('/checkout/{checkoutId}', Checkout::class)->middleware('throttle:checkout')->name('storefront.checkout'); + Route::get('/checkout/{checkoutId}/confirmation', Confirmation::class)->name('storefront.checkout.confirmation'); +}); + +Route::redirect('/home', '/')->name('home'); Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) diff --git a/specs/progress.md b/specs/progress.md index fa0c6eb7..1040b097 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -13,8 +13,8 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro | 0. Specification traceability and repository audit | Complete | Specs and starter audited by backend, frontend, and QA roles | | 1. Foundation, tenancy, authentication, authorization | Complete | All migrations pass; tenancy/auth infrastructure and factories are present | | 2. Catalog, inventory, collections, media | In progress | Schema/models/factories/demo catalog complete; behavior tests pending | -| 3. Themes, CMS, navigation, storefront shell | Pending | Pest feature + Chrome | -| 4. Cart, checkout, discounts, shipping, taxes | Pending | Pest unit/feature + Chrome | +| 3. Themes, CMS, navigation, storefront shell | Complete | Blade compilation, Vite build, and live Herd render smoke pass | +| 4. Cart, checkout, discounts, shipping, taxes | In progress | Customer UI and services integrated; full Pest/Chrome flow pending | | 5. Payments, orders, refunds, fulfillment | Pending | Pest unit/feature + Chrome | | 6. Customer accounts | Pending | Pest feature + Chrome | | 7. Admin panel | Pending | Pest feature + Chrome | @@ -42,6 +42,15 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro - Added `shop.test` as an explicit Acme Fashion storefront domain while retaining the specification's demo domains. - Verified a fresh migrate-and-seed and a second idempotent seed execution. +### Iteration 2 - Complete customer storefront + +- Added the responsive/dark tenant storefront shell, theme tokens, accessible navigation, announcement, footer, toast region, cart drawer, and keyboard-friendly search modal. +- Added home sections, collection browsing/filtering/sorting, product variants and inventory states, cart quantity/discount behavior, checkout stepper, all mock payment methods, and confirmation. +- Added full customer authentication, tenant-aware password reset storage, account dashboard, order history/detail, and address CRUD. +- Added CMS pages, SEO/JSON-LD metadata, reusable storefront components, currency formatting, placeholders, and themed 404/503 pages. +- Registered the complete storefront/customer/checkout web route surface. +- Verified all Blade views compile, the Tailwind/Vite production build succeeds, and live Herd requests to `shop.test/` and the seeded product detail return 200 with correct tenant/product content. + ## Verification Ledger | Check | Latest Result | @@ -49,7 +58,7 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro | Working tree at start | Clean | | Existing shop code reused | No | | Pest suite | Baseline not yet run against integrated shop | -| Vite production build | Not run yet | +| Vite production build | Passed | | Fresh migrate and seed | Passed (all migrations and all 18 seed stages) | | Laravel code-quality checker | Not installed yet | | Chrome acceptance review | Not run yet | From 06773b029a4a87b3e51b599ddc07f24f500b7a09 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 22:52:06 +0200 Subject: [PATCH 05/10] feat: harden shop workflows and API contracts --- .env.example | 5 +- .../Api/Admin/AnalyticsController.php | 62 ++++++-- .../Controllers/Api/Admin/MediaController.php | 66 ++++++++ .../Controllers/Api/Admin/OrderController.php | 35 ++++- .../Api/Admin/PlatformController.php | 23 ++- .../Api/Storefront/CartController.php | 36 ++++- .../Api/Storefront/CheckoutController.php | 36 ++++- .../Requests/CreateFulfillmentRequest.php | 18 +++ app/Http/Requests/CreateRefundRequest.php | 18 +++ app/Http/Requests/RegisterCustomerRequest.php | 18 +++ .../Requests/SetCheckoutAddressRequest.php | 18 +++ .../Requests/SetCheckoutShippingRequest.php | 18 +++ app/Http/Requests/StoreCollectionRequest.php | 18 +++ .../Requests/StoreCustomerAddressRequest.php | 18 +++ app/Http/Requests/StoreDiscountRequest.php | 18 +++ app/Http/Requests/StorePageRequest.php | 18 +++ app/Http/Requests/StoreProductRequest.php | 19 +++ .../Requests/StoreShippingRateRequest.php | 18 +++ .../Requests/StoreShippingZoneRequest.php | 18 +++ app/Http/Requests/UpdateCollectionRequest.php | 11 ++ .../Requests/UpdateCustomerAddressRequest.php | 11 ++ app/Http/Requests/UpdateDiscountRequest.php | 11 ++ app/Http/Requests/UpdatePageRequest.php | 11 ++ app/Http/Requests/UpdateProductRequest.php | 11 ++ .../Requests/UpdateTaxSettingsRequest.php | 18 +++ app/Jobs/CancelUnpaidBankTransferOrders.php | 1 + app/Jobs/CleanupAbandonedCarts.php | 4 +- app/Jobs/ExpireAbandonedCheckouts.php | 6 +- app/Jobs/GenerateOrderExport.php | 86 ++++++++++ app/Jobs/ProcessMediaUpload.php | 67 ++++++-- app/Listeners/ShopEventSubscriber.php | 147 ++++++++++++++++++ app/Models/OrderExport.php | 27 ++++ app/Models/ProductVariant.php | 39 +++++ app/Models/Refund.php | 6 + app/Models/RefundLine.php | 28 ++++ .../OrderLifecycleNotification.php | 64 ++++++++ app/Observers/AuditableObserver.php | 102 ++++++++++++ app/Observers/ProductMediaObserver.php | 16 ++ app/Policies/CollectionPolicy.php | 29 +++- app/Policies/Concerns/ChecksStoreRoles.php | 8 +- app/Policies/CustomerPolicy.php | 17 +- app/Policies/DiscountPolicy.php | 29 +++- app/Policies/FulfillmentPolicy.php | 16 +- app/Policies/NavigationMenuPolicy.php | 21 +++ app/Policies/OrderPolicy.php | 45 +++++- app/Policies/PagePolicy.php | 29 +++- app/Policies/ProductPolicy.php | 39 ++++- app/Policies/RefundPolicy.php | 11 +- app/Policies/StorePolicy.php | 33 +++- app/Policies/ThemePolicy.php | 34 +++- app/Providers/AppServiceProvider.php | 118 +++++++++++++- app/Services/AuditLogger.php | 33 ++++ app/Services/CartService.php | 9 +- app/Services/CheckoutService.php | 10 +- app/Services/CustomerService.php | 14 +- app/Services/OrderService.php | 13 +- app/Services/ProductService.php | 41 ++++- app/Services/RefundService.php | 52 ++++++- app/Services/VariantMatrixService.php | 27 ++-- app/Traits/ChecksStoreRole.php | 40 +++++ bootstrap/app.php | 15 +- config/cache.php | 2 +- config/logging.php | 18 +++ config/queue.php | 2 +- config/sanctum.php | 22 +++ config/session.php | 2 +- config/shop.php | 11 ++ ...1_01_000800_create_order_exports_table.php | 31 ++++ ..._000810_enforce_store_owner_uniqueness.php | 17 ++ ...01_01_000820_create_refund_lines_table.php | 26 ++++ database/seeders/ProductSeeder.php | 17 +- routes/api.php | 25 ++- routes/console.php | 10 ++ routes/web.php | 8 + specs/progress.md | 26 +++- 75 files changed, 1887 insertions(+), 159 deletions(-) create mode 100644 app/Http/Controllers/Api/Admin/MediaController.php create mode 100644 app/Http/Requests/CreateFulfillmentRequest.php create mode 100644 app/Http/Requests/CreateRefundRequest.php create mode 100644 app/Http/Requests/RegisterCustomerRequest.php create mode 100644 app/Http/Requests/SetCheckoutAddressRequest.php create mode 100644 app/Http/Requests/SetCheckoutShippingRequest.php create mode 100644 app/Http/Requests/StoreCollectionRequest.php create mode 100644 app/Http/Requests/StoreCustomerAddressRequest.php create mode 100644 app/Http/Requests/StoreDiscountRequest.php create mode 100644 app/Http/Requests/StorePageRequest.php create mode 100644 app/Http/Requests/StoreProductRequest.php create mode 100644 app/Http/Requests/StoreShippingRateRequest.php create mode 100644 app/Http/Requests/StoreShippingZoneRequest.php create mode 100644 app/Http/Requests/UpdateCollectionRequest.php create mode 100644 app/Http/Requests/UpdateCustomerAddressRequest.php create mode 100644 app/Http/Requests/UpdateDiscountRequest.php create mode 100644 app/Http/Requests/UpdatePageRequest.php create mode 100644 app/Http/Requests/UpdateProductRequest.php create mode 100644 app/Http/Requests/UpdateTaxSettingsRequest.php create mode 100644 app/Jobs/GenerateOrderExport.php create mode 100644 app/Listeners/ShopEventSubscriber.php create mode 100644 app/Models/OrderExport.php create mode 100644 app/Models/RefundLine.php create mode 100644 app/Notifications/OrderLifecycleNotification.php create mode 100644 app/Observers/AuditableObserver.php create mode 100644 app/Observers/ProductMediaObserver.php create mode 100644 app/Policies/NavigationMenuPolicy.php create mode 100644 app/Services/AuditLogger.php create mode 100644 app/Traits/ChecksStoreRole.php create mode 100644 config/sanctum.php create mode 100644 config/shop.php create mode 100644 database/migrations/2026_01_01_000800_create_order_exports_table.php create mode 100644 database/migrations/2026_01_01_000810_enforce_store_owner_uniqueness.php create mode 100644 database/migrations/2026_01_01_000820_create_refund_lines_table.php diff --git a/.env.example b/.env.example index e2f54286..88a618c0 100644 --- a/.env.example +++ b/.env.example @@ -34,9 +34,12 @@ SESSION_PATH=/ SESSION_DOMAIN=null BROADCAST_CONNECTION=log -FILESYSTEM_DISK=public +FILESYSTEM_DISK=local QUEUE_CONNECTION=sync +SANCTUM_TOKEN_PREFIX=shop_ +SANCTUM_EXPIRATION=525600 + CACHE_STORE=file # CACHE_PREFIX= diff --git a/app/Http/Controllers/Api/Admin/AnalyticsController.php b/app/Http/Controllers/Api/Admin/AnalyticsController.php index c1876163..e3130c82 100644 --- a/app/Http/Controllers/Api/Admin/AnalyticsController.php +++ b/app/Http/Controllers/Api/Admin/AnalyticsController.php @@ -3,10 +3,13 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Controller; -use App\Models\Order; +use App\Jobs\GenerateOrderExport; +use App\Models\OrderExport; use App\Services\AnalyticsService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\URL; use Symfony\Component\HttpFoundation\StreamedResponse; final class AnalyticsController extends Controller @@ -26,15 +29,54 @@ public function summary(Request $request, int $storeId): JsonResponse ]]); } - public function exportOrders(int $storeId): StreamedResponse + public function exportOrders(Request $request, int $storeId): JsonResponse { - return response()->streamDownload(function () use ($storeId): void { - $out = fopen('php://output', 'wb'); - fputcsv($out, ['order_number', 'created_at', 'status', 'financial_status', 'fulfillment_status', 'customer_email', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'currency', 'tracking_number']); - Order::withoutGlobalScopes()->where('store_id', $storeId)->with('fulfillments')->orderBy('id')->each(function (Order $order) use ($out): void { - fputcsv($out, [$order->order_number, $order->created_at?->toIso8601String(), $order->status, $order->financial_status, $order->fulfillment_status, $order->email, $order->subtotal_amount, $order->discount_amount, $order->shipping_amount, $order->tax_amount, $order->total_amount, $order->currency, $order->fulfillments->first()?->tracking_number]); - }); - fclose($out); - }, 'orders-'.now()->toDateString().'.csv', ['Content-Type' => 'text/csv']); + $data = $request->validate([ + 'format' => ['sometimes', 'in:csv'], + 'filters' => ['sometimes', 'array'], + 'filters.status' => ['sometimes', 'in:pending,paid,fulfilled,cancelled,refunded'], + 'filters.financial_status' => ['sometimes', 'string'], + 'filters.created_after' => ['sometimes', 'date'], + 'filters.created_before' => ['sometimes', 'date', 'after_or_equal:filters.created_after'], + ]); + $export = OrderExport::withoutGlobalScopes()->create([ + 'store_id' => $storeId, + 'user_id' => $request->user()?->id, + 'format' => $data['format'] ?? 'csv', + 'filters_json' => $data['filters'] ?? [], + 'status' => 'queued', + ]); + GenerateOrderExport::dispatch($export); + + return response()->json([ + 'export_id' => $export->id, + 'status' => 'queued', + 'created_at' => $export->created_at?->toIso8601String(), + ], 202); + } + + public function export(Request $request, int $storeId, int $exportId): JsonResponse|StreamedResponse + { + $export = OrderExport::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($exportId); + if ($request->boolean('download')) { + abort_unless($export->status === 'completed' && $export->storage_key !== null, 409, 'The export is not ready.'); + + return Storage::disk('local')->download($export->storage_key, "orders-{$export->id}.csv", ['Content-Type' => 'text/csv']); + } + + $expires = now()->addHour(); + + return response()->json(['data' => [ + 'id' => $export->id, + 'status' => $export->status, + 'format' => $export->format, + 'row_count' => $export->row_count, + 'download_url' => $export->status === 'completed' + ? URL::temporarySignedRoute('api.admin.exports.show', $expires, ['storeId' => $storeId, 'exportId' => $export->id, 'download' => 1]) + : null, + 'download_expires_at' => $export->status === 'completed' ? $expires->toIso8601String() : null, + 'created_at' => $export->created_at?->toIso8601String(), + 'completed_at' => $export->completed_at?->toIso8601String(), + ]]); } } diff --git a/app/Http/Controllers/Api/Admin/MediaController.php b/app/Http/Controllers/Api/Admin/MediaController.php new file mode 100644 index 00000000..278b5255 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/MediaController.php @@ -0,0 +1,66 @@ +validate([ + 'filename' => ['required', 'string', 'max:255', 'regex:/\.(jpe?g|png|webp|avif|mp4)$/i'], + 'content_type' => ['required', 'in:image/jpeg,image/png,image/webp,image/avif,video/mp4'], + 'byte_size' => ['required', 'integer', 'min:1'], + ]); + $maximum = $data['content_type'] === 'video/mp4' ? 500 * 1024 * 1024 : 50 * 1024 * 1024; + abort_if((int) $data['byte_size'] > $maximum, 422, 'The uploaded file is too large.'); + + $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId); + $extension = mb_strtolower((string) pathinfo($data['filename'], PATHINFO_EXTENSION)); + $storageKey = "stores/{$storeId}/products/{$product->id}/media/".Str::uuid().".{$extension}"; + $media = $product->media()->create([ + 'type' => $data['content_type'] === 'video/mp4' ? 'video' : 'image', + 'storage_key' => $storageKey, + 'mime_type' => $data['content_type'], + 'byte_size' => $data['byte_size'], + 'position' => ((int) $product->media()->max('position')) + 1, + 'status' => 'processing', + ]); + $expires = now()->addMinutes(10); + + return response()->json([ + 'upload_url' => URL::temporarySignedRoute('api.media.upload', $expires, ['media' => $media->id]), + 'method' => 'PUT', + 'headers' => ['Content-Type' => $data['content_type']], + 'storage_key' => $storageKey, + 'media_id' => $media->id, + 'expires_at' => $expires->toIso8601String(), + ], 201); + } + + public function upload(Request $request, ProductMedia $media): JsonResponse + { + abort_unless($request->hasValidSignature(), 403, 'The upload URL is invalid or expired.'); + $contents = $request->getContent(); + abort_if($contents === '', 422, 'The upload body is empty.'); + abort_if(strlen($contents) > (int) $media->byte_size, 422, 'The upload exceeds the declared size.'); + + Storage::disk('public')->put($media->storage_key, $contents); + if (($media->type instanceof \BackedEnum ? $media->type->value : $media->type) === 'video') { + $media->update(['status' => 'ready', 'byte_size' => strlen($contents)]); + } else { + ProcessMediaUpload::dispatch($media); + } + + return response()->json(['data' => $media->refresh()], 202); + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php index 77c56d06..8f8cf3fc 100644 --- a/app/Http/Controllers/Api/Admin/OrderController.php +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -45,23 +45,46 @@ public function show(int $storeId, int $orderId): JsonResponse public function fulfill(Request $request, int $storeId, int $orderId): JsonResponse { $data = $request->validate([ - 'lines' => ['required', 'array', 'min:1'], - 'lines.*' => ['required', 'integer', 'min:1'], + 'line_items' => ['required_without:lines', 'array', 'min:1'], + 'line_items.*.order_line_id' => ['required_with:line_items', 'integer'], + 'line_items.*.quantity' => ['required_with:line_items', 'integer', 'min:1'], + 'lines' => ['required_without:line_items', 'array', 'min:1'], + 'lines.*' => ['required_with:lines', 'integer', 'min:1'], 'tracking_company' => ['nullable', 'string'], 'tracking_number' => ['nullable', 'string'], 'tracking_url' => ['nullable', 'url'], + 'notify_customer' => ['sometimes', 'boolean'], ]); $tracking = collect($data)->only(['tracking_company', 'tracking_number', 'tracking_url'])->all(); - $fulfillment = $this->fulfillments->create($this->find($storeId, $orderId), $data['lines'], $tracking); + $lines = isset($data['line_items']) + ? collect($data['line_items'])->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']])->all() + : $data['lines']; + $fulfillment = $this->fulfillments->create($this->find($storeId, $orderId), $lines, $tracking); + $this->fulfillments->markAsShipped($fulfillment, $tracking); - return response()->json(['data' => $fulfillment], 201); + return response()->json(['data' => $fulfillment->refresh()->load('lines')], 201); } public function refund(Request $request, int $storeId, int $orderId): JsonResponse { - $data = $request->validate(['amount' => ['required', 'integer', 'min:1'], 'reason' => ['nullable', 'string'], 'restock' => ['sometimes', 'boolean'], 'lines' => ['sometimes', 'array']]); + $data = $request->validate([ + 'amount' => ['required_without_all:lines,line_items', 'nullable', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', 'max:1000'], + 'restock' => ['sometimes', 'boolean'], + 'lines' => ['sometimes', 'array'], + 'lines.*' => ['integer', 'min:1'], + 'line_items' => ['sometimes', 'array'], + 'line_items.*.order_line_id' => ['required_with:line_items', 'integer'], + 'line_items.*.quantity' => ['required_with:line_items', 'integer', 'min:1'], + 'notify_customer' => ['sometimes', 'boolean'], + ]); $order = $this->find($storeId, $orderId)->load('payments'); - $refund = $this->refunds->create($order, $order->payments->firstOrFail(), $data['amount'], $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? null); + $lines = isset($data['line_items']) + ? collect($data['line_items'])->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']])->all() + : ($data['lines'] ?? null); + $payment = $order->payments->first(fn ($payment) => ($payment->status instanceof \BackedEnum ? $payment->status->value : $payment->status) === 'captured') + ?? $order->payments->firstOrFail(); + $refund = $this->refunds->create($order, $payment, $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $lines); return response()->json(['data' => $refund], 201); } diff --git a/app/Http/Controllers/Api/Admin/PlatformController.php b/app/Http/Controllers/Api/Admin/PlatformController.php index 0a1ad972..f08f3608 100644 --- a/app/Http/Controllers/Api/Admin/PlatformController.php +++ b/app/Http/Controllers/Api/Admin/PlatformController.php @@ -9,9 +9,9 @@ use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\DB; -use Illuminate\Validation\Rule; +use Illuminate\Support\Facades\Hash; +use Illuminate\Validation\ValidationException; final class PlatformController extends Controller { @@ -29,8 +29,18 @@ public function store(Request $request): JsonResponse 'handle' => ['required', 'regex:/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/', 'max:63', 'unique:stores,handle'], 'default_currency' => ['required', 'string', 'size:3'], 'default_locale' => ['required', 'string', 'max:10'], 'timezone' => ['required', 'timezone'], ]); - $store = Store::query()->create([...$data, 'status' => 'active']); - StoreSettings::query()->create(['store_id' => $store->id, 'settings_json' => []]); + $store = DB::transaction(function () use ($data, $request): Store { + $store = Store::query()->create([...$data, 'status' => 'active']); + StoreSettings::query()->create(['store_id' => $store->id, 'settings_json' => []]); + DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $request->user()->id, + 'role' => 'owner', + 'created_at' => now(), + ]); + + return $store; + }); return response()->json(['data' => $store], 201); } @@ -38,6 +48,11 @@ public function store(Request $request): JsonResponse public function invite(Request $request, int $storeId): JsonResponse { $data = $request->validate(['email' => ['required', 'email'], 'role' => ['required', 'in:owner,admin,staff,support']]); + $actorRole = $request->user()->roleForStore(app('current_store'))?->value; + abort_unless(in_array($actorRole, ['owner', 'admin'], true), 403); + if ($data['role'] === 'owner' && DB::table('store_users')->where('store_id', $storeId)->where('role', 'owner')->exists()) { + throw ValidationException::withMessages(['role' => 'Transfer ownership before assigning a new owner.']); + } $user = User::query()->firstOrCreate(['email' => mb_strtolower($data['email'])], ['name' => str($data['email'])->before('@')->headline(), 'password_hash' => Hash::make(str()->password()), 'status' => 'active']); if ($user->stores()->whereKey($storeId)->exists()) { return response()->json(['message' => 'User is already a member.'], 409); diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php index 432e97ee..c7175995 100644 --- a/app/Http/Controllers/Api/Storefront/CartController.php +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -3,11 +3,15 @@ namespace App\Http\Controllers\Api\Storefront; use App\Exceptions\CartVersionMismatchException; +use App\Exceptions\DomainException; +use App\Exceptions\InsufficientInventoryException; use App\Http\Controllers\Controller; use App\Models\Cart; +use App\Models\ProductVariant; use App\Services\CartService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; final class CartController extends Controller { @@ -32,42 +36,62 @@ public function addLine(Request $request, int $cartId): JsonResponse { $validated = $request->validate([ 'variant_id' => ['required', 'integer'], - 'quantity' => ['required', 'integer', 'min:1', 'max:99'], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], 'expected_version' => ['sometimes', 'integer', 'min:1'], ]); try { $cart = $this->cart($cartId); - $this->carts->addLine($cart, $validated['variant_id'], $validated['quantity'], $validated['expected_version'] ?? null); + $variant = ProductVariant::withoutGlobalScopes() + ->whereKey($validated['variant_id']) + ->where('status', 'active') + ->whereHas('product', fn ($query) => $query->withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->where('status', 'active')) + ->first(); + if ($variant === null) { + throw ValidationException::withMessages(['variant_id' => 'The selected variant is not available.']); + } + $this->carts->addLine($cart, $variant, $validated['quantity'], $validated['expected_version'] ?? null); return response()->json(['data' => $this->data($cart->refresh())], 201); } catch (CartVersionMismatchException $exception) { return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); + } catch (InsufficientInventoryException $exception) { + throw ValidationException::withMessages(['quantity' => $exception->getMessage()]); + } catch (DomainException $exception) { + throw ValidationException::withMessages(['variant_id' => $exception->getMessage()]); } } public function updateLine(Request $request, int $cartId, int $lineId): JsonResponse { $validated = $request->validate([ - 'quantity' => ['required', 'integer', 'min:0', 'max:99'], + 'quantity' => ['required', 'integer', 'min:0', 'max:9999'], 'expected_version' => ['sometimes', 'integer', 'min:1'], + 'cart_version' => ['sometimes', 'integer', 'min:1'], ]); try { $cart = $this->cart($cartId); - $this->carts->updateLineQuantity($cart, $lineId, $validated['quantity'], $validated['expected_version'] ?? null); + $this->carts->updateLineQuantity($cart, $lineId, $validated['quantity'], $validated['expected_version'] ?? $validated['cart_version'] ?? null); return response()->json(['data' => $this->data($cart->refresh())]); } catch (CartVersionMismatchException $exception) { return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); + } catch (InsufficientInventoryException $exception) { + throw ValidationException::withMessages(['quantity' => $exception->getMessage()]); } } public function removeLine(Request $request, int $cartId, int $lineId): JsonResponse { - $validated = $request->validate(['expected_version' => ['sometimes', 'integer', 'min:1']]); + $validated = $request->validate([ + 'expected_version' => ['sometimes', 'integer', 'min:1'], + 'cart_version' => ['sometimes', 'integer', 'min:1'], + ]); try { $cart = $this->cart($cartId); - $this->carts->removeLine($cart, $lineId, $validated['expected_version'] ?? null); + $this->carts->removeLine($cart, $lineId, $validated['expected_version'] ?? $validated['cart_version'] ?? null); return response()->json(['data' => $this->data($cart->refresh())]); } catch (CartVersionMismatchException $exception) { diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php index 8b2aef0c..b82367e5 100644 --- a/app/Http/Controllers/Api/Storefront/CheckoutController.php +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -2,7 +2,12 @@ namespace App\Http\Controllers\Api\Storefront; +use App\Exceptions\DomainException; +use App\Exceptions\InsufficientInventoryException; +use App\Exceptions\InvalidCheckoutTransitionException; +use App\Exceptions\InvalidDiscountException; use App\Exceptions\PaymentFailedException; +use App\Exceptions\ShippingUnavailableException; use App\Http\Controllers\Controller; use App\Models\Cart; use App\Models\Checkout; @@ -10,6 +15,7 @@ use App\Services\ShippingCalculator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; final class CheckoutController extends Controller { @@ -22,7 +28,11 @@ public function store(Request $request): JsonResponse { $validated = $request->validate(['cart_id' => ['required', 'integer']]); $cart = Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines')->findOrFail($validated['cart_id']); - $checkout = $this->checkouts->create($cart); + try { + $checkout = $this->checkouts->create($cart); + } catch (InvalidCheckoutTransitionException $exception) { + throw ValidationException::withMessages(['cart_id' => $exception->getMessage()]); + } return response()->json(['data' => $this->data($checkout)], 201); } @@ -56,7 +66,11 @@ public function address(Request $request, int $checkoutId): JsonResponse public function shipping(Request $request, int $checkoutId): JsonResponse { $validated = $request->validate(['shipping_rate_id' => ['nullable', 'integer']]); - $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $validated['shipping_rate_id'] ?? null); + try { + $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $validated['shipping_rate_id'] ?? null); + } catch (ShippingUnavailableException $exception) { + throw ValidationException::withMessages(['shipping_rate_id' => $exception->getMessage()]); + } return response()->json(['data' => $this->data($checkout)]); } @@ -64,7 +78,11 @@ public function shipping(Request $request, int $checkoutId): JsonResponse public function payment(Request $request, int $checkoutId): JsonResponse { $validated = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]); - $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $validated['payment_method']); + try { + $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $validated['payment_method']); + } catch (InsufficientInventoryException $exception) { + throw ValidationException::withMessages(['payment_method' => $exception->getMessage()]); + } return response()->json(['data' => $this->data($checkout)]); } @@ -72,7 +90,15 @@ public function payment(Request $request, int $checkoutId): JsonResponse public function discount(Request $request, int $checkoutId): JsonResponse { $validated = $request->validate(['code' => ['required', 'string', 'max:100']]); - $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), $validated['code']); + try { + $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), $validated['code']); + } catch (InvalidDiscountException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'error_code' => $exception->reason, + 'errors' => ['code' => [$exception->getMessage()]], + ], 422); + } return response()->json(['data' => $this->data($checkout)]); } @@ -98,6 +124,8 @@ public function pay(Request $request, int $checkoutId): JsonResponse return response()->json(['data' => ['checkout' => $this->data($this->checkout($checkoutId)), 'order' => $order->load(['lines', 'payments'])]]); } catch (PaymentFailedException $exception) { return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422); + } catch (DomainException $exception) { + throw ValidationException::withMessages(['checkout' => $exception->getMessage()]); } } diff --git a/app/Http/Requests/CreateFulfillmentRequest.php b/app/Http/Requests/CreateFulfillmentRequest.php new file mode 100644 index 00000000..68d161c9 --- /dev/null +++ b/app/Http/Requests/CreateFulfillmentRequest.php @@ -0,0 +1,18 @@ + ['required', 'array', 'min:1'], 'lines.*' => ['required', 'integer', 'min:1'], 'tracking_company' => ['nullable', 'string', 'max:100'], 'tracking_number' => ['nullable', 'string', 'max:255'], 'tracking_url' => ['nullable', 'url']]; + } +} diff --git a/app/Http/Requests/CreateRefundRequest.php b/app/Http/Requests/CreateRefundRequest.php new file mode 100644 index 00000000..ac02fbc1 --- /dev/null +++ b/app/Http/Requests/CreateRefundRequest.php @@ -0,0 +1,18 @@ + ['required_without:lines', 'integer', 'min:1'], 'lines' => ['required_without:amount', 'array'], 'lines.*' => ['integer', 'min:1'], 'reason' => ['nullable', 'string', 'max:1000'], 'restock' => ['sometimes', 'boolean']]; + } +} diff --git a/app/Http/Requests/RegisterCustomerRequest.php b/app/Http/Requests/RegisterCustomerRequest.php new file mode 100644 index 00000000..4cca8bcb --- /dev/null +++ b/app/Http/Requests/RegisterCustomerRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'marketing_opt_in' => ['sometimes', 'boolean']]; + } +} diff --git a/app/Http/Requests/SetCheckoutAddressRequest.php b/app/Http/Requests/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..022e4aa4 --- /dev/null +++ b/app/Http/Requests/SetCheckoutAddressRequest.php @@ -0,0 +1,18 @@ + ['required', 'email'], 'shipping_address' => ['required', 'array'], 'shipping_address.first_name' => ['required', 'string'], 'shipping_address.last_name' => ['required', 'string'], 'shipping_address.address1' => ['required', 'string'], 'shipping_address.city' => ['required', 'string'], 'shipping_address.country_code' => ['required', 'string', 'size:2'], 'shipping_address.postal_code' => ['required', 'string']]; + } +} diff --git a/app/Http/Requests/SetCheckoutShippingRequest.php b/app/Http/Requests/SetCheckoutShippingRequest.php new file mode 100644 index 00000000..26208c56 --- /dev/null +++ b/app/Http/Requests/SetCheckoutShippingRequest.php @@ -0,0 +1,18 @@ + ['nullable', 'integer', 'exists:shipping_rates,id']]; + } +} diff --git a/app/Http/Requests/StoreCollectionRequest.php b/app/Http/Requests/StoreCollectionRequest.php new file mode 100644 index 00000000..4e0fe0e7 --- /dev/null +++ b/app/Http/Requests/StoreCollectionRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:255'], 'handle' => ['sometimes', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'type' => ['sometimes', 'in:manual,automated'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array'], 'product_ids.*' => ['integer']]; + } +} diff --git a/app/Http/Requests/StoreCustomerAddressRequest.php b/app/Http/Requests/StoreCustomerAddressRequest.php new file mode 100644 index 00000000..9a314346 --- /dev/null +++ b/app/Http/Requests/StoreCustomerAddressRequest.php @@ -0,0 +1,18 @@ + ['nullable', 'string', 'max:100'], 'address' => ['required', 'array'], 'address.first_name' => ['required', 'string'], 'address.last_name' => ['required', 'string'], 'address.address1' => ['required', 'string'], 'address.city' => ['required', 'string'], 'address.country_code' => ['required', 'string', 'size:2'], 'address.postal_code' => ['required', 'string'], 'is_default' => ['sometimes', 'boolean']]; + } +} diff --git a/app/Http/Requests/StoreDiscountRequest.php b/app/Http/Requests/StoreDiscountRequest.php new file mode 100644 index 00000000..0a8e3974 --- /dev/null +++ b/app/Http/Requests/StoreDiscountRequest.php @@ -0,0 +1,18 @@ + ['required', 'in:code,automatic'], 'code' => ['nullable', 'string', 'max:100'], 'value_type' => ['required', 'in:fixed,percent,free_shipping'], 'value_amount' => ['required', 'integer', 'min:0'], 'starts_at' => ['required', 'date'], 'ends_at' => ['nullable', 'date', 'after:starts_at'], 'usage_limit' => ['nullable', 'integer', 'min:1'], 'rules_json' => ['sometimes', 'array'], 'status' => ['sometimes', 'in:draft,active,expired,disabled']]; + } +} diff --git a/app/Http/Requests/StorePageRequest.php b/app/Http/Requests/StorePageRequest.php new file mode 100644 index 00000000..3ea43db3 --- /dev/null +++ b/app/Http/Requests/StorePageRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:255'], 'handle' => ['sometimes', 'string', 'max:255'], 'body_html' => ['nullable', 'string'], 'status' => ['sometimes', 'in:draft,published,archived'], 'published_at' => ['nullable', 'date']]; + } +} diff --git a/app/Http/Requests/StoreProductRequest.php b/app/Http/Requests/StoreProductRequest.php new file mode 100644 index 00000000..9dd904a6 --- /dev/null +++ b/app/Http/Requests/StoreProductRequest.php @@ -0,0 +1,19 @@ + */ + public function rules(): array + { + return ['title' => ['required', 'string', 'max:255'], 'handle' => ['sometimes', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'vendor' => ['nullable', 'string', 'max:255'], 'product_type' => ['nullable', 'string', 'max:255'], 'tags' => ['sometimes', 'array'], 'status' => ['sometimes', 'in:draft,active,archived'], 'options' => ['sometimes', 'array', 'max:3']]; + } +} diff --git a/app/Http/Requests/StoreShippingRateRequest.php b/app/Http/Requests/StoreShippingRateRequest.php new file mode 100644 index 00000000..1f42c122 --- /dev/null +++ b/app/Http/Requests/StoreShippingRateRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'config_json' => ['required', 'array'], 'is_active' => ['sometimes', 'boolean']]; + } +} diff --git a/app/Http/Requests/StoreShippingZoneRequest.php b/app/Http/Requests/StoreShippingZoneRequest.php new file mode 100644 index 00000000..8b2f6305 --- /dev/null +++ b/app/Http/Requests/StoreShippingZoneRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:255'], 'countries_json' => ['required', 'array', 'min:1'], 'countries_json.*' => ['string', 'size:2'], 'regions_json' => ['sometimes', 'array']]; + } +} diff --git a/app/Http/Requests/UpdateCollectionRequest.php b/app/Http/Requests/UpdateCollectionRequest.php new file mode 100644 index 00000000..8186c177 --- /dev/null +++ b/app/Http/Requests/UpdateCollectionRequest.php @@ -0,0 +1,11 @@ + ['sometimes', 'string', 'max:255'], ...collect(parent::rules())->except('title')->all()]; + } +} diff --git a/app/Http/Requests/UpdateCustomerAddressRequest.php b/app/Http/Requests/UpdateCustomerAddressRequest.php new file mode 100644 index 00000000..d6157c7f --- /dev/null +++ b/app/Http/Requests/UpdateCustomerAddressRequest.php @@ -0,0 +1,11 @@ +map(fn (array $rules): array => ['sometimes', ...array_values(array_filter($rules, fn (string $rule): bool => $rule !== 'required'))])->all(); + } +} diff --git a/app/Http/Requests/UpdateDiscountRequest.php b/app/Http/Requests/UpdateDiscountRequest.php new file mode 100644 index 00000000..42490bc8 --- /dev/null +++ b/app/Http/Requests/UpdateDiscountRequest.php @@ -0,0 +1,11 @@ +map(fn (array $rules): array => ['sometimes', ...array_values(array_filter($rules, fn (string $rule): bool => $rule !== 'required'))])->all(); + } +} diff --git a/app/Http/Requests/UpdatePageRequest.php b/app/Http/Requests/UpdatePageRequest.php new file mode 100644 index 00000000..78f02005 --- /dev/null +++ b/app/Http/Requests/UpdatePageRequest.php @@ -0,0 +1,11 @@ + ['sometimes', 'string', 'max:255'], ...collect(parent::rules())->except('title')->all()]; + } +} diff --git a/app/Http/Requests/UpdateProductRequest.php b/app/Http/Requests/UpdateProductRequest.php new file mode 100644 index 00000000..886f485b --- /dev/null +++ b/app/Http/Requests/UpdateProductRequest.php @@ -0,0 +1,11 @@ +map(fn (array $rules): array => ['sometimes', ...array_values(array_filter($rules, fn (string $rule): bool => $rule !== 'required'))])->all(); + } +} diff --git a/app/Http/Requests/UpdateTaxSettingsRequest.php b/app/Http/Requests/UpdateTaxSettingsRequest.php new file mode 100644 index 00000000..316ca167 --- /dev/null +++ b/app/Http/Requests/UpdateTaxSettingsRequest.php @@ -0,0 +1,18 @@ + ['required', 'in:manual,provider'], 'provider' => ['required', 'in:none,stripe_tax'], 'prices_include_tax' => ['required', 'boolean'], 'config_json' => ['required', 'array']]; + } +} diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php index 5ff06586..5c57032a 100644 --- a/app/Jobs/CancelUnpaidBankTransferOrders.php +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -22,6 +22,7 @@ public function handle(OrderService $orders): void ->with('store.settings') ->chunkById(100, function ($pending) use ($orders): void { foreach ($pending as $order) { + app()->instance('current_store', $order->store); $days = (int) data_get($order->store?->settings?->settings_json, 'bank_transfer_cancel_days', 7); if ($order->placed_at?->lt(now()->subDays($days))) { $orders->cancel($order, 'Bank transfer payment timeout'); diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php index cef08322..f33301d3 100644 --- a/app/Jobs/CleanupAbandonedCarts.php +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Cart; +use App\Models\Store; use App\Services\CheckoutService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,9 +18,10 @@ final class CleanupAbandonedCarts implements ShouldQueue public function handle(CheckoutService $checkouts): void { Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14)) - ->with('checkouts') + ->with(['checkouts' => fn ($query) => $query->withoutGlobalScopes()]) ->chunkById(100, function ($carts) use ($checkouts): void { foreach ($carts as $cart) { + app()->instance('current_store', Store::query()->findOrFail($cart->store_id)); foreach ($cart->checkouts->whereNotIn('status', ['completed', 'expired']) as $checkout) { $checkouts->expireCheckout($checkout); } diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php index 31f5980f..0f83873c 100644 --- a/app/Jobs/ExpireAbandonedCheckouts.php +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\Checkout; +use App\Models\Store; use App\Services\CheckoutService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -21,6 +22,9 @@ public function handle(CheckoutService $service): void ->where(function ($query): void { $query->where('expires_at', '<', now())->orWhere('updated_at', '<', now()->subDay()); }) - ->chunkById(100, fn ($checkouts) => $checkouts->each(fn (Checkout $checkout) => $service->expireCheckout($checkout))); + ->chunkById(100, fn ($checkouts) => $checkouts->each(function (Checkout $checkout) use ($service): void { + app()->instance('current_store', Store::query()->findOrFail($checkout->store_id)); + $service->expireCheckout($checkout); + })); } } diff --git a/app/Jobs/GenerateOrderExport.php b/app/Jobs/GenerateOrderExport.php new file mode 100644 index 00000000..1112e444 --- /dev/null +++ b/app/Jobs/GenerateOrderExport.php @@ -0,0 +1,86 @@ +find($this->export->id); + if ($export === null) { + return; + } + + $export->update(['status' => 'processing', 'error_message' => null]); + + try { + $filters = (array) $export->filters_json; + $orders = Order::withoutGlobalScopes() + ->where('store_id', $export->store_id) + ->when($filters['status'] ?? null, fn ($query, $status) => $query->where('status', $status)) + ->when($filters['financial_status'] ?? null, fn ($query, $status) => $query->where('financial_status', $status)) + ->when($filters['created_after'] ?? null, fn ($query, $date) => $query->where('created_at', '>=', $date)) + ->when($filters['created_before'] ?? null, fn ($query, $date) => $query->where('created_at', '<=', $date)) + ->with('fulfillments') + ->orderBy('id') + ->get(); + + $stream = fopen('php://temp', 'w+b'); + if ($stream === false) { + throw new \RuntimeException('Could not create the export stream.'); + } + fputcsv($stream, ['order_number', 'created_at', 'status', 'financial_status', 'fulfillment_status', 'customer_email', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'currency', 'tracking_number']); + foreach ($orders as $order) { + fputcsv($stream, [ + $order->order_number, + $order->created_at?->toIso8601String(), + $order->status instanceof \BackedEnum ? $order->status->value : $order->status, + $order->financial_status instanceof \BackedEnum ? $order->financial_status->value : $order->financial_status, + $order->fulfillment_status instanceof \BackedEnum ? $order->fulfillment_status->value : $order->fulfillment_status, + $order->email, + $order->subtotal_amount, + $order->discount_amount, + $order->shipping_amount, + $order->tax_amount, + $order->total_amount, + $order->currency, + $order->fulfillments->first()?->tracking_number, + ]); + } + rewind($stream); + $contents = stream_get_contents($stream); + fclose($stream); + if ($contents === false) { + throw new \RuntimeException('Could not read the export stream.'); + } + + $key = "exports/{$export->store_id}/orders-{$export->id}.csv"; + Storage::disk('local')->put($key, $contents); + $export->update([ + 'status' => 'completed', + 'row_count' => $orders->count(), + 'storage_key' => $key, + 'completed_at' => now(), + ]); + } catch (Throwable $exception) { + $export->update(['status' => 'failed', 'error_message' => mb_substr($exception->getMessage(), 0, 2000)]); + throw $exception; + } + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php index 251ffcfa..09eadcb8 100644 --- a/app/Jobs/ProcessMediaUpload.php +++ b/app/Jobs/ProcessMediaUpload.php @@ -49,23 +49,28 @@ public function handle(): void $contents = $disk->get($media->storage_key); $source = function_exists('imagecreatefromstring') ? @imagecreatefromstring($contents) : false; - if ($source !== false) { - foreach (['thumbnail' => 150, 'small' => 300, 'medium' => 600, 'large' => 1200] as $name => $maximum) { - $scale = min(1, $maximum / max($width, $height)); - $targetWidth = max(1, (int) round($width * $scale)); - $targetHeight = max(1, (int) round($height * $scale)); - $target = imagecreatetruecolor($targetWidth, $targetHeight); - imagealphablending($target, false); - imagesavealpha($target, true); - imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); - ob_start(); - imagewebp($target, null, 85); - $encoded = (string) ob_get_clean(); - imagedestroy($target); - $disk->put("media/{$media->product_id}/{$media->id}/{$name}.webp", $encoded); + if ($source === false) { + throw new \RuntimeException('This server cannot decode the uploaded image.'); + } + + $extension = $this->extensionForMime((string) ($info['mime'] ?? '')); + foreach (['thumbnail' => 150, 'small' => 300, 'medium' => 600, 'large' => 1200] as $name => $maximum) { + $scale = min(1, $maximum / max($width, $height)); + $targetWidth = max(1, (int) round($width * $scale)); + $targetHeight = max(1, (int) round($height * $scale)); + $target = imagecreatetruecolor($targetWidth, $targetHeight); + imagealphablending($target, false); + imagesavealpha($target, true); + imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height); + + $directory = "media/{$media->product_id}/{$media->id}"; + $disk->put("{$directory}/{$name}.{$extension}", $this->encode($target, $extension)); + if ($extension !== 'webp' && function_exists('imagewebp')) { + $disk->put("{$directory}/{$name}.webp", $this->encode($target, 'webp')); } - imagedestroy($source); + imagedestroy($target); } + imagedestroy($source); $media->update(['status' => 'ready']); } catch (Throwable $exception) { @@ -74,4 +79,36 @@ public function handle(): void throw $exception; } } + + private function extensionForMime(string $mime): string + { + return match ($mime) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/avif' => 'avif', + default => throw new \RuntimeException('The uploaded image format is not supported.'), + }; + } + + /** @param \GdImage|resource $image */ + private function encode(mixed $image, string $extension): string + { + ob_start(); + $success = match ($extension) { + 'jpg' => imagejpeg($image, null, 88), + 'png' => imagepng($image, null, 7), + 'gif' => imagegif($image), + 'webp' => imagewebp($image, null, 85), + 'avif' => function_exists('imageavif') && imageavif($image, null, 70), + default => false, + }; + $contents = (string) ob_get_clean(); + if (! $success || $contents === '') { + throw new \RuntimeException('The resized image could not be encoded.'); + } + + return $contents; + } } diff --git a/app/Listeners/ShopEventSubscriber.php b/app/Listeners/ShopEventSubscriber.php new file mode 100644 index 00000000..ec1c7ac4 --- /dev/null +++ b/app/Listeners/ShopEventSubscriber.php @@ -0,0 +1,147 @@ +order; + $store = Store::query()->findOrFail($order->store_id); + $this->notify($order->email, new OrderLifecycleNotification($order, 'confirmed')); + $this->webhooks->dispatch($store, 'order.created', $this->orderPayload($order)); + $this->analytics->track( + $store, + 'checkout_completed', + ['order_id' => $order->id, 'total_amount' => $order->total_amount], + customerId: $order->customer_id, + clientEventId: "order:{$order->id}:completed", + ); + } + + public function orderRefunded(OrderRefunded $event): void + { + $store = Store::query()->findOrFail($event->order->store_id); + $this->notify($event->order->email, new OrderLifecycleNotification($event->order, 'refunded', $event->refund)); + $this->webhooks->dispatch($store, 'order.refunded', [ + ...$this->orderPayload($event->order), + 'refund_id' => $event->refund->id, + 'amount' => $event->refund->amount, + ]); + } + + public function checkoutCompleted(CheckoutCompleted $event): void + { + $store = Store::query()->findOrFail($event->checkout->store_id); + $this->webhooks->dispatch($store, 'checkout.completed', [ + 'checkout_id' => $event->checkout->id, + 'order_id' => $event->order->id, + 'total_amount' => $event->order->total_amount, + ]); + } + + public function fulfillmentShipped(FulfillmentShipped $event): void + { + $order = $event->fulfillment->order()->withoutGlobalScopes()->firstOrFail(); + $store = Store::query()->findOrFail($order->store_id); + $this->notify($order->email, new OrderLifecycleNotification($order, 'shipped', $event->fulfillment)); + $this->webhooks->dispatch($store, 'order.fulfilled', [ + ...$this->orderPayload($order), + 'fulfillment_id' => $event->fulfillment->id, + 'tracking_number' => $event->fulfillment->tracking_number, + ]); + } + + public function fulfillmentDelivered(FulfillmentDelivered $event): void + { + $order = $event->fulfillment->order()->withoutGlobalScopes()->firstOrFail(); + $store = Store::query()->findOrFail($order->store_id); + $this->webhooks->dispatch($store, 'fulfillment.delivered', [ + ...$this->orderPayload($order), + 'fulfillment_id' => $event->fulfillment->id, + ]); + } + + public function orderCancelled(OrderCancelled $event): void + { + $store = Store::query()->findOrFail($event->order->store_id); + $this->notify($event->order->email, new OrderLifecycleNotification($event->order, 'cancelled', $event->reason)); + $this->webhooks->dispatch($store, 'order.cancelled', [ + ...$this->orderPayload($event->order), + 'reason' => $event->reason, + ]); + } + + public function productCreated(ProductCreated $event): void + { + $this->productWebhook($event->product->store_id, 'product.created', $event->product->id); + } + + public function productUpdated(ProductUpdated $event): void + { + $this->productWebhook($event->product->store_id, 'product.updated', $event->product->id); + } + + public function productDeleted(ProductDeleted $event): void + { + $this->productWebhook($event->product->store_id, 'product.deleted', $event->product->id); + } + + public function subscribe(Dispatcher $events): void + { + $events->listen(OrderCreated::class, [self::class, 'orderCreated']); + $events->listen(OrderRefunded::class, [self::class, 'orderRefunded']); + $events->listen(CheckoutCompleted::class, [self::class, 'checkoutCompleted']); + $events->listen(FulfillmentShipped::class, [self::class, 'fulfillmentShipped']); + $events->listen(FulfillmentDelivered::class, [self::class, 'fulfillmentDelivered']); + $events->listen(OrderCancelled::class, [self::class, 'orderCancelled']); + $events->listen(ProductCreated::class, [self::class, 'productCreated']); + $events->listen(ProductUpdated::class, [self::class, 'productUpdated']); + $events->listen(ProductDeleted::class, [self::class, 'productDeleted']); + } + + /** @return array */ + private function orderPayload(mixed $order): array + { + return [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'status' => $order->status instanceof \BackedEnum ? $order->status->value : $order->status, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + ]; + } + + private function productWebhook(int $storeId, string $event, int $productId): void + { + $this->webhooks->dispatch(Store::query()->findOrFail($storeId), $event, ['product_id' => $productId]); + } + + private function notify(?string $email, OrderLifecycleNotification $notification): void + { + if ($email !== null && $email !== '') { + Notification::route('mail', $email)->notify($notification); + } + } +} diff --git a/app/Models/OrderExport.php b/app/Models/OrderExport.php new file mode 100644 index 00000000..bf78c87c --- /dev/null +++ b/app/Models/OrderExport.php @@ -0,0 +1,27 @@ + 'array', + 'row_count' => 'integer', + 'completed_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index 89eec499..b881c664 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -3,17 +3,56 @@ namespace App\Models; use App\Enums\VariantStatus; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Validation\ValidationException; class ProductVariant extends Model { use HasFactory; + protected static function booted(): void + { + static::saving(function (ProductVariant $variant): void { + $sku = trim((string) $variant->sku); + if ($sku === '') { + return; + } + + $storeId = Product::withoutGlobalScopes()->whereKey($variant->product_id)->value('store_id'); + $duplicateExists = static::query() + ->where('sku', $sku) + ->when($variant->exists, fn (Builder $query) => $query->whereKeyNot($variant->getKey())) + ->whereHas('product', fn (Builder $query) => $query->withoutGlobalScopes()->where('store_id', $storeId)) + ->exists(); + + if ($duplicateExists) { + throw ValidationException::withMessages(['sku' => 'The SKU has already been taken for this store.']); + } + + $variant->sku = $sku; + }); + + static::created(function (ProductVariant $variant): void { + $storeId = Product::withoutGlobalScopes()->whereKey($variant->product_id)->value('store_id'); + + InventoryItem::withoutGlobalScopes()->firstOrCreate( + ['variant_id' => $variant->getKey()], + [ + 'store_id' => $storeId, + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ], + ); + }); + } + protected $fillable = [ 'product_id', 'sku', 'barcode', 'price_amount', 'compare_at_amount', 'currency', 'weight_g', 'requires_shipping', 'is_default', 'position', 'status', diff --git a/app/Models/Refund.php b/app/Models/Refund.php index 2298e177..ca53981c 100644 --- a/app/Models/Refund.php +++ b/app/Models/Refund.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Refund extends Model { @@ -32,4 +33,9 @@ public function payment(): BelongsTo { return $this->belongsTo(Payment::class); } + + public function lines(): HasMany + { + return $this->hasMany(RefundLine::class); + } } diff --git a/app/Models/RefundLine.php b/app/Models/RefundLine.php new file mode 100644 index 00000000..b61023b0 --- /dev/null +++ b/app/Models/RefundLine.php @@ -0,0 +1,28 @@ + 'integer', 'amount' => 'integer']; + } + + public function refund(): BelongsTo + { + return $this->belongsTo(Refund::class); + } + + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Notifications/OrderLifecycleNotification.php b/app/Notifications/OrderLifecycleNotification.php new file mode 100644 index 00000000..e2432358 --- /dev/null +++ b/app/Notifications/OrderLifecycleNotification.php @@ -0,0 +1,64 @@ + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $message = (new MailMessage)->subject($this->subject())->greeting('Hello!'); + + match ($this->type) { + 'confirmed' => $message + ->line("We received order {$this->order->order_number}.") + ->line('Total: '.number_format($this->order->total_amount / 100, 2).' '.$this->order->currency), + 'refunded' => $message + ->line("A refund was processed for order {$this->order->order_number}.") + ->line('Refund amount: '.number_format(($this->context instanceof Refund ? $this->context->amount : 0) / 100, 2).' '.$this->order->currency), + 'shipped' => $message + ->line("Order {$this->order->order_number} has shipped.") + ->line($this->context instanceof Fulfillment && $this->context->tracking_number + ? "Tracking: {$this->context->tracking_number}" + : 'Tracking details will follow when available.'), + 'cancelled' => $message + ->line("Order {$this->order->order_number} was cancelled.") + ->line(is_string($this->context) && $this->context !== '' ? "Reason: {$this->context}" : 'No payment was captured.'), + default => $message->line("Order {$this->order->order_number} was updated."), + }; + + return $message->action('Visit the shop', url('/account/orders')) + ->line('Thank you for shopping with us.'); + } + + private function subject(): string + { + return match ($this->type) { + 'confirmed' => "Order {$this->order->order_number} confirmed", + 'refunded' => "Refund for {$this->order->order_number}", + 'shipped' => "Order {$this->order->order_number} shipped", + 'cancelled' => "Order {$this->order->order_number} cancelled", + default => "Order {$this->order->order_number} update", + }; + } +} diff --git a/app/Observers/AuditableObserver.php b/app/Observers/AuditableObserver.php new file mode 100644 index 00000000..1afdc477 --- /dev/null +++ b/app/Observers/AuditableObserver.php @@ -0,0 +1,102 @@ +record($model, 'created'); + } + + public function updated(Model $model): void + { + $changes = []; + foreach (Arr::except($model->getChanges(), ['updated_at']) as $attribute => $value) { + if ($this->sensitive($attribute)) { + continue; + } + $changes[$attribute] = [ + $this->serialise($model->getRawOriginal($attribute)), + $this->serialise($value), + ]; + } + + if ($changes !== []) { + $this->record($model, 'updated', ['changes' => $changes]); + } + } + + public function deleted(Model $model): void + { + $this->record($model, 'deleted'); + } + + public function restored(Model $model): void + { + $this->record($model, 'restored'); + } + + /** @param array $extra */ + private function record(Model $model, string $action, array $extra = []): void + { + $resource = match (class_basename($model)) { + 'NavigationMenu' => 'navigation_menu', + 'TaxSettings' => 'tax_setting', + default => Str::snake(class_basename($model)), + }; + + $this->audit->log( + "{$resource}.{$action}", + auth()->guard('web')->id(), + $this->storeId($model), + $resource, + is_numeric($model->getKey()) ? (int) $model->getKey() : null, + $extra, + ); + } + + private function storeId(Model $model): ?int + { + $storeId = $model->getAttribute('store_id'); + if ($storeId !== null) { + return (int) $storeId; + } + + if ($model instanceof Fulfillment || $model instanceof Refund) { + return (int) ($model->order()->withoutGlobalScopes()->value('store_id') ?: 0) ?: null; + } + + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return (int) ($store instanceof Model ? $store->getKey() : $store); + } + + private function sensitive(string $attribute): bool + { + return Str::contains(mb_strtolower($attribute), ['password', 'token', 'secret', 'encrypted', 'raw_json']); + } + + private function serialise(mixed $value): mixed + { + if ($value instanceof BackedEnum) { + return $value->value; + } + + return is_scalar($value) || $value === null ? $value : json_decode(json_encode($value), true); + } +} diff --git a/app/Observers/ProductMediaObserver.php b/app/Observers/ProductMediaObserver.php new file mode 100644 index 00000000..d35e5947 --- /dev/null +++ b/app/Observers/ProductMediaObserver.php @@ -0,0 +1,16 @@ +delete($media->storage_key); + $disk->deleteDirectory("media/{$media->product_id}/{$media->id}"); + } +} diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php index 61c545f1..bd399972 100644 --- a/app/Policies/CollectionPolicy.php +++ b/app/Policies/CollectionPolicy.php @@ -10,9 +10,28 @@ final class CollectionPolicy { use ChecksStoreRoles; - public function viewAny(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } - public function view(User $user, Collection $collection): bool { return $this->hasRole($user, $collection, ['owner', 'admin', 'staff', 'support']); } - public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } - public function update(User $user, Collection $collection): bool { return $this->hasRole($user, $collection, ['owner', 'admin', 'staff']); } - public function delete(User $user, Collection $collection): bool { return $this->hasRole($user, $collection, ['owner', 'admin']); } + public function viewAny(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Collection $collection): bool + { + return $this->hasRole($user, $collection, ['owner', 'admin', 'staff', 'support']); + } + + public function create(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff']); + } + + public function update(User $user, Collection $collection): bool + { + return $this->hasRole($user, $collection, ['owner', 'admin', 'staff']); + } + + public function delete(User $user, Collection $collection): bool + { + return $this->hasRole($user, $collection, ['owner', 'admin']); + } } diff --git a/app/Policies/Concerns/ChecksStoreRoles.php b/app/Policies/Concerns/ChecksStoreRoles.php index 1cdc2e40..040baefd 100644 --- a/app/Policies/Concerns/ChecksStoreRoles.php +++ b/app/Policies/Concerns/ChecksStoreRoles.php @@ -17,6 +17,10 @@ protected function hasRole(User $user, Model|Store|null $resource, array $roles) if ($store === null) { return false; } + $store = $store instanceof Store ? $store : Store::query()->find($store); + if ($store === null) { + return false; + } $role = $user->roleForStore($store); $value = $role instanceof BackedEnum ? (string) $role->value : ($role === null ? null : (string) $role); @@ -35,7 +39,9 @@ private function storeFor(?Model $resource): ?Store return $resource->store; } if (method_exists($resource, 'order')) { - return $resource->order?->store; + $storeId = $resource->order()->withoutGlobalScopes()->value('store_id'); + + return $storeId === null ? null : Store::query()->find($storeId); } return null; diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php index cc4fbbc1..5344e193 100644 --- a/app/Policies/CustomerPolicy.php +++ b/app/Policies/CustomerPolicy.php @@ -10,7 +10,18 @@ final class CustomerPolicy { use ChecksStoreRoles; - public function viewAny(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } - public function view(User $user, Customer $customer): bool { return $this->hasRole($user, $customer, ['owner', 'admin', 'staff', 'support']); } - public function update(User $user, Customer $customer): bool { return $this->hasRole($user, $customer, ['owner', 'admin', 'staff']); } + public function viewAny(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Customer $customer): bool + { + return $this->hasRole($user, $customer, ['owner', 'admin', 'staff', 'support']); + } + + public function update(User $user, Customer $customer): bool + { + return $this->hasRole($user, $customer, ['owner', 'admin', 'staff']); + } } diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php index 6b01e4d8..b8e40d37 100644 --- a/app/Policies/DiscountPolicy.php +++ b/app/Policies/DiscountPolicy.php @@ -10,9 +10,28 @@ final class DiscountPolicy { use ChecksStoreRoles; - public function viewAny(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } - public function view(User $user, Discount $discount): bool { return $this->hasRole($user, $discount, ['owner', 'admin', 'staff', 'support']); } - public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } - public function update(User $user, Discount $discount): bool { return $this->hasRole($user, $discount, ['owner', 'admin', 'staff']); } - public function delete(User $user, Discount $discount): bool { return $this->hasRole($user, $discount, ['owner', 'admin']); } + public function viewAny(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Discount $discount): bool + { + return $this->hasRole($user, $discount, ['owner', 'admin', 'staff', 'support']); + } + + public function create(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff']); + } + + public function update(User $user, Discount $discount): bool + { + return $this->hasRole($user, $discount, ['owner', 'admin', 'staff']); + } + + public function delete(User $user, Discount $discount): bool + { + return $this->hasRole($user, $discount, ['owner', 'admin']); + } } diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php index 9d477eec..7771f661 100644 --- a/app/Policies/FulfillmentPolicy.php +++ b/app/Policies/FulfillmentPolicy.php @@ -11,6 +11,18 @@ final class FulfillmentPolicy { use ChecksStoreRoles; - public function create(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); } - public function update(User $user, Fulfillment $fulfillment): bool { return $this->hasRole($user, $fulfillment, ['owner', 'admin', 'staff']); } + public function create(User $user, Order $order): bool + { + return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); + } + + public function update(User $user, Fulfillment $fulfillment): bool + { + return $this->hasRole($user, $fulfillment, ['owner', 'admin', 'staff']); + } + + public function cancel(User $user, Fulfillment $fulfillment): bool + { + return $this->update($user, $fulfillment); + } } diff --git a/app/Policies/NavigationMenuPolicy.php b/app/Policies/NavigationMenuPolicy.php new file mode 100644 index 00000000..8d45927c --- /dev/null +++ b/app/Policies/NavigationMenuPolicy.php @@ -0,0 +1,21 @@ +hasRole($user, null, ['owner', 'admin', 'staff']); + } + + public function manage(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin']); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php index 05646e2f..307d4ee0 100644 --- a/app/Policies/OrderPolicy.php +++ b/app/Policies/OrderPolicy.php @@ -10,10 +10,43 @@ final class OrderPolicy { use ChecksStoreRoles; - public function viewAny(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } - public function view(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin', 'staff', 'support']); } - public function update(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); } - public function cancel(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin']); } - public function refund(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin']); } - public function fulfill(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); } + public function viewAny(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Order $order): bool + { + return $this->hasRole($user, $order, ['owner', 'admin', 'staff', 'support']); + } + + public function update(User $user, Order $order): bool + { + return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); + } + + public function cancel(User $user, Order $order): bool + { + return $this->hasRole($user, $order, ['owner', 'admin']); + } + + public function refund(User $user, Order $order): bool + { + return $this->hasRole($user, $order, ['owner', 'admin']); + } + + public function fulfill(User $user, Order $order): bool + { + return $this->hasRole($user, $order, ['owner', 'admin', 'staff']); + } + + public function createRefund(User $user, Order $order): bool + { + return $this->refund($user, $order); + } + + public function createFulfillment(User $user, Order $order): bool + { + return $this->fulfill($user, $order); + } } diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php index 9256b6fd..d4dd86a4 100644 --- a/app/Policies/PagePolicy.php +++ b/app/Policies/PagePolicy.php @@ -10,9 +10,28 @@ final class PagePolicy { use ChecksStoreRoles; - public function viewAny(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } - public function view(User $user, Page $page): bool { return $this->hasRole($user, $page, ['owner', 'admin', 'staff', 'support']); } - public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } - public function update(User $user, Page $page): bool { return $this->hasRole($user, $page, ['owner', 'admin', 'staff']); } - public function delete(User $user, Page $page): bool { return $this->hasRole($user, $page, ['owner', 'admin']); } + public function viewAny(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff']); + } + + public function view(User $user, Page $page): bool + { + return $this->hasRole($user, $page, ['owner', 'admin', 'staff']); + } + + public function create(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff']); + } + + public function update(User $user, Page $page): bool + { + return $this->hasRole($user, $page, ['owner', 'admin', 'staff']); + } + + public function delete(User $user, Page $page): bool + { + return $this->hasRole($user, $page, ['owner', 'admin']); + } } diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php index 637b69a6..bfb3d0ad 100644 --- a/app/Policies/ProductPolicy.php +++ b/app/Policies/ProductPolicy.php @@ -10,9 +10,38 @@ final class ProductPolicy { use ChecksStoreRoles; - public function viewAny(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); } - public function view(User $user, Product $product): bool { return $this->hasRole($user, $product, ['owner', 'admin', 'staff', 'support']); } - public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin', 'staff']); } - public function update(User $user, Product $product): bool { return $this->hasRole($user, $product, ['owner', 'admin', 'staff']); } - public function delete(User $user, Product $product): bool { return $this->hasRole($user, $product, ['owner', 'admin']); } + public function viewAny(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff', 'support']); + } + + public function view(User $user, Product $product): bool + { + return $this->hasRole($user, $product, ['owner', 'admin', 'staff', 'support']); + } + + public function create(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin', 'staff']); + } + + public function update(User $user, Product $product): bool + { + return $this->hasRole($user, $product, ['owner', 'admin', 'staff']); + } + + public function delete(User $user, Product $product): bool + { + return $this->hasRole($user, $product, ['owner', 'admin']); + } + + public function archive(User $user, Product $product): bool + { + return $this->delete($user, $product); + } + + public function restore(User $user, Product $product): bool + { + return $this->delete($user, $product); + } } diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php index 58a760dc..da9f4f98 100644 --- a/app/Policies/RefundPolicy.php +++ b/app/Policies/RefundPolicy.php @@ -11,6 +11,13 @@ final class RefundPolicy { use ChecksStoreRoles; - public function create(User $user, Order $order): bool { return $this->hasRole($user, $order, ['owner', 'admin']); } - public function view(User $user, Refund $refund): bool { return $this->hasRole($user, $refund, ['owner', 'admin', 'staff', 'support']); } + public function create(User $user, Order $order): bool + { + return $this->hasRole($user, $order, ['owner', 'admin']); + } + + public function view(User $user, Refund $refund): bool + { + return $this->hasRole($user, $refund, ['owner', 'admin', 'staff', 'support']); + } } diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php index 363f7bc5..07f4d2d3 100644 --- a/app/Policies/StorePolicy.php +++ b/app/Policies/StorePolicy.php @@ -10,8 +10,33 @@ final class StorePolicy { use ChecksStoreRoles; - public function view(User $user, Store $store): bool { return $this->hasRole($user, $store, ['owner', 'admin', 'staff', 'support']); } - public function update(User $user, Store $store): bool { return $this->hasRole($user, $store, ['owner', 'admin']); } - public function delete(User $user, Store $store): bool { return $this->hasRole($user, $store, ['owner']); } - public function manageStaff(User $user, Store $store): bool { return $this->hasRole($user, $store, ['owner', 'admin']); } + public function view(User $user, Store $store): bool + { + return $this->hasRole($user, $store, ['owner', 'admin', 'staff', 'support']); + } + + public function update(User $user, Store $store): bool + { + return $this->hasRole($user, $store, ['owner', 'admin']); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, $store, ['owner']); + } + + public function manageStaff(User $user, Store $store): bool + { + return $this->hasRole($user, $store, ['owner', 'admin']); + } + + public function viewSettings(User $user, Store $store): bool + { + return $this->hasRole($user, $store, ['owner', 'admin']); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->viewSettings($user, $store); + } } diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php index e0df6bc5..2979887a 100644 --- a/app/Policies/ThemePolicy.php +++ b/app/Policies/ThemePolicy.php @@ -10,9 +10,33 @@ final class ThemePolicy { use ChecksStoreRoles; - public function viewAny(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin']); } - public function view(User $user, Theme $theme): bool { return $this->hasRole($user, $theme, ['owner', 'admin']); } - public function create(User $user): bool { return $this->hasRole($user, null, ['owner', 'admin']); } - public function update(User $user, Theme $theme): bool { return $this->hasRole($user, $theme, ['owner', 'admin']); } - public function delete(User $user, Theme $theme): bool { return $this->hasRole($user, $theme, ['owner', 'admin']); } + public function viewAny(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin']); + } + + public function view(User $user, Theme $theme): bool + { + return $this->hasRole($user, $theme, ['owner', 'admin']); + } + + public function create(User $user): bool + { + return $this->hasRole($user, null, ['owner', 'admin']); + } + + public function update(User $user, Theme $theme): bool + { + return $this->hasRole($user, $theme, ['owner', 'admin']); + } + + public function delete(User $user, Theme $theme): bool + { + return $this->hasRole($user, $theme, ['owner', 'admin']); + } + + public function publish(User $user, Theme $theme): bool + { + return $this->update($user, $theme); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e15491f9..231e9b26 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,17 +5,41 @@ use App\Auth\CustomerUserProvider; use App\Contracts\PaymentProvider; use App\Contracts\TaxProvider; +use App\Enums\StoreUserRole; +use App\Listeners\ShopEventSubscriber; +use App\Models\Collection; +use App\Models\Customer; +use App\Models\Discount; +use App\Models\Fulfillment; +use App\Models\NavigationMenu; +use App\Models\Order; +use App\Models\Page; use App\Models\Product; +use App\Models\ProductMedia; +use App\Models\Refund; +use App\Models\ShippingZone; +use App\Models\Store; +use App\Models\TaxSettings; +use App\Models\Theme; +use App\Models\User; +use App\Observers\AuditableObserver; +use App\Observers\ProductMediaObserver; use App\Observers\ProductObserver; +use App\Services\AuditLogger; use App\Services\Payments\MockPaymentProvider; use App\Services\Tax\ManualTaxProvider; use Carbon\CarbonImmutable; +use Illuminate\Auth\Events\Failed; +use Illuminate\Auth\Events\Login; +use Illuminate\Auth\Events\Logout; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; -use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -38,9 +62,28 @@ public function boot(): void $this->configureDefaults(); $this->configureAuthentication(); $this->configureRateLimits(); + $this->configureGates(); Product::observe(ProductObserver::class); + ProductMedia::observe(ProductMediaObserver::class); + foreach ([ + Product::class, + Collection::class, + Discount::class, + Page::class, + Theme::class, + Order::class, + Fulfillment::class, + Refund::class, + NavigationMenu::class, + ShippingZone::class, + TaxSettings::class, + ] as $model) { + $model::observe(AuditableObserver::class); + } + Event::subscribe(ShopEventSubscriber::class); + $this->configureAuditEvents(); ResetPassword::createUrlUsing(function ($notifiable, string $token): string { - $path = $notifiable instanceof \App\Models\Customer ? '/reset-password/' : '/reset-password/'; + $path = $notifiable instanceof \App\Models\Customer ? '/reset-password/' : '/admin/reset-password/'; return url($path.$token).'?email='.urlencode($notifiable->getEmailForPasswordReset()); }); @@ -94,4 +137,73 @@ private function configureRateLimits(): void RateLimiter::for('analytics', fn ($request) => Limit::perMinute(60)->by($request->ip())); RateLimiter::for('webhooks', fn ($request) => Limit::perMinute(100)->by($request->ip())); } + + private function configureAuditEvents(): void + { + Event::listen(Login::class, function (Login $event): void { + $customer = $event->user instanceof Customer; + app(AuditLogger::class)->log( + $customer ? 'customer.login' : 'auth.login', + $customer ? null : (int) $event->user->getAuthIdentifier(), + $this->currentStoreId(), + extra: $customer ? ['customer_id' => (int) $event->user->getAuthIdentifier()] : [], + ); + }); + Event::listen(Failed::class, function (Failed $event): void { + $customer = $event->guard === 'customer'; + app(AuditLogger::class)->log( + $customer ? 'customer.failed_login' : 'auth.failed_login', + storeId: $this->currentStoreId(), + extra: ['email' => (string) ($event->credentials['email'] ?? '')], + ); + }); + Event::listen(Logout::class, function (Logout $event): void { + if ($event->user !== null) { + app(AuditLogger::class)->log( + $event->user instanceof Customer ? 'customer.logout' : 'auth.logout', + $event->user instanceof Customer ? null : (int) $event->user->getAuthIdentifier(), + $this->currentStoreId(), + ); + } + }); + } + + private function configureGates(): void + { + $definitions = [ + 'manage-store-settings' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-staff' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-developers' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'view-analytics' => [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff], + 'manage-shipping' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-taxes' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-search-settings' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-navigation' => [StoreUserRole::Owner, StoreUserRole::Admin], + 'manage-apps' => [StoreUserRole::Owner, StoreUserRole::Admin], + ]; + + foreach ($definitions as $ability => $roles) { + Gate::define($ability, function (User $user) use ($roles): bool { + if (! app()->bound('current_store')) { + return false; + } + $store = app('current_store'); + $store = $store instanceof Store ? $store : Store::query()->find($store); + $role = $store === null ? null : $user->roleForStore($store); + + return $role !== null && in_array($role, $roles, true); + }); + } + } + + private function currentStoreId(): ?int + { + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return (int) ($store instanceof \App\Models\Store ? $store->getKey() : $store); + } } diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php new file mode 100644 index 00000000..615ea2a1 --- /dev/null +++ b/app/Services/AuditLogger.php @@ -0,0 +1,33 @@ + $extra */ + public function log( + string $event, + ?int $userId = null, + ?int $storeId = null, + ?string $resourceType = null, + ?int $resourceId = null, + array $extra = [], + ): void { + $request = app()->bound('request') ? request() : null; + $context = [ + 'timestamp' => now()->toIso8601String(), + 'event' => $event, + 'user_id' => $userId, + 'store_id' => $storeId, + 'resource_type' => $resourceType, + 'resource_id' => $resourceId, + 'ip' => $request?->ip() ?? 'console', + 'user_agent' => (string) ($request?->userAgent() ?? 'console'), + ...$extra, + ]; + + Log::channel('audit')->info($event, $context); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php index 3ca5bd55..4738363c 100644 --- a/app/Services/CartService.php +++ b/app/Services/CartService.php @@ -66,10 +66,13 @@ public function addLine(Cart $cart, int|ProductVariant $variant, int $quantity = $this->positive($quantity); $variant = $variant instanceof ProductVariant ? $variant - : ProductVariant::withoutGlobalScopes()->with(['product', 'inventoryItem'])->findOrFail($variant); - $variant->loadMissing(['product', 'inventoryItem']); + : ProductVariant::withoutGlobalScopes()->findOrFail($variant); + $variant->load([ + 'product' => fn ($query) => $query->withoutGlobalScopes(), + 'inventoryItem' => fn ($query) => $query->withoutGlobalScopes(), + ]); - if ((int) $variant->product->store_id !== (int) $cart->store_id) { + if ($variant->product === null || (int) $variant->product->store_id !== (int) $cart->store_id) { throw new DomainException('The selected variant does not belong to this store.'); } diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php index 57bdaca0..409988b1 100644 --- a/app/Services/CheckoutService.php +++ b/app/Services/CheckoutService.php @@ -107,7 +107,10 @@ public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $pa { $this->assertState($checkout, ['shipping_selected']); $method = $paymentMethod instanceof PaymentMethod ? $paymentMethod : PaymentMethod::from($paymentMethod); - $checkout->loadMissing('cart.lines.variant.inventoryItem'); + $checkout->load([ + 'cart' => fn ($query) => $query->withoutGlobalScopes(), + 'cart.lines.variant.inventoryItem' => fn ($query) => $query->withoutGlobalScopes(), + ]); DB::transaction(function () use ($checkout, $method): void { foreach ($checkout->cart->lines as $line) { @@ -161,7 +164,10 @@ public function expireCheckout(Checkout $checkout): void private function releaseReservations(Checkout $checkout): void { - $checkout->loadMissing('cart.lines.variant.inventoryItem'); + $checkout->load([ + 'cart' => fn ($query) => $query->withoutGlobalScopes(), + 'cart.lines.variant.inventoryItem' => fn ($query) => $query->withoutGlobalScopes(), + ]); foreach ($checkout->cart->lines as $line) { if ($line->variant->inventoryItem !== null) { $this->inventory->release($line->variant->inventoryItem, (int) $line->quantity); diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php index f626a1e9..54849c99 100644 --- a/app/Services/CustomerService.php +++ b/app/Services/CustomerService.php @@ -10,6 +10,8 @@ final class CustomerService { + public function __construct(private readonly AuditLogger $audit) {} + /** @param array $data */ public function register(Store $store, array $data): Customer { @@ -20,12 +22,22 @@ public function register(Store $store, array $data): Customer 'marketing_opt_in' => ['sometimes', 'boolean'], ])->validate(); - return Customer::withoutGlobalScopes()->create([ + $customer = Customer::withoutGlobalScopes()->create([ 'store_id' => $store->id, 'name' => $validated['name'], 'email' => mb_strtolower($validated['email']), 'password_hash' => Hash::make($validated['password']), 'marketing_opt_in' => (bool) ($validated['marketing_opt_in'] ?? false), ]); + + $this->audit->log( + 'customer.registered', + storeId: (int) $store->id, + resourceType: 'customer', + resourceId: (int) $customer->id, + extra: ['customer_id' => (int) $customer->id], + ); + + return $customer; } } diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 367b8240..d446f9b5 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -9,11 +9,9 @@ use App\Models\Checkout; use App\Models\Customer; use App\Models\Order; -use App\Models\Payment; use App\Models\Store; use App\ValueObjects\PaymentResult; use BackedEnum; -use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; final class OrderService @@ -26,7 +24,12 @@ public function __construct( public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentResult = null): Order { return DB::transaction(function () use ($checkout, $paymentResult): Order { - $checkout = Checkout::withoutGlobalScopes()->lockForUpdate()->with(['cart.lines.variant.product', 'store'])->findOrFail($checkout->id); + $checkout = Checkout::withoutGlobalScopes()->lockForUpdate()->with([ + 'cart' => fn ($query) => $query->withoutGlobalScopes(), + 'cart.lines.variant.product' => fn ($query) => $query->withoutGlobalScopes(), + 'cart.lines.variant.inventoryItem' => fn ($query) => $query->withoutGlobalScopes(), + 'store', + ])->findOrFail($checkout->id); $snapshot = (array) ($checkout->totals_json ?? []); if (isset($snapshot['order_id'])) { return Order::withoutGlobalScopes()->findOrFail((int) $snapshot['order_id']); @@ -132,7 +135,7 @@ public function confirmBankTransfer(Order $order): void } DB::transaction(function () use ($order): void { - $order->load('lines.variant.inventoryItem'); + $order->load(['lines.variant.inventoryItem' => fn ($query) => $query->withoutGlobalScopes()]); foreach ($order->lines as $line) { if ($line->variant?->inventoryItem !== null) { $this->inventory->commit($line->variant->inventoryItem, (int) $line->quantity); @@ -153,7 +156,7 @@ public function cancel(Order $order, string $reason): void DB::transaction(function () use ($order, $reason): void { if ($this->value($order->financial_status) === 'pending') { - $order->load('lines.variant.inventoryItem'); + $order->load(['lines.variant.inventoryItem' => fn ($query) => $query->withoutGlobalScopes()]); foreach ($order->lines as $line) { if ($line->variant?->inventoryItem !== null) { $this->inventory->release($line->variant->inventoryItem, (int) $line->quantity); diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index aefe54d1..7ee55331 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -50,7 +50,7 @@ public function create(Store $store, array $data): Product 'position' => 0, 'status' => 'active', ]); - InventoryItem::withoutGlobalScopes()->firstOrCreate( + InventoryItem::withoutGlobalScopes()->updateOrCreate( ['variant_id' => $variant->id], ['store_id' => $store->id, 'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0), 'quantity_reserved' => 0, 'policy' => $variantData['policy'] ?? 'deny'], ); @@ -128,13 +128,44 @@ private function syncOptions(Product $product, array $options): void if (count($options) > 3) { throw new \InvalidArgumentException('A product may have at most three options.'); } - $product->options()->delete(); + + $existingOptions = $product->options()->with('values')->get(); + $keptOptionIds = []; + foreach (array_values($options) as $position => $optionData) { - $option = $product->options()->create(['name' => $optionData['name'], 'position' => $position]); - foreach (array_values((array) ($optionData['values'] ?? [])) as $valuePosition => $value) { - $option->values()->create(['value' => is_array($value) ? $value['value'] : $value, 'position' => $valuePosition]); + $name = trim((string) ($optionData['name'] ?? '')); + $values = array_values((array) ($optionData['values'] ?? [])); + if ($name === '' || $values === []) { + throw new \InvalidArgumentException('Every product option needs a name and at least one value.'); + } + + $option = isset($optionData['id']) + ? $existingOptions->firstWhere('id', (int) $optionData['id']) + : $existingOptions->first(fn ($candidate) => mb_strtolower($candidate->name) === mb_strtolower($name)); + $option ??= $product->options()->make(); + $option->fill(['name' => $name, 'position' => $position])->save(); + $keptOptionIds[] = $option->id; + + $existingValues = $option->values; + $keptValueIds = []; + foreach ($values as $valuePosition => $valueData) { + $value = trim((string) (is_array($valueData) ? ($valueData['value'] ?? '') : $valueData)); + if ($value === '') { + throw new \InvalidArgumentException('Product option values may not be empty.'); + } + + $optionValue = is_array($valueData) && isset($valueData['id']) + ? $existingValues->firstWhere('id', (int) $valueData['id']) + : $existingValues->first(fn ($candidate) => mb_strtolower($candidate->value) === mb_strtolower($value)); + $optionValue ??= $option->values()->make(); + $optionValue->fill(['value' => $value, 'position' => $valuePosition])->save(); + $keptValueIds[] = $optionValue->id; } + + $option->values()->whereNotIn('id', $keptValueIds)->delete(); } + + $product->options()->whereNotIn('id', $keptOptionIds)->delete(); } private function hasOrderReferences(Product $product): bool diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php index a28bf2a8..12eff580 100644 --- a/app/Services/RefundService.php +++ b/app/Services/RefundService.php @@ -8,6 +8,7 @@ use App\Models\Order; use App\Models\Payment; use App\Models\Refund; +use BackedEnum; use Illuminate\Support\Facades\DB; final class RefundService @@ -21,27 +22,63 @@ public function __construct( public function create( Order $order, Payment $payment, - int $amount, + ?int $amount = null, ?string $reason = null, bool $restock = false, ?array $lines = null, ): Refund { + if ((int) $payment->order_id !== (int) $order->id || ! in_array($this->value($payment->status), ['captured', 'refunded'], true)) { + throw new DomainException('The selected payment is not refundable for this order.'); + } + + $order->load('lines'); + $lineAmounts = []; + if ($lines !== null) { + foreach ($lines as $lineId => $quantity) { + $line = $order->lines->firstWhere('id', (int) $lineId); + $alreadyRefunded = $line === null ? 0 : (int) DB::table('refund_lines') + ->join('refunds', 'refunds.id', '=', 'refund_lines.refund_id') + ->where('refund_lines.order_line_id', $line->id) + ->where('refunds.status', 'processed') + ->sum('refund_lines.quantity'); + if ($line === null || $quantity < 1 || $quantity > (int) $line->quantity - $alreadyRefunded) { + throw new DomainException('A refund quantity exceeds the refundable line quantity.'); + } + $lineAmounts[(int) $lineId] = (int) round((int) $line->total_amount * $quantity / max(1, (int) $line->quantity)); + } + } + $refunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); $remaining = min((int) $order->total_amount, (int) $payment->amount) - $refunded; + $amount ??= $lineAmounts === [] ? $remaining : array_sum($lineAmounts); if ($amount < 1 || $amount > $remaining) { throw new DomainException('The refund amount exceeds the refundable balance.'); } - return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $lines): Refund { - $result = $this->provider->refund($payment, $amount); + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $lines, $lineAmounts): Refund { $refund = $order->refunds()->create([ 'payment_id' => $payment->id, 'amount' => $amount, 'reason' => $reason, + 'status' => 'pending', + ]); + foreach ($lines ?? [] as $lineId => $quantity) { + $refund->lines()->create([ + 'order_line_id' => (int) $lineId, + 'quantity' => $quantity, + 'amount' => $lineAmounts[(int) $lineId], + ]); + } + $result = $this->provider->refund($payment, $amount); + $refund->update([ 'status' => $result->success ? 'processed' : 'failed', 'provider_refund_id' => $result->providerRefundId, ]); + if (! $result->success) { + return $refund->refresh(); + } + $total = (int) $order->refunds()->where('status', 'processed')->sum('amount'); $full = $total >= $order->total_amount; $order->update(['financial_status' => $full ? 'refunded' : 'partially_refunded', 'status' => $full ? 'refunded' : $order->status]); @@ -50,7 +87,7 @@ public function create( } if ($restock) { - $order->load('lines.variant.inventoryItem'); + $order->load(['lines.variant.inventoryItem' => fn ($query) => $query->withoutGlobalScopes()]); foreach ($order->lines as $line) { $quantity = $lines === null ? (int) $line->quantity : (int) ($lines[$line->id] ?? 0); if ($quantity > 0 && $line->variant?->inventoryItem !== null) { @@ -60,7 +97,12 @@ public function create( } event(new OrderRefunded($order, $refund)); - return $refund->refresh(); + return $refund->refresh()->load('lines'); }); } + + private function value(mixed $value): string + { + return $value instanceof BackedEnum ? (string) $value->value : (string) $value; + } } diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php index 30ec6ed1..a2ed1a42 100644 --- a/app/Services/VariantMatrixService.php +++ b/app/Services/VariantMatrixService.php @@ -2,11 +2,9 @@ namespace App\Services; -use App\Models\InventoryItem; use App\Models\OrderLine; use App\Models\Product; use App\Models\ProductVariant; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; final class VariantMatrixService @@ -17,15 +15,18 @@ public function rebuildMatrix(Product $product): void $product->load(['options.values', 'variants.optionValues', 'variants.inventoryItem']); $groups = $product->options->sortBy('position')->map(fn ($option) => $option->values->sortBy('position')->pluck('id')->all())->all(); $combinations = $groups === [] ? [[]] : $this->cartesian(array_values($groups)); - $existing = $product->variants->keyBy(fn (ProductVariant $variant): string => $this->key($variant->optionValues->modelKeys())); + $existing = $product->variants; $template = $product->variants->first(); - $desiredKeys = []; + $matchedVariantIds = []; foreach ($combinations as $position => $combination) { $key = $this->key($combination); - $desiredKeys[] = $key; - if ($existing->has($key)) { - $existing[$key]->update(['position' => $position, 'status' => 'active', 'is_default' => $groups === []]); + $matchingVariant = $existing->first(fn (ProductVariant $variant): bool => ! in_array($variant->id, $matchedVariantIds, true) + && $this->key($variant->optionValues->modelKeys()) === $key); + if ($matchingVariant !== null) { + $matchingVariant->update(['position' => $position, 'status' => 'active', 'is_default' => $groups === []]); + $matchedVariantIds[] = $matchingVariant->id; + continue; } @@ -44,17 +45,11 @@ public function rebuildMatrix(Product $product): void if ($combination !== []) { $variant->optionValues()->sync($combination); } - InventoryItem::withoutGlobalScopes()->create([ - 'store_id' => $product->store_id, - 'variant_id' => $variant->id, - 'quantity_on_hand' => 0, - 'quantity_reserved' => 0, - 'policy' => 'deny', - ]); + $matchedVariantIds[] = $variant->id; } - foreach ($existing as $key => $variant) { - if (in_array($key, $desiredKeys, true)) { + foreach ($existing as $variant) { + if (in_array($variant->id, $matchedVariantIds, true)) { continue; } if (OrderLine::query()->where('variant_id', $variant->id)->exists()) { diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..0a1b70eb --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,40 @@ +find($storeId); + + return $store === null ? null : $user->roleForStore($store); + } + + /** @param list $roles */ + public function hasRole(User $user, int $storeId, array $roles): bool + { + $role = $this->getStoreRole($user, $storeId); + + return $role !== null && in_array($role, $roles, true); + } + + public function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]); + } + + public function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 9093f7ea..3b657069 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,11 +1,14 @@ CheckAbilities::class, ]); $middleware->group('storefront', [ResolveStore::class]); + $middleware->group('storefront.api', [ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ResolveStore::class, + ]); $middleware->group('admin.store', [ResolveStore::class]); $middleware->redirectGuestsTo(fn ($request): string => $request->is('account', 'account/*') ? '/account/login' diff --git a/config/cache.php b/config/cache.php index b32aead2..9289977f 100644 --- a/config/cache.php +++ b/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'file'), /* |-------------------------------------------------------------------------- diff --git a/config/logging.php b/config/logging.php index 9e998a49..31a0fd15 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,5 +1,6 @@ true, ], + 'json' => [ + 'driver' => 'monolog', + 'handler' => StreamHandler::class, + 'handler_with' => ['stream' => storage_path('logs/shop.json')], + 'formatter' => JsonFormatter::class, + 'level' => env('LOG_LEVEL', 'debug'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'audit' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/audit.log'), + 'level' => 'info', + 'days' => 90, + 'replace_placeholders' => true, + ], + 'slack' => [ 'driver' => 'slack', 'url' => env('LOG_SLACK_WEBHOOK_URL'), diff --git a/config/queue.php b/config/queue.php index 79c2c0a2..d0e0f50e 100644 --- a/config/queue.php +++ b/config/queue.php @@ -13,7 +13,7 @@ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'sync'), /* |-------------------------------------------------------------------------- diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..222ac24e --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,22 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + ))), + 'guard' => ['web'], + 'expiration' => (int) env('SANCTUM_EXPIRATION', 525600), + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'shop_'), + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], +]; diff --git a/config/session.php b/config/session.php index 5b541b75..e6197a0f 100644 --- a/config/session.php +++ b/config/session.php @@ -18,7 +18,7 @@ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'file'), /* |-------------------------------------------------------------------------- diff --git a/config/shop.php b/config/shop.php new file mode 100644 index 00000000..2f3342f9 --- /dev/null +++ b/config/shop.php @@ -0,0 +1,11 @@ + [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + ], + 'checkout_expiration_hours' => 24, + 'cart_abandon_days' => 14, +]; diff --git a/database/migrations/2026_01_01_000800_create_order_exports_table.php b/database/migrations/2026_01_01_000800_create_order_exports_table.php new file mode 100644 index 00000000..bd4ee41e --- /dev/null +++ b/database/migrations/2026_01_01_000800_create_order_exports_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('format')->default('csv'); + $table->json('filters_json')->default('{}'); + $table->enum('status', ['queued', 'processing', 'completed', 'failed'])->default('queued'); + $table->unsignedInteger('row_count')->default(0); + $table->string('storage_key')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->index(['store_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_exports'); + } +}; diff --git a/database/migrations/2026_01_01_000810_enforce_store_owner_uniqueness.php b/database/migrations/2026_01_01_000810_enforce_store_owner_uniqueness.php new file mode 100644 index 00000000..9ca04998 --- /dev/null +++ b/database/migrations/2026_01_01_000810_enforce_store_owner_uniqueness.php @@ -0,0 +1,17 @@ +id(); + $table->foreignId('refund_id')->constrained()->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->integer('amount')->default(0); + $table->unique(['refund_id', 'order_line_id']); + $table->index('order_line_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('refund_lines'); + } +}; diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index 2621e458..d0825a72 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -7,7 +7,6 @@ use App\Models\Product; use App\Models\Store; use Illuminate\Database\Seeder; -use Illuminate\Support\Str; class ProductSeeder extends Seeder { @@ -95,13 +94,15 @@ private function seedProduct(Store $store, array $definition, int $productPositi if ($combination !== []) { $variant->optionValues()->sync(collect($combination)->pluck('id')->all()); } - InventoryItem::withoutGlobalScopes()->create([ - 'store_id' => $store->id, - 'variant_id' => $variant->id, - 'quantity_on_hand' => $inventory, - 'quantity_reserved' => 0, - 'policy' => $policy, - ]); + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $store->id, + 'quantity_on_hand' => $inventory, + 'quantity_reserved' => 0, + 'policy' => $policy, + ], + ); } } diff --git a/routes/api.php b/routes/api.php index 326ea580..067c2b0f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,21 +1,22 @@ middleware(['store.resolve', 'throttle:api.storefront'])->group(function (): void { +Route::prefix('storefront/v1')->middleware(['storefront.api', 'throttle:api.storefront'])->group(function (): void { Route::post('carts', [CartController::class, 'store']); Route::get('carts/{cartId}', [CartController::class, 'show']); Route::post('carts/{cartId}/lines', [CartController::class, 'addLine']); @@ -52,6 +53,7 @@ Route::get('products/{productId}', [AdminProductController::class, 'show'])->middleware('abilities:read-products'); Route::put('products/{productId}', [AdminProductController::class, 'update'])->middleware('abilities:write-products'); Route::delete('products/{productId}', [AdminProductController::class, 'destroy'])->middleware('abilities:write-products'); + Route::post('products/{productId}/media/presign-upload', [AdminMediaController::class, 'presign'])->middleware('abilities:write-products'); Route::get('collections', [AdminCollectionController::class, 'index'])->middleware('abilities:read-collections'); Route::post('collections', [AdminCollectionController::class, 'store'])->middleware('abilities:write-collections'); @@ -87,5 +89,16 @@ Route::get('search/status', [AdminContentController::class, 'searchStatus'])->middleware('abilities:read-settings'); Route::get('analytics/summary', [AdminAnalyticsController::class, 'summary'])->middleware('abilities:read-analytics'); Route::post('exports/orders', [AdminAnalyticsController::class, 'exportOrders'])->middleware('abilities:read-orders'); + Route::get('exports/{exportId}', [AdminAnalyticsController::class, 'export'])->middleware('abilities:read-orders')->name('api.admin.exports.show'); }); }); + +Route::put('admin/v1/media/{media}/upload', [AdminMediaController::class, 'upload']) + ->middleware('signed') + ->name('api.media.upload'); + +Route::prefix('apps/v1')->group(function (): void { + Route::get('stores/{storeId}/{resource}', fn () => response()->json([ + 'message' => 'The OAuth app ecosystem is not implemented in this release.', + ], 501))->where('resource', 'products|orders|customers'); +}); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..420d1c11 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,18 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes()->withoutOverlapping(); +Schedule::job(new CleanupAbandonedCarts)->dailyAt('00:30')->withoutOverlapping(); +Schedule::job(new AggregateAnalytics)->dailyAt('01:00')->withoutOverlapping(); +Schedule::job(new CancelUnpaidBankTransferOrders)->dailyAt('02:00')->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php index 167a997a..b206ec67 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,9 +17,17 @@ use App\Livewire\Storefront\Pages\Show as Page; use App\Livewire\Storefront\Products\Show as Product; use App\Livewire\Storefront\Search\Index as Search; +use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; +Route::get('/oauth/authorize', fn () => response()->json([ + 'message' => 'The OAuth app ecosystem is not implemented in this release.', +], 501)); +Route::post('/oauth/token', fn () => response()->json([ + 'message' => 'The OAuth app ecosystem is not implemented in this release.', +], 501))->withoutMiddleware(ValidateCsrfToken::class); + Route::middleware('storefront')->group(function (): void { Route::get('/', Home::class)->name('storefront.home'); Route::get('/collections', Collections::class)->name('storefront.collections.index'); diff --git a/specs/progress.md b/specs/progress.md index 1040b097..656c15b6 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -12,13 +12,13 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro | --- | --- | --- | | 0. Specification traceability and repository audit | Complete | Specs and starter audited by backend, frontend, and QA roles | | 1. Foundation, tenancy, authentication, authorization | Complete | All migrations pass; tenancy/auth infrastructure and factories are present | -| 2. Catalog, inventory, collections, media | In progress | Schema/models/factories/demo catalog complete; behavior tests pending | +| 2. Catalog, inventory, collections, media | Complete | Catalog/cart/pricing and media behavior suites pass | | 3. Themes, CMS, navigation, storefront shell | Complete | Blade compilation, Vite build, and live Herd render smoke pass | -| 4. Cart, checkout, discounts, shipping, taxes | In progress | Customer UI and services integrated; full Pest/Chrome flow pending | -| 5. Payments, orders, refunds, fulfillment | Pending | Pest unit/feature + Chrome | -| 6. Customer accounts | Pending | Pest feature + Chrome | -| 7. Admin panel | Pending | Pest feature + Chrome | -| 8. Search, analytics, apps, webhooks | Pending | Pest unit/feature | +| 4. Cart, checkout, discounts, shipping, taxes | In progress | Domain and storefront API suites pass; Chrome flow pending | +| 5. Payments, orders, refunds, fulfillment | In progress | Lifecycle suite passes; admin UI and Chrome pending | +| 6. Customer accounts | In progress | Tenant auth/reset/address/order suite passes; Chrome pending | +| 7. Admin panel | In progress | Full resource surface under implementation and runtime smoke QA | +| 8. Search, analytics, apps, webhooks | In progress | Search/analytics/media/webhook/job suite passes; admin UI pending | | 9. Accessibility, responsive, dark mode, error states | Pending | Build + Chrome review | | 10. Full quality and acceptance verification | Pending | Pint, quality checker, all Pest, fresh seed, routes, config cache, Chrome | @@ -51,13 +51,23 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro - Registered the complete storefront/customer/checkout web route surface. - Verified all Blade views compile, the Tailwind/Vite production build succeeds, and live Herd requests to `shop.test/` and the seeded product detail return 200 with correct tenant/product content. +### Iteration 3 - Domain hardening and automated acceptance + +- Enforced automatic one-to-one inventory creation and per-store SKU uniqueness for every variant. +- Reworked option synchronization and matrix rebuilding so unchanged variants preserve identity, price, SKU, and inventory while removed duplicate combinations are archived or deleted correctly. +- Hardened carts, checkout expiry, unpaid bank-transfer cancellation, abandoned-cart cleanup, refunds, and order inventory work against stale tenant context in cross-store scheduled jobs. +- Added structured audit logging, auth/model audit listeners, order lifecycle notifications, webhook/analytics subscribers, image resizing in source and WebP formats, and media cleanup. +- Added asynchronous order export status/download support, direct media upload URLs, line-level refund tracking, OAuth/app 501 placeholders, exact policy abilities, reusable role gates, Sanctum token prefix/expiry, and single-owner database enforcement. +- Restored stateful storefront API sessions and stable documented 422 errors for invalid variants, inventory, shipping, discounts, and checkout transitions. +- Verified focused Pest suites for catalog/cart/pricing, checkout/order lifecycle, customer accounts, search/analytics/media/webhooks/jobs, and the complete storefront REST API. All currently pass. + ## Verification Ledger | Check | Latest Result | | --- | --- | | Working tree at start | Clean | | Existing shop code reused | No | -| Pest suite | Baseline not yet run against integrated shop | +| Pest suite | All implemented focused shop slices pass; full integrated run pending admin completion | | Vite production build | Passed | | Fresh migrate and seed | Passed (all migrations and all 18 seed stages) | | Laravel code-quality checker | Not installed yet | @@ -67,4 +77,4 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro - SQLite FTS5 is available and its migration passes locally. - `shop.test`, `acme-fashion.test`, and `acme-electronics.test` are seeded; live browser host routing remains to be verified. -- The specification spans roughly 320-380 files; traceability and staged verification are required to prevent late integration defects. +- Final integrated quality scan and visible Chrome customer/admin review remain pending until the admin surface is route-complete. From 3fd11f98dd014095dcb0f1ab30018244c5fdb4bf Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 23:50:47 +0200 Subject: [PATCH 06/10] feat: complete admin security and acceptance hardening --- .env.example | 20 +- app/Actions/SanitizeHtml.php | 100 + app/Auth/CustomerUserProvider.php | 50 +- .../CodeQuality/CheckCodeQualityCommand.php | 148 + app/Contracts/DnsResolver.php | 9 + app/Events/FulfillmentShipped.php | 5 +- app/Events/OrderRefunded.php | 6 +- .../UnsafeWebhookTargetException.php | 7 + .../Api/Admin/AnalyticsController.php | 6 +- .../Api/Admin/CollectionController.php | 34 +- .../Api/Admin/ContentController.php | 81 +- .../Api/Admin/DiscountController.php | 19 +- .../Controllers/Api/Admin/MediaController.php | 3 +- .../Controllers/Api/Admin/OrderController.php | 49 +- .../Api/Admin/ProductController.php | 138 +- .../Api/Admin/SettingsController.php | 17 +- .../Api/Storefront/AnalyticsController.php | 42 +- .../Api/Storefront/CartController.php | 80 +- .../Api/Storefront/CheckoutController.php | 208 +- .../Api/Storefront/OrderController.php | 6 +- .../Api/Storefront/SearchController.php | 76 +- app/Http/Controllers/Controller.php | 4 +- app/Http/Middleware/EnsureUserIsActive.php | 45 + app/Http/Middleware/ResolveStore.php | 14 +- app/Http/Requests/ApplyDiscountRequest.php | 18 + .../Requests/CreateFulfillmentRequest.php | 16 +- app/Http/Requests/CreateRefundRequest.php | 8 +- app/Http/Requests/InviteStaffRequest.php | 19 + app/Http/Requests/LoginRequest.php | 18 + app/Http/Requests/StoreCollectionRequest.php | 8 +- .../Requests/StoreCustomerAddressRequest.php | 3 +- app/Http/Requests/StoreDiscountRequest.php | 3 +- app/Http/Requests/StorePageRequest.php | 3 +- app/Http/Requests/StoreProductRequest.php | 3 +- .../Requests/StoreShippingRateRequest.php | 3 +- .../Requests/StoreShippingZoneRequest.php | 3 +- app/Http/Requests/UpdateCollectionRequest.php | 12 + app/Http/Requests/UpdateDiscountRequest.php | 12 + app/Http/Requests/UpdatePageRequest.php | 12 + app/Http/Requests/UpdateProductRequest.php | 12 + .../Requests/UpdateStoreSettingsRequest.php | 24 + .../Requests/UpdateTaxSettingsRequest.php | 3 +- app/Jobs/AggregateAnalytics.php | 7 + app/Jobs/CancelUnpaidBankTransferOrders.php | 35 +- app/Jobs/CleanupAbandonedCarts.php | 31 +- app/Jobs/Concerns/RestoresCurrentStore.php | 24 + app/Jobs/DeliverWebhook.php | 22 +- app/Jobs/ExpireAbandonedCheckouts.php | 29 +- app/Jobs/GenerateOrderExport.php | 18 +- app/Jobs/ProcessMediaUpload.php | 5 + app/Jobs/ReindexProducts.php | 77 + app/Listeners/ShopEventSubscriber.php | 65 +- app/Livewire/Actions/Logout.php | 2 +- app/Livewire/Admin/AdminComponent.php | 80 + app/Livewire/Admin/Analytics/Index.php | 194 + app/Livewire/Admin/Apps/Index.php | 67 + app/Livewire/Admin/Apps/Show.php | 78 + app/Livewire/Admin/Auth/ForgotPassword.php | 26 + app/Livewire/Admin/Auth/Login.php | 80 + app/Livewire/Admin/Auth/ResetPassword.php | 59 + app/Livewire/Admin/Collections/Form.php | 134 + app/Livewire/Admin/Collections/Index.php | 57 + app/Livewire/Admin/Customers/Index.php | 41 + app/Livewire/Admin/Customers/Show.php | 129 + app/Livewire/Admin/Dashboard.php | 156 + app/Livewire/Admin/Developers/Index.php | 176 + app/Livewire/Admin/Discounts/Form.php | 178 + app/Livewire/Admin/Discounts/Index.php | 59 + app/Livewire/Admin/Inventory/Index.php | 70 + app/Livewire/Admin/Layout/Sidebar.php | 33 + app/Livewire/Admin/Layout/TopBar.php | 56 + app/Livewire/Admin/Navigation/Index.php | 213 + app/Livewire/Admin/Orders/Index.php | 70 + app/Livewire/Admin/Orders/Show.php | 154 + app/Livewire/Admin/Pages/Form.php | 102 + app/Livewire/Admin/Pages/Index.php | 42 + app/Livewire/Admin/Products/Form.php | 327 ++ app/Livewire/Admin/Products/Index.php | 144 + app/Livewire/Admin/Search/Settings.php | 121 + app/Livewire/Admin/Settings/Checkout.php | 60 + app/Livewire/Admin/Settings/Index.php | 137 + app/Livewire/Admin/Settings/Notifications.php | 65 + app/Livewire/Admin/Settings/Shipping.php | 174 + app/Livewire/Admin/Settings/Taxes.php | 105 + app/Livewire/Admin/Themes/Editor.php | 200 + app/Livewire/Admin/Themes/Index.php | 109 + .../Storefront/Account/Auth/Login.php | 7 +- .../Storefront/Account/Auth/Register.php | 14 +- .../Storefront/Checkout/Confirmation.php | 10 +- app/Livewire/Storefront/Checkout/Show.php | 12 + .../Storefront/StorefrontComponent.php | 16 +- app/Models/Fulfillment.php | 10 + app/Models/NavigationItem.php | 10 + app/Models/Page.php | 6 + app/Models/Payment.php | 2 + app/Models/Product.php | 6 + app/Models/ThemeSettings.php | 16 +- app/Models/User.php | 6 +- .../OrderLifecycleNotification.php | 3 +- app/Observers/StoreDomainObserver.php | 23 + app/Observers/ThemeCacheObserver.php | 30 + app/Policies/StorePolicy.php | 5 + app/Providers/AppServiceProvider.php | 25 +- app/Providers/FortifyServiceProvider.php | 19 +- app/Services/CartService.php | 77 +- app/Services/CheckoutService.php | 142 +- app/Services/CodeQuality/AiReviewRequest.php | 24 + app/Services/CodeQuality/AiReviewResult.php | 16 + app/Services/CodeQuality/CheckContext.php | 36 + app/Services/CodeQuality/CheckRegistry.php | 775 ++++ app/Services/CodeQuality/CheckResult.php | 61 + .../CodeQuality/Checks/AiCodeQualityCheck.php | 85 + .../Checks/PatternCodeQualityCheck.php | 155 + .../CodeQuality/CodeQualityException.php | 38 + .../CodeQuality/CodeQualityPipeline.php | 172 + .../CodeQuality/CodeQualityRunOptions.php | 40 + app/Services/CodeQuality/CodeQualityScope.php | 80 + .../CodeQuality/Contracts/AiReviewer.php | 11 + .../Contracts/CodeQualityCheck.php | 21 + app/Services/CodeQuality/Findings/Finding.php | 158 + app/Services/CodeQuality/Findings/Report.php | 95 + .../CodeQuality/Output/JsonReportWriter.php | 13 + .../CodeQuality/Output/JsonlReportWriter.php | 34 + .../CodeQuality/Output/PrettyReportWriter.php | 52 + .../CodeQuality/Runners/CodexAiRunner.php | 247 ++ .../CodeQuality/Runners/ProcessResult.php | 22 + .../CodeQuality/Runners/ProcessRunner.php | 34 + app/Services/CodeQuality/ScopeResolver.php | 205 + app/Services/CodeQuality/Support/Severity.php | 52 + app/Services/CustomerService.php | 46 +- app/Services/DiscountService.php | 48 + app/Services/FulfillmentService.php | 6 +- app/Services/NativeDnsResolver.php | 31 + app/Services/OrderService.php | 78 +- app/Services/OrderStatusLink.php | 41 + app/Services/OutboundDispatcher.php | 30 + app/Services/PricingEngine.php | 109 +- app/Services/ProductService.php | 241 +- app/Services/RefundService.php | 39 +- app/Services/SearchService.php | 118 +- app/Services/Tax/ManualTaxProvider.php | 21 +- app/Services/Tax/StripeTaxProvider.php | 9 +- app/Services/ThemeArchiveService.php | 134 + app/Services/ThemeSettingsService.php | 18 + app/Services/VariantMatrixService.php | 10 +- app/Services/WebhookService.php | 39 +- app/Services/WebhookTargetValidator.php | 96 + app/Support/SafeUrl.php | 70 + app/ValueObjects/TaxCalculationResult.php | 19 +- app/ValueObjects/ValidatedWebhookTarget.php | 20 + bootstrap/app.php | 5 + composer.lock | 3618 +++++++++++------ config/code_quality.php | 89 + config/cors.php | 12 + config/session.php | 6 +- package-lock.json | 977 ++--- package.json | 14 +- resources/css/app.css | 151 + .../views/admin/analytics/index.blade.php | 17 + resources/views/admin/apps/index.blade.php | 5 + resources/views/admin/apps/show.blade.php | 25 + .../admin/auth/forgot-password.blade.php | 13 + resources/views/admin/auth/login.blade.php | 16 + .../views/admin/auth/reset-password.blade.php | 9 + .../views/admin/collections/form.blade.php | 19 + .../views/admin/collections/index.blade.php | 14 + .../views/admin/customers/index.blade.php | 10 + .../views/admin/customers/show.blade.php | 18 + resources/views/admin/dashboard.blade.php | 66 + .../views/admin/developers/index.blade.php | 28 + .../views/admin/discounts/form.blade.php | 17 + .../views/admin/discounts/index.blade.php | 8 + .../views/admin/inventory/index.blade.php | 12 + .../views/admin/layout/sidebar.blade.php | 43 + .../views/admin/layout/top-bar.blade.php | 36 + resources/views/admin/layouts/app.blade.php | 57 + resources/views/admin/layouts/auth.blade.php | 26 + .../views/admin/navigation/index.blade.php | 9 + resources/views/admin/orders/index.blade.php | 11 + resources/views/admin/orders/show.blade.php | 45 + resources/views/admin/pages/form.blade.php | 8 + resources/views/admin/pages/index.blade.php | 10 + resources/views/admin/products/form.blade.php | 71 + .../views/admin/products/index.blade.php | 48 + .../views/admin/search/settings.blade.php | 7 + .../views/admin/settings/checkout.blade.php | 9 + .../views/admin/settings/index.blade.php | 40 + .../admin/settings/notifications.blade.php | 9 + .../views/admin/settings/shipping.blade.php | 23 + .../views/admin/settings/taxes.blade.php | 8 + resources/views/admin/themes/editor.blade.php | 16 + resources/views/admin/themes/index.blade.php | 17 + .../components/admin/breadcrumbs.blade.php | 41 + .../views/components/admin/card.blade.php | 40 + .../admin/confirmation-modal.blade.php | 56 + .../components/admin/empty-state.blade.php | 25 + .../views/components/admin/field.blade.php | 33 + .../components/admin/form-section.blade.php | 17 + .../components/admin/list-toolbar.blade.php | 30 + .../admin/loading-overlay.blade.php | 19 + .../views/components/admin/money.blade.php | 14 + .../components/admin/page-header.blade.php | 25 + .../components/admin/status-badge.blade.php | 43 + .../admin/sticky-save-bar.blade.php | 56 + .../components/admin/table-empty.blade.php | 29 + .../components/admin/table-loading.blade.php | 17 + .../components/admin/table-shell.blade.php | 36 + .../views/components/admin/tabs.blade.php | 49 + .../admin/toast-container.blade.php | 69 + .../checkout/confirmation.blade.php | 2 +- routes/api.php | 80 +- routes/web.php | 82 + specs/progress.md | 34 +- tests/Feature/Auth/AuthenticationTest.php | 7 +- tests/Feature/Auth/PasswordResetTest.php | 53 +- tests/Feature/CodeQualityCheckCommandTest.php | 295 ++ tests/Feature/ExampleTest.php | 9 +- tests/Feature/Shop/AdminApiTest.php | 419 ++ tests/Feature/Shop/AdminUiTest.php | 547 +++ ...uthExportAuthorizationRequirementsTest.php | 301 ++ tests/Feature/Shop/CatalogCartPricingTest.php | 435 ++ .../Shop/CheckoutOrderLifecycleTest.php | 390 ++ tests/Feature/Shop/CommerceIntegrityTest.php | 258 ++ tests/Feature/Shop/CustomerAccountsTest.php | 225 + .../Feature/Shop/ReliabilitySecurityTest.php | 303 ++ tests/Feature/Shop/SecurityAcceptanceTest.php | 99 + tests/Feature/Shop/SecurityHardeningTest.php | 161 + .../SideEffectsSearchAnalyticsJobsTest.php | 451 ++ tests/Feature/Shop/StorefrontApiTest.php | 539 +++ .../Shop/TenancyAndAuthorizationTest.php | 216 + .../Shop/ThemeArchiveAndSearchStatusTest.php | 355 ++ tests/Fixtures/CodeQuality/CleanService.php | 11 + .../Fixtures/CodeQuality/EnvOutsideConfig.php | 11 + .../CodeQuality/HeartbeatAiReference.php | 13 + .../CodeQuality/frontend-hardcoded-route.tsx | 5 + tests/Pest.php | 288 +- tests/Unit/ShopCalculatorsTest.php | 199 + 237 files changed, 19164 insertions(+), 2311 deletions(-) create mode 100644 app/Actions/SanitizeHtml.php create mode 100644 app/Console/Commands/CodeQuality/CheckCodeQualityCommand.php create mode 100644 app/Contracts/DnsResolver.php create mode 100644 app/Exceptions/UnsafeWebhookTargetException.php create mode 100644 app/Http/Middleware/EnsureUserIsActive.php create mode 100644 app/Http/Requests/ApplyDiscountRequest.php create mode 100644 app/Http/Requests/InviteStaffRequest.php create mode 100644 app/Http/Requests/LoginRequest.php create mode 100644 app/Http/Requests/UpdateStoreSettingsRequest.php create mode 100644 app/Jobs/Concerns/RestoresCurrentStore.php create mode 100644 app/Jobs/ReindexProducts.php create mode 100644 app/Livewire/Admin/AdminComponent.php create mode 100644 app/Livewire/Admin/Analytics/Index.php create mode 100644 app/Livewire/Admin/Apps/Index.php create mode 100644 app/Livewire/Admin/Apps/Show.php create mode 100644 app/Livewire/Admin/Auth/ForgotPassword.php create mode 100644 app/Livewire/Admin/Auth/Login.php create mode 100644 app/Livewire/Admin/Auth/ResetPassword.php create mode 100644 app/Livewire/Admin/Collections/Form.php create mode 100644 app/Livewire/Admin/Collections/Index.php create mode 100644 app/Livewire/Admin/Customers/Index.php create mode 100644 app/Livewire/Admin/Customers/Show.php create mode 100644 app/Livewire/Admin/Dashboard.php create mode 100644 app/Livewire/Admin/Developers/Index.php create mode 100644 app/Livewire/Admin/Discounts/Form.php create mode 100644 app/Livewire/Admin/Discounts/Index.php create mode 100644 app/Livewire/Admin/Inventory/Index.php create mode 100644 app/Livewire/Admin/Layout/Sidebar.php create mode 100644 app/Livewire/Admin/Layout/TopBar.php create mode 100644 app/Livewire/Admin/Navigation/Index.php create mode 100644 app/Livewire/Admin/Orders/Index.php create mode 100644 app/Livewire/Admin/Orders/Show.php create mode 100644 app/Livewire/Admin/Pages/Form.php create mode 100644 app/Livewire/Admin/Pages/Index.php create mode 100644 app/Livewire/Admin/Products/Form.php create mode 100644 app/Livewire/Admin/Products/Index.php create mode 100644 app/Livewire/Admin/Search/Settings.php create mode 100644 app/Livewire/Admin/Settings/Checkout.php create mode 100644 app/Livewire/Admin/Settings/Index.php create mode 100644 app/Livewire/Admin/Settings/Notifications.php create mode 100644 app/Livewire/Admin/Settings/Shipping.php create mode 100644 app/Livewire/Admin/Settings/Taxes.php create mode 100644 app/Livewire/Admin/Themes/Editor.php create mode 100644 app/Livewire/Admin/Themes/Index.php create mode 100644 app/Observers/StoreDomainObserver.php create mode 100644 app/Observers/ThemeCacheObserver.php create mode 100644 app/Services/CodeQuality/AiReviewRequest.php create mode 100644 app/Services/CodeQuality/AiReviewResult.php create mode 100644 app/Services/CodeQuality/CheckContext.php create mode 100644 app/Services/CodeQuality/CheckRegistry.php create mode 100644 app/Services/CodeQuality/CheckResult.php create mode 100644 app/Services/CodeQuality/Checks/AiCodeQualityCheck.php create mode 100644 app/Services/CodeQuality/Checks/PatternCodeQualityCheck.php create mode 100644 app/Services/CodeQuality/CodeQualityException.php create mode 100644 app/Services/CodeQuality/CodeQualityPipeline.php create mode 100644 app/Services/CodeQuality/CodeQualityRunOptions.php create mode 100644 app/Services/CodeQuality/CodeQualityScope.php create mode 100644 app/Services/CodeQuality/Contracts/AiReviewer.php create mode 100644 app/Services/CodeQuality/Contracts/CodeQualityCheck.php create mode 100644 app/Services/CodeQuality/Findings/Finding.php create mode 100644 app/Services/CodeQuality/Findings/Report.php create mode 100644 app/Services/CodeQuality/Output/JsonReportWriter.php create mode 100644 app/Services/CodeQuality/Output/JsonlReportWriter.php create mode 100644 app/Services/CodeQuality/Output/PrettyReportWriter.php create mode 100644 app/Services/CodeQuality/Runners/CodexAiRunner.php create mode 100644 app/Services/CodeQuality/Runners/ProcessResult.php create mode 100644 app/Services/CodeQuality/Runners/ProcessRunner.php create mode 100644 app/Services/CodeQuality/ScopeResolver.php create mode 100644 app/Services/CodeQuality/Support/Severity.php create mode 100644 app/Services/NativeDnsResolver.php create mode 100644 app/Services/OrderStatusLink.php create mode 100644 app/Services/OutboundDispatcher.php create mode 100644 app/Services/ThemeArchiveService.php create mode 100644 app/Services/WebhookTargetValidator.php create mode 100644 app/Support/SafeUrl.php create mode 100644 app/ValueObjects/ValidatedWebhookTarget.php create mode 100644 config/code_quality.php create mode 100644 config/cors.php create mode 100644 resources/views/admin/analytics/index.blade.php create mode 100644 resources/views/admin/apps/index.blade.php create mode 100644 resources/views/admin/apps/show.blade.php create mode 100644 resources/views/admin/auth/forgot-password.blade.php create mode 100644 resources/views/admin/auth/login.blade.php create mode 100644 resources/views/admin/auth/reset-password.blade.php create mode 100644 resources/views/admin/collections/form.blade.php create mode 100644 resources/views/admin/collections/index.blade.php create mode 100644 resources/views/admin/customers/index.blade.php create mode 100644 resources/views/admin/customers/show.blade.php create mode 100644 resources/views/admin/dashboard.blade.php create mode 100644 resources/views/admin/developers/index.blade.php create mode 100644 resources/views/admin/discounts/form.blade.php create mode 100644 resources/views/admin/discounts/index.blade.php create mode 100644 resources/views/admin/inventory/index.blade.php create mode 100644 resources/views/admin/layout/sidebar.blade.php create mode 100644 resources/views/admin/layout/top-bar.blade.php create mode 100644 resources/views/admin/layouts/app.blade.php create mode 100644 resources/views/admin/layouts/auth.blade.php create mode 100644 resources/views/admin/navigation/index.blade.php create mode 100644 resources/views/admin/orders/index.blade.php create mode 100644 resources/views/admin/orders/show.blade.php create mode 100644 resources/views/admin/pages/form.blade.php create mode 100644 resources/views/admin/pages/index.blade.php create mode 100644 resources/views/admin/products/form.blade.php create mode 100644 resources/views/admin/products/index.blade.php create mode 100644 resources/views/admin/search/settings.blade.php create mode 100644 resources/views/admin/settings/checkout.blade.php create mode 100644 resources/views/admin/settings/index.blade.php create mode 100644 resources/views/admin/settings/notifications.blade.php create mode 100644 resources/views/admin/settings/shipping.blade.php create mode 100644 resources/views/admin/settings/taxes.blade.php create mode 100644 resources/views/admin/themes/editor.blade.php create mode 100644 resources/views/admin/themes/index.blade.php create mode 100644 resources/views/components/admin/breadcrumbs.blade.php create mode 100644 resources/views/components/admin/card.blade.php create mode 100644 resources/views/components/admin/confirmation-modal.blade.php create mode 100644 resources/views/components/admin/empty-state.blade.php create mode 100644 resources/views/components/admin/field.blade.php create mode 100644 resources/views/components/admin/form-section.blade.php create mode 100644 resources/views/components/admin/list-toolbar.blade.php create mode 100644 resources/views/components/admin/loading-overlay.blade.php create mode 100644 resources/views/components/admin/money.blade.php create mode 100644 resources/views/components/admin/page-header.blade.php create mode 100644 resources/views/components/admin/status-badge.blade.php create mode 100644 resources/views/components/admin/sticky-save-bar.blade.php create mode 100644 resources/views/components/admin/table-empty.blade.php create mode 100644 resources/views/components/admin/table-loading.blade.php create mode 100644 resources/views/components/admin/table-shell.blade.php create mode 100644 resources/views/components/admin/tabs.blade.php create mode 100644 resources/views/components/admin/toast-container.blade.php create mode 100644 tests/Feature/CodeQualityCheckCommandTest.php create mode 100644 tests/Feature/Shop/AdminApiTest.php create mode 100644 tests/Feature/Shop/AdminUiTest.php create mode 100644 tests/Feature/Shop/AuthExportAuthorizationRequirementsTest.php create mode 100644 tests/Feature/Shop/CatalogCartPricingTest.php create mode 100644 tests/Feature/Shop/CheckoutOrderLifecycleTest.php create mode 100644 tests/Feature/Shop/CommerceIntegrityTest.php create mode 100644 tests/Feature/Shop/CustomerAccountsTest.php create mode 100644 tests/Feature/Shop/ReliabilitySecurityTest.php create mode 100644 tests/Feature/Shop/SecurityAcceptanceTest.php create mode 100644 tests/Feature/Shop/SecurityHardeningTest.php create mode 100644 tests/Feature/Shop/SideEffectsSearchAnalyticsJobsTest.php create mode 100644 tests/Feature/Shop/StorefrontApiTest.php create mode 100644 tests/Feature/Shop/TenancyAndAuthorizationTest.php create mode 100644 tests/Feature/Shop/ThemeArchiveAndSearchStatusTest.php create mode 100644 tests/Fixtures/CodeQuality/CleanService.php create mode 100644 tests/Fixtures/CodeQuality/EnvOutsideConfig.php create mode 100644 tests/Fixtures/CodeQuality/HeartbeatAiReference.php create mode 100644 tests/Fixtures/CodeQuality/frontend-hardcoded-route.tsx create mode 100644 tests/Unit/ShopCalculatorsTest.php diff --git a/.env.example b/.env.example index 88a618c0..bc0a4354 100644 --- a/.env.example +++ b/.env.example @@ -29,7 +29,9 @@ DB_CONNECTION=sqlite SESSION_DRIVER=file SESSION_LIFETIME=120 -SESSION_ENCRYPT=false +SESSION_ENCRYPT=true +SESSION_COOKIE=shop_session +SESSION_SECURE_COOKIE=false SESSION_PATH=/ SESSION_DOMAIN=null @@ -40,6 +42,9 @@ QUEUE_CONNECTION=sync SANCTUM_TOKEN_PREFIX=shop_ SANCTUM_EXPIRATION=525600 +CODE_QUALITY_MULTI_TENANT=true +CODE_QUALITY_TENANT_KEYS=store_id + CACHE_STORE=file # CACHE_PREFIX= @@ -66,3 +71,16 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +# Code-quality checker (php artisan code-quality:check) +CODE_QUALITY_TIMEOUT=600 +CODE_QUALITY_CHECK_TIMEOUT=180 +CODE_QUALITY_AI_ENABLED=false +CODE_QUALITY_CODEX_BINARY=codex +CODE_QUALITY_AI_MODEL=gpt-5.4-mini +CODE_QUALITY_AI_CONCURRENCY=3 +CODE_QUALITY_AI_TIMEOUT=180 +CODE_QUALITY_AI_REASONING=low +CODE_QUALITY_AI_VERBOSITY=low +CODE_QUALITY_AI_MAX_PROMPT_FILE_BYTES=120000 +CODE_QUALITY_AI_FREE_PATH_KEYWORDS= +CODE_QUALITY_FRONTEND_ROUTE_HELPERS=route,action,wayfinder diff --git a/app/Actions/SanitizeHtml.php b/app/Actions/SanitizeHtml.php new file mode 100644 index 00000000..25f21842 --- /dev/null +++ b/app/Actions/SanitizeHtml.php @@ -0,0 +1,100 @@ +> */ + private const ALLOWED = [ + 'p' => [], 'br' => [], 'strong' => [], 'em' => [], 'u' => [], + 'ol' => [], 'ul' => [], 'li' => [], 'a' => ['href'], 'img' => ['src', 'alt'], + 'h1' => [], 'h2' => [], 'h3' => [], 'h4' => [], 'h5' => [], 'h6' => [], + 'blockquote' => [], 'table' => [], 'thead' => [], 'tbody' => [], 'tr' => [], + 'th' => [], 'td' => [], 'div' => [], 'span' => [], + ]; + + public function execute(?string $html): ?string + { + if ($html === null) { + return null; + } + + $html = preg_replace('#<(script|style|iframe|object|embed|template|svg|math)\b[^>]*>.*?#is', '', $html) ?? ''; + $html = preg_replace('#<(script|style|iframe|object|embed|template|svg|math)\b[^>]*/?>#is', '', $html) ?? ''; + $allowedTags = '<'.implode('><', array_keys(self::ALLOWED)).'>'; + $html = strip_tags($html, $allowedTags); + if ($html === '' || ! class_exists(DOMDocument::class)) { + return trim($html); + } + + $document = new DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + $document->loadHTML( + '
      '.$html.'
      ', + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD, + ); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $xpath = new DOMXPath($document); + + foreach ($xpath->query('//*[@*]') ?: [] as $element) { + if (! $element instanceof DOMElement || $element->getAttribute('id') === 'sanitize-root') { + continue; + } + $tag = mb_strtolower($element->tagName); + foreach (iterator_to_array($element->attributes) as $attribute) { + if (! in_array(mb_strtolower($attribute->nodeName), self::ALLOWED[$tag] ?? [], true)) { + $element->removeAttributeNode($attribute); + } + } + foreach (['href', 'src'] as $attribute) { + if ($element->hasAttribute($attribute) && ! $this->safeUrl($element->getAttribute($attribute), $attribute === 'src')) { + $element->removeAttribute($attribute); + } + } + } + + foreach ($xpath->query('//comment()') ?: [] as $comment) { + $comment->parentNode?->removeChild($comment); + } + + $elements = iterator_to_array($xpath->query('//*[not(@id="sanitize-root")]') ?: []); + foreach (array_reverse($elements) as $element) { + if ($element instanceof DOMElement + && ! in_array(mb_strtolower($element->tagName), ['br', 'img'], true) + && trim($element->textContent) === '' + && $element->getElementsByTagName('img')->length === 0 + && $element->getElementsByTagName('br')->length === 0) { + $element->parentNode?->removeChild($element); + } + } + + $root = $document->getElementById('sanitize-root'); + if ($root === null) { + return ''; + } + + $result = ''; + foreach ($root->childNodes as $child) { + $result .= $document->saveHTML($child); + } + + return trim($result); + } + + private function safeUrl(string $url, bool $image): bool + { + $url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($url === '' || preg_match('/[\x00-\x1F\x7F]/', $url) === 1) { + return false; + } + $scheme = mb_strtolower((string) parse_url($url, PHP_URL_SCHEME)); + $schemes = $image ? ['http', 'https'] : ['http', 'https', 'mailto', 'tel']; + + return $scheme === '' || in_array($scheme, $schemes, true); + } +} diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php index 8218bcdf..e344c122 100644 --- a/app/Auth/CustomerUserProvider.php +++ b/app/Auth/CustomerUserProvider.php @@ -8,6 +8,37 @@ final class CustomerUserProvider extends EloquentUserProvider { + public function retrieveById($identifier): ?Authenticatable + { + return $this->tenantQuery()->whereKey($identifier)->first(); + } + + public function retrieveByToken($identifier, $token): ?Authenticatable + { + $model = $this->retrieveById($identifier); + if (! $model instanceof Customer || $model->getRememberTokenName() === '') { + return null; + } + + $rememberToken = $model->getRememberToken(); + + return $rememberToken !== null && hash_equals($rememberToken, (string) $token) ? $model : null; + } + + public function updateRememberToken(Authenticatable $user, $token): void + { + if (! $user instanceof Customer || $user->getRememberTokenName() === '') { + return; + } + + $tenantCustomer = $this->retrieveById($user->getAuthIdentifier()); + if (! $tenantCustomer instanceof Customer) { + return; + } + + parent::updateRememberToken($tenantCustomer, $token); + } + /** @param array $credentials */ public function retrieveByCredentials(array $credentials): ?Authenticatable { @@ -15,12 +46,10 @@ public function retrieveByCredentials(array $credentials): ?Authenticatable return null; } - $query = Customer::withoutGlobalScopes(); - if (app()->bound('current_store')) { - $query->where('store_id', app('current_store')->id); - } else { + if (! app()->bound('current_store')) { return null; } + $query = $this->tenantQuery(); foreach ($credentials as $key => $value) { if (! str_contains($key, 'password')) { @@ -30,4 +59,17 @@ public function retrieveByCredentials(array $credentials): ?Authenticatable return $query->first(); } + + private function tenantQuery(): mixed + { + $query = Customer::withoutGlobalScopes()->whereRaw('1 = 0'); + if (! app()->bound('current_store')) { + return $query; + } + + $store = app('current_store'); + $storeId = is_object($store) ? $store->getKey() : $store; + + return Customer::withoutGlobalScopes()->where('store_id', $storeId); + } } diff --git a/app/Console/Commands/CodeQuality/CheckCodeQualityCommand.php b/app/Console/Commands/CodeQuality/CheckCodeQualityCommand.php new file mode 100644 index 00000000..d0ab48cb --- /dev/null +++ b/app/Console/Commands/CodeQuality/CheckCodeQualityCommand.php @@ -0,0 +1,148 @@ + fix -> check loop. The command reports findings only; it never edits source files.'; + + /** + * Execute the console command. + */ + public function handle( + CodeQualityPipeline $pipeline, + JsonReportWriter $json, + JsonlReportWriter $jsonl, + PrettyReportWriter $pretty, + Filesystem $files, + ): int { + try { + $options = $this->optionsDto(); + $report = $pipeline->run($options); + $output = match ($options->format) { + 'json' => $json->write($report), + 'jsonl' => $jsonl->write($report), + 'pretty' => $pretty->write($report), + default => throw CodeQualityException::invalidInput("Unsupported output format: {$options->format}"), + }; + + if ($options->output !== null && $options->output !== '') { + $outputPath = $this->outputPath($options->output); + $files->ensureDirectoryExists(dirname($outputPath)); + $files->put($outputPath, $json->write($report)); + } + + $this->output->write($output); + + return $report->exitCode(); + } catch (CodeQualityException $exception) { + $this->components->error($exception->getMessage()); + + return $exception->exitCode(); + } catch (Throwable $exception) { + report($exception); + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + } + + private function optionsDto(): CodeQualityRunOptions + { + $format = (string) $this->option('format'); + $failOn = (string) $this->option('fail-on'); + + if (! in_array($format, ['pretty', 'json', 'jsonl'], true)) { + throw CodeQualityException::invalidInput("Unsupported output format: {$format}"); + } + + if (! in_array($failOn, ['info', 'warning', 'error', 'critical'], true)) { + throw CodeQualityException::invalidInput("Unsupported --fail-on severity: {$failOn}"); + } + + $timeout = $this->positiveIntOption('timeout', (int) config('code_quality.defaults.timeout_seconds', 600)); + $checkTimeout = $this->positiveIntOption('check-timeout', (int) config('code_quality.defaults.check_timeout_seconds', 180)); + $aiConcurrency = $this->option('ai-concurrency') === null + ? null + : $this->positiveIntOption('ai-concurrency', (int) config('code_quality.ai.concurrency', 3)); + + return new CodeQualityRunOptions( + paths: array_values(array_map('strval', (array) $this->argument('paths'))), + checks: array_values(array_map('strval', (array) $this->option('checks'))), + excludes: array_values(array_map('strval', (array) $this->option('exclude'))), + changed: (bool) $this->option('changed'), + staged: (bool) $this->option('staged'), + base: $this->option('base') === null ? null : (string) $this->option('base'), + format: $format, + output: $this->option('output') === null ? null : (string) $this->option('output'), + failOn: $failOn, + ai: (bool) $this->option('ai'), + noAi: (bool) $this->option('no-ai'), + aiModel: $this->option('ai-model') === null ? null : (string) $this->option('ai-model'), + aiConcurrency: $aiConcurrency, + timeoutSeconds: $timeout, + checkTimeoutSeconds: $checkTimeout, + profile: (string) $this->option('profile'), + ); + } + + private function positiveIntOption(string $name, int $default): int + { + $value = $this->option($name); + + if ($value === null || $value === '') { + return $default; + } + + if (! is_numeric($value) || (int) $value <= 0) { + throw CodeQualityException::invalidInput("--{$name} must be a positive integer."); + } + + return (int) $value; + } + + private function outputPath(string $path): string + { + if (str_starts_with($path, '/')) { + $directory = realpath(dirname($path)) ?: dirname($path); + + if (! str_starts_with($directory, base_path())) { + throw CodeQualityException::invalidInput('The --output path must stay inside the repository.'); + } + + return $path; + } + + return base_path($path); + } +} diff --git a/app/Contracts/DnsResolver.php b/app/Contracts/DnsResolver.php new file mode 100644 index 00000000..6e54174b --- /dev/null +++ b/app/Contracts/DnsResolver.php @@ -0,0 +1,9 @@ + */ + public function resolve(string $hostname): array; +} diff --git a/app/Events/FulfillmentShipped.php b/app/Events/FulfillmentShipped.php index b4b6c193..f83cf99d 100644 --- a/app/Events/FulfillmentShipped.php +++ b/app/Events/FulfillmentShipped.php @@ -10,5 +10,8 @@ final class FulfillmentShipped { use Dispatchable, SerializesModels; - public function __construct(public readonly Fulfillment $fulfillment) {} + public function __construct( + public readonly Fulfillment $fulfillment, + public readonly bool $notifyCustomer = true, + ) {} } diff --git a/app/Events/OrderRefunded.php b/app/Events/OrderRefunded.php index b87b273d..fecf0677 100644 --- a/app/Events/OrderRefunded.php +++ b/app/Events/OrderRefunded.php @@ -11,5 +11,9 @@ final class OrderRefunded { use Dispatchable, SerializesModels; - public function __construct(public readonly Order $order, public readonly Refund $refund) {} + public function __construct( + public readonly Order $order, + public readonly Refund $refund, + public readonly bool $notifyCustomer = true, + ) {} } diff --git a/app/Exceptions/UnsafeWebhookTargetException.php b/app/Exceptions/UnsafeWebhookTargetException.php new file mode 100644 index 00000000..af5ccc99 --- /dev/null +++ b/app/Exceptions/UnsafeWebhookTargetException.php @@ -0,0 +1,7 @@ +authorize('viewAnalytics', app('current_store')); $data = $request->validate(['from' => ['required', 'date'], 'to' => ['required', 'date', 'after_or_equal:from'], 'granularity' => ['sometimes', 'in:day,week,month']]); $daily = $this->analytics->getDailyMetrics(app('current_store'), $data['from'], $data['to']); @@ -31,6 +33,7 @@ public function summary(Request $request, int $storeId): JsonResponse public function exportOrders(Request $request, int $storeId): JsonResponse { + $this->authorize('viewAny', Order::class); $data = $request->validate([ 'format' => ['sometimes', 'in:csv'], 'filters' => ['sometimes', 'array'], @@ -46,7 +49,7 @@ public function exportOrders(Request $request, int $storeId): JsonResponse 'filters_json' => $data['filters'] ?? [], 'status' => 'queued', ]); - GenerateOrderExport::dispatch($export); + GenerateOrderExport::dispatch($export)->afterCommit(); return response()->json([ 'export_id' => $export->id, @@ -57,6 +60,7 @@ public function exportOrders(Request $request, int $storeId): JsonResponse public function export(Request $request, int $storeId, int $exportId): JsonResponse|StreamedResponse { + $this->authorize('viewAny', Order::class); $export = OrderExport::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($exportId); if ($request->boolean('download')) { abort_unless($export->status === 'completed' && $export->storage_key !== null, 409, 'The export is not ready.'); diff --git a/app/Http/Controllers/Api/Admin/CollectionController.php b/app/Http/Controllers/Api/Admin/CollectionController.php index d3155bed..c3d50972 100644 --- a/app/Http/Controllers/Api/Admin/CollectionController.php +++ b/app/Http/Controllers/Api/Admin/CollectionController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreCollectionRequest; +use App\Http\Requests\UpdateCollectionRequest; use App\Models\Collection; use App\Support\HandleGenerator; use Illuminate\Http\JsonResponse; @@ -15,6 +17,7 @@ public function __construct(private readonly HandleGenerator $handles) {} public function index(Request $request, int $storeId): JsonResponse { + $this->authorize('viewAny', Collection::class); $data = $request->validate(['status' => ['sometimes', 'in:draft,active,archived'], 'query' => ['sometimes', 'string'], 'per_page' => ['sometimes', 'integer', 'max:100']]); $items = Collection::withoutGlobalScopes()->where('store_id', $storeId) ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status'])) @@ -24,11 +27,12 @@ public function index(Request $request, int $storeId): JsonResponse return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total(), 'current_page' => $items->currentPage()]]); } - public function store(Request $request, int $storeId): JsonResponse + public function store(StoreCollectionRequest $request, int $storeId): JsonResponse { + $this->authorize('create', Collection::class); $data = $this->validateData($request, $storeId); $collection = Collection::withoutGlobalScopes()->create([ - ...$data, + ...collect($data)->except(['product_ids', 'add_product_ids', 'remove_product_ids'])->all(), 'store_id' => $storeId, 'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'collections', $storeId), ]); @@ -37,13 +41,25 @@ public function store(Request $request, int $storeId): JsonResponse return response()->json(['data' => $collection->load('products')], 201); } - public function update(Request $request, int $storeId, int $collectionId): JsonResponse + public function update(UpdateCollectionRequest $request, int $storeId, int $collectionId): JsonResponse { $collection = $this->find($storeId, $collectionId); + $this->authorize('update', $collection); $data = $this->validateData($request, $storeId, $collectionId, true); - $collection->update(collect($data)->except('product_ids')->all()); + $collection->update(collect($data)->except(['product_ids', 'add_product_ids', 'remove_product_ids'])->all()); if (array_key_exists('product_ids', $data)) { $collection->products()->sync($this->positions($data['product_ids'])); + } else { + $existing = $collection->products()->orderByPivot('position')->pluck('products.id')->map(fn ($id): int => (int) $id); + $merged = $existing + ->merge((array) ($data['add_product_ids'] ?? [])) + ->diff((array) ($data['remove_product_ids'] ?? [])) + ->unique() + ->values() + ->all(); + if (array_key_exists('add_product_ids', $data) || array_key_exists('remove_product_ids', $data)) { + $collection->products()->sync($this->positions($merged)); + } } return response()->json(['data' => $collection->refresh()->load('products')]); @@ -51,7 +67,9 @@ public function update(Request $request, int $storeId, int $collectionId): JsonR public function destroy(int $storeId, int $collectionId): JsonResponse { - $this->find($storeId, $collectionId)->delete(); + $collection = $this->find($storeId, $collectionId); + $this->authorize('delete', $collection); + $collection->delete(); return response()->json(['message' => 'Collection deleted.']); } @@ -66,7 +84,11 @@ private function validateData(Request $request, int $storeId, ?int $ignore = nul 'type' => ['sometimes', 'in:manual,automated'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array'], - 'product_ids.*' => ['integer'], + 'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)], + 'add_product_ids' => ['sometimes', 'array'], + 'add_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)], + 'remove_product_ids' => ['sometimes', 'array'], + 'remove_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)], ]); } diff --git a/app/Http/Controllers/Api/Admin/ContentController.php b/app/Http/Controllers/Api/Admin/ContentController.php index cd12fc75..2869e7ce 100644 --- a/app/Http/Controllers/Api/Admin/ContentController.php +++ b/app/Http/Controllers/Api/Admin/ContentController.php @@ -3,38 +3,49 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StorePageRequest; +use App\Http\Requests\UpdatePageRequest; +use App\Jobs\ReindexProducts; use App\Models\Page; +use App\Models\StoreSettings; use App\Models\Theme; -use App\Services\SearchService; +use App\Services\ThemeArchiveService; use App\Support\HandleGenerator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; final class ContentController extends Controller { - public function __construct(private readonly HandleGenerator $handles, private readonly SearchService $search) {} + public function __construct( + private readonly HandleGenerator $handles, + private readonly ThemeArchiveService $themeArchives, + ) {} public function pages(Request $request, int $storeId): JsonResponse { + $this->authorize('viewAny', Page::class); $data = $request->validate(['status' => ['sometimes', 'in:draft,published,archived'], 'per_page' => ['sometimes', 'integer', 'max:100']]); $pages = Page::withoutGlobalScopes()->where('store_id', $storeId)->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))->paginate($data['per_page'] ?? 25); return response()->json(['data' => $pages->items(), 'meta' => ['total' => $pages->total()]]); } - public function storePage(Request $request, int $storeId): JsonResponse + public function storePage(StorePageRequest $request, int $storeId): JsonResponse { + $this->authorize('create', Page::class); $data = $this->pageData($request, $storeId); $page = Page::withoutGlobalScopes()->create(['store_id' => $storeId, ...$data, 'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'pages', $storeId)]); return response()->json(['data' => $page], 201); } - public function updatePage(Request $request, int $storeId, int $pageId): JsonResponse + public function updatePage(UpdatePageRequest $request, int $storeId, int $pageId): JsonResponse { $page = $this->page($storeId, $pageId); + $this->authorize('update', $page); $page->update($this->pageData($request, $storeId, $pageId, true)); return response()->json(['data' => $page->refresh()]); @@ -42,26 +53,26 @@ public function updatePage(Request $request, int $storeId, int $pageId): JsonRes public function destroyPage(int $storeId, int $pageId): JsonResponse { - $this->page($storeId, $pageId)->delete(); + $page = $this->page($storeId, $pageId); + $this->authorize('delete', $page); + $page->delete(); return response()->json(['message' => 'Page deleted.']); } public function storeTheme(Request $request, int $storeId): JsonResponse { - $data = $request->validate(['name' => ['required_without:file', 'nullable', 'string'], 'file' => ['sometimes', 'file', 'mimes:zip', 'max:51200']]); - $theme = Theme::withoutGlobalScopes()->create(['store_id' => $storeId, 'name' => $data['name'] ?? pathinfo($data['file']->getClientOriginalName(), PATHINFO_FILENAME), 'version' => '1.0.0', 'status' => 'draft']); - if (isset($data['file'])) { - $path = $data['file']->store("themes/{$storeId}/{$theme->id}", 'local'); - $theme->files()->create(['path' => 'theme.zip', 'storage_key' => $path, 'sha256' => hash_file('sha256', $data['file']->getRealPath()), 'byte_size' => $data['file']->getSize()]); - } - - return response()->json(['data' => $theme->load('files')], 201); + $this->authorize('create', Theme::class); + $data = $request->validate(['name' => ['nullable', 'string', 'max:255'], 'file' => ['required', 'file', 'mimes:zip', 'max:51200']]); + $theme = $this->themeArchives->install(app('current_store'), $data['file'], $data['name'] ?? null); + + return response()->json(['data' => $theme], 201); } public function publishTheme(int $storeId, int $themeId): JsonResponse { $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); + $this->authorize('publish', $theme); DB::transaction(function () use ($storeId, $theme): void { Theme::withoutGlobalScopes()->where('store_id', $storeId)->whereKeyNot($theme->id)->update(['status' => 'draft', 'published_at' => null]); $theme->update(['status' => 'published', 'published_at' => now()]); @@ -73,6 +84,7 @@ public function publishTheme(int $storeId, int $themeId): JsonResponse public function updateThemeSettings(Request $request, int $storeId, int $themeId): JsonResponse { $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId); + $this->authorize('update', $theme); $data = $request->validate(['settings' => ['required', 'array']]); $settings = $theme->settings()->updateOrCreate(['theme_id' => $theme->id], ['settings_json' => $data['settings']]); @@ -81,14 +93,51 @@ public function updateThemeSettings(Request $request, int $storeId, int $themeId public function reindex(int $storeId): JsonResponse { - \App\Models\Product::withoutGlobalScopes()->where('store_id', $storeId)->each(fn ($product) => $this->search->syncProduct($product)); + $this->authorize('update', app('current_store')); + $jobId = DB::transaction(function () use ($storeId): string { + $record = StoreSettings::withoutGlobalScopes() + ->where('store_id', $storeId) + ->lockForUpdate() + ->first() ?? new StoreSettings(['store_id' => $storeId]); + $settings = (array) $record->settings_json; + abort_if( + in_array(data_get($settings, 'search.status'), ['queued', 'processing'], true), + 409, + 'A search reindex is already in progress.', + ); + + $jobId = 'job_reindex_'.Str::lower(Str::random(12)); + data_set($settings, 'search.status', 'queued'); + data_set($settings, 'search.progress', 0); + data_set($settings, 'search.job_id', $jobId); + $record->settings_json = $settings; + $record->save(); + + return $jobId; + }); + ReindexProducts::dispatch($storeId)->afterCommit(); - return response()->json(['message' => 'Search index rebuilt.'], 202); + return response()->json([ + 'message' => 'Reindex job queued.', + 'job_id' => $jobId, + 'status' => 'queued', + ], 202); } public function searchStatus(int $storeId): JsonResponse { - return response()->json(['data' => ['status' => 'ready', 'products_indexed' => \App\Models\Product::withoutGlobalScopes()->where('store_id', $storeId)->count(), 'last_indexed_at' => now()]]); + $this->authorize('view', app('current_store')); + $record = StoreSettings::withoutGlobalScopes()->where('store_id', $storeId)->first(); + $search = (array) data_get($record?->settings_json, 'search', []); + + return response()->json(['data' => [ + 'store_id' => $storeId, + 'index_status' => $search['status'] ?? 'never_indexed', + 'last_reindex_at' => $search['last_reindex_at'] ?? $search['last_indexed_at'] ?? null, + 'last_reindex_duration_seconds' => $search['last_reindex_duration_seconds'] ?? null, + 'documents_count' => (int) ($search['documents_count'] ?? $search['products_indexed'] ?? 0), + 'pending_updates' => (int) ($search['pending_updates'] ?? 0), + ]]); } /** @return array */ diff --git a/app/Http/Controllers/Api/Admin/DiscountController.php b/app/Http/Controllers/Api/Admin/DiscountController.php index 9b2465e9..1b688c92 100644 --- a/app/Http/Controllers/Api/Admin/DiscountController.php +++ b/app/Http/Controllers/Api/Admin/DiscountController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreDiscountRequest; +use App\Http\Requests\UpdateDiscountRequest; use App\Models\Discount; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -12,6 +14,7 @@ final class DiscountController extends Controller { public function index(Request $request, int $storeId): JsonResponse { + $this->authorize('viewAny', Discount::class); $data = $request->validate(['type' => ['sometimes', 'in:code,automatic'], 'status' => ['sometimes', 'in:draft,active,expired,disabled'], 'per_page' => ['sometimes', 'integer', 'max:100']]); $items = Discount::withoutGlobalScopes()->where('store_id', $storeId) ->when(isset($data['type']), fn ($q) => $q->where('type', $data['type'])) @@ -21,16 +24,18 @@ public function index(Request $request, int $storeId): JsonResponse return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total()]]); } - public function store(Request $request, int $storeId): JsonResponse + public function store(StoreDiscountRequest $request, int $storeId): JsonResponse { + $this->authorize('create', Discount::class); $discount = Discount::withoutGlobalScopes()->create(['store_id' => $storeId, ...$this->data($request, $storeId)]); return response()->json(['data' => $discount], 201); } - public function update(Request $request, int $storeId, int $discountId): JsonResponse + public function update(UpdateDiscountRequest $request, int $storeId, int $discountId): JsonResponse { $discount = $this->find($storeId, $discountId); + $this->authorize('update', $discount); $discount->update($this->data($request, $storeId, $discountId, true)); return response()->json(['data' => $discount->refresh()]); @@ -38,7 +43,9 @@ public function update(Request $request, int $storeId, int $discountId): JsonRes public function destroy(int $storeId, int $discountId): JsonResponse { - $this->find($storeId, $discountId)->delete(); + $discount = $this->find($storeId, $discountId); + $this->authorize('delete', $discount); + $discount->delete(); return response()->json(['message' => 'Discount deleted.']); } @@ -55,6 +62,12 @@ private function data(Request $request, int $storeId, ?int $ignore = null, bool 'ends_at' => ['nullable', 'date', 'after:starts_at'], 'usage_limit' => ['nullable', 'integer', 'min:1'], 'rules_json' => ['sometimes', 'array'], + 'rules_json.applicable_product_ids' => ['sometimes', 'array'], + 'rules_json.applicable_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)], + 'rules_json.applicable_collection_ids' => ['sometimes', 'array'], + 'rules_json.applicable_collection_ids.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $storeId)], + 'rules_json.one_per_customer' => ['sometimes', 'boolean'], + 'rules_json.once_per_customer' => ['sometimes', 'boolean'], 'status' => ['sometimes', 'in:draft,active,expired,disabled'], ]); } diff --git a/app/Http/Controllers/Api/Admin/MediaController.php b/app/Http/Controllers/Api/Admin/MediaController.php index 278b5255..ff3c2ea4 100644 --- a/app/Http/Controllers/Api/Admin/MediaController.php +++ b/app/Http/Controllers/Api/Admin/MediaController.php @@ -25,6 +25,7 @@ public function presign(Request $request, int $storeId, int $productId): JsonRes abort_if((int) $data['byte_size'] > $maximum, 422, 'The uploaded file is too large.'); $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId); + $this->authorize('update', $product); $extension = mb_strtolower((string) pathinfo($data['filename'], PATHINFO_EXTENSION)); $storageKey = "stores/{$storeId}/products/{$product->id}/media/".Str::uuid().".{$extension}"; $media = $product->media()->create([ @@ -58,7 +59,7 @@ public function upload(Request $request, ProductMedia $media): JsonResponse if (($media->type instanceof \BackedEnum ? $media->type->value : $media->type) === 'video') { $media->update(['status' => 'ready', 'byte_size' => strlen($contents)]); } else { - ProcessMediaUpload::dispatch($media); + ProcessMediaUpload::dispatch($media)->afterCommit(); } return response()->json(['data' => $media->refresh()], 202); diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php index 8f8cf3fc..f13cf50c 100644 --- a/app/Http/Controllers/Api/Admin/OrderController.php +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -2,13 +2,19 @@ namespace App\Http\Controllers\Api\Admin; +use App\Exceptions\DomainException; +use App\Exceptions\FulfillmentGuardException; use App\Http\Controllers\Controller; +use App\Http\Requests\CreateFulfillmentRequest; +use App\Http\Requests\CreateRefundRequest; use App\Models\Order; use App\Services\FulfillmentService; use App\Services\OrderService; use App\Services\RefundService; +use App\Support\SafeUrl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; final class OrderController extends Controller { @@ -20,6 +26,7 @@ public function __construct( public function index(Request $request, int $storeId): JsonResponse { + $this->authorize('viewAny', Order::class); $data = $request->validate([ 'status' => ['sometimes', 'in:pending,paid,fulfilled,cancelled,refunded'], 'financial_status' => ['sometimes', 'in:pending,authorized,paid,partially_refunded,refunded,voided'], @@ -39,11 +46,16 @@ public function index(Request $request, int $storeId): JsonResponse public function show(int $storeId, int $orderId): JsonResponse { - return response()->json(['data' => $this->find($storeId, $orderId)->load(['lines', 'payments', 'refunds', 'fulfillments.lines', 'customer'])]); + $order = $this->find($storeId, $orderId); + $this->authorize('view', $order); + + return response()->json(['data' => $order->load(['lines', 'payments', 'refunds', 'fulfillments.lines', 'customer'])]); } - public function fulfill(Request $request, int $storeId, int $orderId): JsonResponse + public function fulfill(CreateFulfillmentRequest $request, int $storeId, int $orderId): JsonResponse { + $order = $this->find($storeId, $orderId); + $this->authorize('createFulfillment', $order); $data = $request->validate([ 'line_items' => ['required_without:lines', 'array', 'min:1'], 'line_items.*.order_line_id' => ['required_with:line_items', 'integer'], @@ -52,20 +64,31 @@ public function fulfill(Request $request, int $storeId, int $orderId): JsonRespo 'lines.*' => ['required_with:lines', 'integer', 'min:1'], 'tracking_company' => ['nullable', 'string'], 'tracking_number' => ['nullable', 'string'], - 'tracking_url' => ['nullable', 'url'], + 'tracking_url' => [ + 'nullable', 'string', 'max:2048', + function (string $attribute, mixed $value, \Closure $fail): void { + if (! SafeUrl::isAllowed($value)) { + $fail('The tracking link must be a relative, HTTP, or HTTPS URL.'); + } + }, + ], 'notify_customer' => ['sometimes', 'boolean'], ]); $tracking = collect($data)->only(['tracking_company', 'tracking_number', 'tracking_url'])->all(); $lines = isset($data['line_items']) ? collect($data['line_items'])->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']])->all() : $data['lines']; - $fulfillment = $this->fulfillments->create($this->find($storeId, $orderId), $lines, $tracking); - $this->fulfillments->markAsShipped($fulfillment, $tracking); + try { + $fulfillment = $this->fulfillments->create($order, $lines, $tracking); + } catch (FulfillmentGuardException $exception) { + return response()->json(['message' => $exception->getMessage()], 409); + } + $this->fulfillments->markAsShipped($fulfillment, $tracking, (bool) ($data['notify_customer'] ?? true)); return response()->json(['data' => $fulfillment->refresh()->load('lines')], 201); } - public function refund(Request $request, int $storeId, int $orderId): JsonResponse + public function refund(CreateRefundRequest $request, int $storeId, int $orderId): JsonResponse { $data = $request->validate([ 'amount' => ['required_without_all:lines,line_items', 'nullable', 'integer', 'min:1'], @@ -79,12 +102,17 @@ public function refund(Request $request, int $storeId, int $orderId): JsonRespon 'notify_customer' => ['sometimes', 'boolean'], ]); $order = $this->find($storeId, $orderId)->load('payments'); + $this->authorize('createRefund', $order); $lines = isset($data['line_items']) ? collect($data['line_items'])->mapWithKeys(fn (array $line): array => [(int) $line['order_line_id'] => (int) $line['quantity']])->all() : ($data['lines'] ?? null); $payment = $order->payments->first(fn ($payment) => ($payment->status instanceof \BackedEnum ? $payment->status->value : $payment->status) === 'captured') ?? $order->payments->firstOrFail(); - $refund = $this->refunds->create($order, $payment, $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $lines); + try { + $refund = $this->refunds->create($order, $payment, $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $lines, (bool) ($data['notify_customer'] ?? true)); + } catch (DomainException $exception) { + throw ValidationException::withMessages(['amount' => $exception->getMessage()]); + } return response()->json(['data' => $refund], 201); } @@ -92,7 +120,12 @@ public function refund(Request $request, int $storeId, int $orderId): JsonRespon public function confirmPayment(int $storeId, int $orderId): JsonResponse { $order = $this->find($storeId, $orderId); - $this->orders->confirmBankTransfer($order); + $this->authorize('update', $order); + try { + $this->orders->confirmBankTransfer($order); + } catch (DomainException $exception) { + return response()->json(['message' => $exception->getMessage()], 409); + } return response()->json(['data' => $order->refresh()]); } diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php index 92b071d1..8cd8e331 100644 --- a/app/Http/Controllers/Api/Admin/ProductController.php +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -4,11 +4,15 @@ use App\Enums\ProductStatus; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreProductRequest; +use App\Http\Requests\UpdateProductRequest; use App\Models\Product; +use App\Models\ProductVariant; use App\Services\ProductService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; final class ProductController extends Controller { @@ -16,10 +20,11 @@ public function __construct(private readonly ProductService $products) {} public function index(Request $request, int $storeId): JsonResponse { + $this->authorize('viewAny', Product::class); $validated = $request->validate([ 'status' => ['sometimes', 'in:draft,active,archived'], 'query' => ['sometimes', 'string', 'max:200'], - 'collection_id' => ['sometimes', 'integer'], + 'collection_id' => ['sometimes', 'integer', Rule::exists('collections', 'id')->where('store_id', $storeId)], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], 'sort' => ['sometimes', 'in:title_asc,title_desc,created_at_asc,created_at_desc,updated_at_desc'], ]); @@ -41,8 +46,9 @@ public function index(Request $request, int $storeId): JsonResponse return response()->json(['data' => $products->items(), 'meta' => ['current_page' => $products->currentPage(), 'last_page' => $products->lastPage(), 'total' => $products->total()]]); } - public function store(Request $request, int $storeId): JsonResponse + public function store(StoreProductRequest $request, int $storeId): JsonResponse { + $this->authorize('create', Product::class); $data = $this->validated($request, $storeId); $product = $this->products->create(app('current_store'), $data); @@ -51,19 +57,25 @@ public function store(Request $request, int $storeId): JsonResponse public function show(int $storeId, int $productId): JsonResponse { - return response()->json(['data' => $this->product($storeId, $productId)->load(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'media', 'collections'])]); + $product = $this->product($storeId, $productId); + $this->authorize('view', $product); + + return response()->json(['data' => $product->load(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'media', 'collections'])]); } - public function update(Request $request, int $storeId, int $productId): JsonResponse + public function update(UpdateProductRequest $request, int $storeId, int $productId): JsonResponse { + $product = $this->product($storeId, $productId); + $this->authorize('update', $product); $data = $this->validated($request, $storeId, true); - return response()->json(['data' => $this->products->update($this->product($storeId, $productId), $data)]); + return response()->json(['data' => $this->products->update($product, $data)]); } public function destroy(int $storeId, int $productId): JsonResponse { $product = $this->product($storeId, $productId); + $this->authorize('archive', $product); $this->products->transitionStatus($product, ProductStatus::Archived); return response()->json(['message' => 'Product archived.', 'data' => $product->refresh()]); @@ -74,22 +86,124 @@ private function validated(Request $request, int $storeId, bool $partial = false { $sometimes = $partial ? ['sometimes'] : ['required']; - return $request->validate([ + $uniqueSku = function (string $attribute, mixed $value, \Closure $fail) use ($storeId, $request): void { + if ($value === null || trim((string) $value) === '') { + return; + } + $query = ProductVariant::query()->where('sku', trim((string) $value)) + ->whereHas('product', fn ($products) => $products->withoutGlobalScopes()->where('store_id', $storeId)); + if ($request->route('productId') !== null) { + preg_match('/^variants\.(\d+)\.sku$/', $attribute, $matches); + $variantId = isset($matches[1]) ? data_get($request->input('variants'), $matches[1].'.id') : null; + if ($variantId !== null) { + $query->whereKeyNot((int) $variantId); + } elseif ($attribute === 'variant.sku') { + $defaultId = ProductVariant::query() + ->where('product_id', (int) $request->route('productId')) + ->orderByDesc('is_default') + ->value('id'); + $query->when($defaultId !== null, fn ($variants) => $variants->whereKeyNot($defaultId)); + } + } + if ($query->exists()) { + $fail('The SKU has already been taken for this store.'); + } + }; + + $validated = $request->validate([ 'title' => [...$sometimes, 'string', 'max:255'], - 'handle' => ['sometimes', 'string', 'max:255', Rule::unique('products')->where('store_id', $storeId)->ignore($request->route('productId'))], - 'description_html' => ['nullable', 'string'], + 'handle' => ['sometimes', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('products')->where('store_id', $storeId)->ignore($request->route('productId'))], + 'description_html' => ['nullable', 'string', 'max:65535'], 'vendor' => ['nullable', 'string', 'max:255'], 'product_type' => ['nullable', 'string', 'max:255'], - 'tags' => ['sometimes', 'array'], + 'tags' => ['sometimes', 'array', 'max:50'], + 'tags.*' => ['string', 'max:255'], 'status' => ['sometimes', 'in:draft,active,archived'], 'options' => ['sometimes', 'array', 'max:3'], - 'options.*.name' => ['required_with:options', 'string', 'max:100'], - 'options.*.values' => ['required_with:options', 'array', 'min:1'], + 'options.*.id' => ['sometimes', 'integer', Rule::exists('product_options', 'id')->where('product_id', (int) $request->route('productId'))], + 'options.*.name' => ['required_with:options', 'string', 'max:255'], + 'options.*.position' => ['sometimes', 'integer', 'min:0', 'max:3'], + 'options.*.values' => ['sometimes', 'array', 'min:1'], 'variant' => ['sometimes', 'array'], 'variant.price_amount' => ['sometimes', 'integer', 'min:0'], - 'variant.sku' => ['nullable', 'string', 'max:100'], + 'variant.sku' => ['nullable', 'string', 'max:255', $uniqueSku], 'variant.quantity_on_hand' => ['sometimes', 'integer'], + 'variants' => [$partial ? 'sometimes' : 'required_without:variant', 'array', 'min:1', 'max:100'], + 'variants.*.id' => ['sometimes', 'integer', Rule::exists('product_variants', 'id')->where('product_id', (int) $request->route('productId'))], + 'variants.*.sku' => ['nullable', 'string', 'max:255', 'distinct:ignore_case', $uniqueSku], + 'variants.*.barcode' => ['nullable', 'string', 'max:255'], + 'variants.*.price_amount' => ['required_with:variants', 'integer', 'min:0'], + 'variants.*.compare_at_amount' => ['nullable', 'integer', 'min:0'], + 'variants.*.currency' => ['sometimes', 'string', 'size:3'], + 'variants.*.weight_g' => ['nullable', 'integer', 'min:0'], + 'variants.*.requires_shipping' => ['sometimes', 'boolean'], + 'variants.*.is_default' => ['sometimes', 'boolean'], + 'variants.*.position' => ['sometimes', 'integer', 'min:0'], + 'variants.*.status' => ['sometimes', 'in:active,archived'], + 'variants.*.option_values' => ['sometimes', 'array'], + 'variants.*.option_values.*.id' => ['sometimes', 'integer'], + 'variants.*.option_values.*.option_name' => ['required_without:variants.*.option_values.*.id', 'string'], + 'variants.*.option_values.*.value' => ['required_without:variants.*.option_values.*.id', 'string'], + 'variants.*.inventory' => ['sometimes', 'array'], + 'variants.*.inventory.quantity_on_hand' => ['sometimes', 'integer', 'min:0'], + 'variants.*.inventory.policy' => ['sometimes', 'in:deny,continue'], + 'collections' => ['sometimes', 'array'], + 'collections.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $storeId)], ]); + + foreach ((array) ($validated['variants'] ?? []) as $index => $variant) { + if (isset($variant['compare_at_amount']) && (int) $variant['compare_at_amount'] <= (int) $variant['price_amount']) { + throw ValidationException::withMessages(["variants.{$index}.compare_at_amount" => 'The compare-at price must be greater than the price.']); + } + } + + if (isset($validated['options'], $validated['variants'])) { + $validated['options'] = collect($validated['options'])->map(function (array $option, int $index) use ($validated): array { + if (! empty($option['values'])) { + return $option; + } + $option['values'] = collect($validated['variants']) + ->flatMap(fn (array $variant): array => (array) ($variant['option_values'] ?? [])) + ->filter(fn (array $value): bool => mb_strtolower((string) ($value['option_name'] ?? '')) === mb_strtolower($option['name'])) + ->pluck('value') + ->filter() + ->unique(fn ($value) => mb_strtolower((string) $value)) + ->values() + ->all(); + if ($option['values'] === []) { + throw ValidationException::withMessages(["options.{$index}.values" => 'Every option needs at least one value.']); + } + + return $option; + })->all(); + } + + if (isset($validated['options'])) { + $combinationCount = collect($validated['options'])->reduce(function (int $count, array $option): int { + $valueCount = collect((array) ($option['values'] ?? [])) + ->map(fn (mixed $value): string => trim((string) (is_array($value) ? ($value['value'] ?? '') : $value))) + ->filter() + ->unique(fn (string $value): string => mb_strtolower($value)) + ->count(); + + return $count * max(1, $valueCount); + }, 1); + if ($combinationCount > 100) { + throw ValidationException::withMessages(['options' => 'Product options may generate at most 100 variants.']); + } + } + + if (! empty($validated['variants'])) { + $defaults = collect($validated['variants'])->filter(fn (array $variant): bool => (bool) ($variant['is_default'] ?? false)); + if ($defaults->count() > 1) { + throw ValidationException::withMessages(['variants' => 'Exactly one variant may be the default.']); + } + if ($defaults->isEmpty()) { + $validated['variants'][0]['is_default'] = true; + } + } + + return $validated; } private function product(int $storeId, int $productId): Product diff --git a/app/Http/Controllers/Api/Admin/SettingsController.php b/app/Http/Controllers/Api/Admin/SettingsController.php index 13e6f3fa..331d3075 100644 --- a/app/Http/Controllers/Api/Admin/SettingsController.php +++ b/app/Http/Controllers/Api/Admin/SettingsController.php @@ -3,6 +3,9 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\StoreShippingRateRequest; +use App\Http\Requests\StoreShippingZoneRequest; +use App\Http\Requests\UpdateTaxSettingsRequest; use App\Models\ShippingZone; use App\Models\TaxSettings; use Illuminate\Http\JsonResponse; @@ -12,11 +15,14 @@ final class SettingsController extends Controller { public function zones(int $storeId): JsonResponse { + $this->authorize('viewSettings', app('current_store')); + return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]); } - public function storeZone(Request $request, int $storeId): JsonResponse + public function storeZone(StoreShippingZoneRequest $request, int $storeId): JsonResponse { + $this->authorize('updateSettings', app('current_store')); $data = $request->validate(['name' => ['required', 'string'], 'countries_json' => ['required', 'array'], 'regions_json' => ['sometimes', 'array']]); $zone = ShippingZone::withoutGlobalScopes()->create(['store_id' => $storeId, ...$data, 'regions_json' => $data['regions_json'] ?? []]); @@ -25,14 +31,16 @@ public function storeZone(Request $request, int $storeId): JsonResponse public function updateZone(Request $request, int $storeId, int $zoneId): JsonResponse { + $this->authorize('updateSettings', app('current_store')); $zone = $this->zone($storeId, $zoneId); $zone->update($request->validate(['name' => ['sometimes', 'string'], 'countries_json' => ['sometimes', 'array'], 'regions_json' => ['sometimes', 'array']])); return response()->json(['data' => $zone->refresh()]); } - public function storeRate(Request $request, int $storeId, int $zoneId): JsonResponse + public function storeRate(StoreShippingRateRequest $request, int $storeId, int $zoneId): JsonResponse { + $this->authorize('updateSettings', app('current_store')); $data = $request->validate(['name' => ['required', 'string'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'config_json' => ['required', 'array'], 'is_active' => ['sometimes', 'boolean']]); $rate = $this->zone($storeId, $zoneId)->rates()->create($data); @@ -41,11 +49,14 @@ public function storeRate(Request $request, int $storeId, int $zoneId): JsonResp public function tax(int $storeId): JsonResponse { + $this->authorize('viewSettings', app('current_store')); + return response()->json(['data' => TaxSettings::withoutGlobalScopes()->where('store_id', $storeId)->firstOrFail()]); } - public function updateTax(Request $request, int $storeId): JsonResponse + public function updateTax(UpdateTaxSettingsRequest $request, int $storeId): JsonResponse { + $this->authorize('updateSettings', app('current_store')); $data = $request->validate(['mode' => ['required', 'in:manual,provider'], 'provider' => ['required', 'in:none,stripe_tax'], 'prices_include_tax' => ['required', 'boolean'], 'config_json' => ['required', 'array']]); $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data); diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsController.php b/app/Http/Controllers/Api/Storefront/AnalyticsController.php index b6bb6d7d..d7bc439d 100644 --- a/app/Http/Controllers/Api/Storefront/AnalyticsController.php +++ b/app/Http/Controllers/Api/Storefront/AnalyticsController.php @@ -7,6 +7,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; +use Illuminate\Validation\ValidationException; final class AnalyticsController extends Controller { @@ -15,25 +16,48 @@ public function __construct(private readonly AnalyticsService $analytics) {} public function store(Request $request): JsonResponse { $validated = $request->validate([ - 'events' => ['required', 'array', 'max:100'], - 'events.*.type' => ['required', 'in:page_view,product_view,add_to_cart,remove_from_cart,checkout_started,checkout_completed,search'], - 'events.*.session_id' => ['required', 'string', 'max:255'], - 'events.*.client_event_id' => ['required', 'string', 'max:255'], + 'events' => ['required', 'array', 'min:1', 'max:50'], + 'events.*.type' => ['required', 'in:page_view,add_to_cart,remove_from_cart,checkout_started,checkout_completed,search'], + 'events.*.session_id' => ['required', 'string', 'max:100'], + 'events.*.client_event_id' => ['required', 'string', 'max:100'], 'events.*.occurred_at' => ['required', 'date'], - 'events.*.properties' => ['sometimes', 'array'], + 'events.*.properties' => ['sometimes', 'array', function (string $attribute, mixed $value, \Closure $fail): void { + if ($this->depth((array) $value) > 3) { + $fail("The {$attribute} field may not be nested more than 3 levels."); + } + }], ]); - foreach ($validated['events'] as $event) { - $this->analytics->track( + $accepted = 0; + $rejected = 0; + foreach ($validated['events'] as $index => $event) { + $occurredAt = Carbon::parse($event['occurred_at']); + if ($occurredAt->lt(now()->subHour()) || $occurredAt->gt(now()->addHour())) { + throw ValidationException::withMessages(["events.{$index}.occurred_at" => 'The event timestamp must be within one hour of the current time.']); + } + $record = $this->analytics->track( app('current_store'), $event['type'], $event['properties'] ?? [], $event['session_id'], auth('customer')->id(), $event['client_event_id'], - Carbon::parse($event['occurred_at']), + $occurredAt, ); + $record?->wasRecentlyCreated ? $accepted++ : $rejected++; } - return response()->json(['accepted' => count($validated['events'])], 202); + return response()->json(['accepted' => $accepted, 'rejected' => $rejected], 202); + } + + private function depth(array $value): int + { + $depth = 1; + foreach ($value as $item) { + if (is_array($item)) { + $depth = max($depth, 1 + $this->depth($item)); + } + } + + return $depth; } } diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php index c7175995..2637fd5a 100644 --- a/app/Http/Controllers/Api/Storefront/CartController.php +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -11,6 +11,7 @@ use App\Services\CartService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; final class CartController extends Controller @@ -19,17 +20,22 @@ public function __construct(private readonly CartService $carts) {} public function store(Request $request): JsonResponse { + $validated = $request->validate([ + 'currency' => ['sometimes', 'string', 'size:3', 'regex:/^[A-Z]{3}$/'], + ]); $store = app('current_store'); $customer = auth('customer')->user(); - $cart = $this->carts->create($store, $customer); + $cart = $this->carts->create($store, $customer, $validated['currency'] ?? null); $request->session()->put('cart_id', $cart->id); + $request->session()->put('api_cart_ids', collect($request->session()->get('api_cart_ids', [])) + ->push($cart->id)->unique()->take(-20)->values()->all()); - return response()->json(['data' => $this->data($cart)], 201); + return response()->json($this->data($cart), 201); } - public function show(int $cartId): JsonResponse + public function show(Request $request, int $cartId): JsonResponse { - return response()->json(['data' => $this->data($this->cart($cartId))]); + return response()->json($this->data($this->cart($request, $cartId))); } public function addLine(Request $request, int $cartId): JsonResponse @@ -41,7 +47,7 @@ public function addLine(Request $request, int $cartId): JsonResponse ]); try { - $cart = $this->cart($cartId); + $cart = $this->cart($request, $cartId); $variant = ProductVariant::withoutGlobalScopes() ->whereKey($validated['variant_id']) ->where('status', 'active') @@ -54,7 +60,7 @@ public function addLine(Request $request, int $cartId): JsonResponse } $this->carts->addLine($cart, $variant, $validated['quantity'], $validated['expected_version'] ?? null); - return response()->json(['data' => $this->data($cart->refresh())], 201); + return response()->json($this->data($cart->refresh()), 201); } catch (CartVersionMismatchException $exception) { return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); } catch (InsufficientInventoryException $exception) { @@ -67,19 +73,21 @@ public function addLine(Request $request, int $cartId): JsonResponse public function updateLine(Request $request, int $cartId, int $lineId): JsonResponse { $validated = $request->validate([ - 'quantity' => ['required', 'integer', 'min:0', 'max:9999'], + 'quantity' => ['required', 'integer', 'min:1', 'max:9999'], 'expected_version' => ['sometimes', 'integer', 'min:1'], - 'cart_version' => ['sometimes', 'integer', 'min:1'], + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], ]); try { - $cart = $this->cart($cartId); + $cart = $this->cart($request, $cartId); $this->carts->updateLineQuantity($cart, $lineId, $validated['quantity'], $validated['expected_version'] ?? $validated['cart_version'] ?? null); - return response()->json(['data' => $this->data($cart->refresh())]); + return response()->json($this->data($cart->refresh())); } catch (CartVersionMismatchException $exception) { return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); } catch (InsufficientInventoryException $exception) { throw ValidationException::withMessages(['quantity' => $exception->getMessage()]); + } catch (DomainException $exception) { + throw ValidationException::withMessages(['cart' => $exception->getMessage()]); } } @@ -87,44 +95,78 @@ public function removeLine(Request $request, int $cartId, int $lineId): JsonResp { $validated = $request->validate([ 'expected_version' => ['sometimes', 'integer', 'min:1'], - 'cart_version' => ['sometimes', 'integer', 'min:1'], + 'cart_version' => ['required_without:expected_version', 'integer', 'min:1'], ]); try { - $cart = $this->cart($cartId); + $cart = $this->cart($request, $cartId); $this->carts->removeLine($cart, $lineId, $validated['expected_version'] ?? $validated['cart_version'] ?? null); - return response()->json(['data' => $this->data($cart->refresh())]); + return response()->json($this->data($cart->refresh())); } catch (CartVersionMismatchException $exception) { return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409); + } catch (DomainException $exception) { + throw ValidationException::withMessages(['cart' => $exception->getMessage()]); } } - private function cart(int $id): Cart + private function cart(Request $request, int $id): Cart { - return Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines.variant.product')->findOrFail($id); + $cart = Cart::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->with(['lines.variant.product.media', 'lines.variant.optionValues', 'lines.variant.inventoryItem']) + ->findOrFail($id); + $customerId = auth('customer')->id(); + $sessionCartIds = collect($request->session()->get('api_cart_ids', [])) + ->push($request->session()->get('cart_id')) + ->filter() + ->map(fn ($value): int => (int) $value); + $ownedByCustomer = $customerId !== null && (int) $cart->customer_id === (int) $customerId; + $ownedBySession = $cart->customer_id === null && $sessionCartIds->contains((int) $cart->id); + + abort_unless($ownedByCustomer || $ownedBySession, 404); + + return $cart; } /** @return array */ private function data(Cart $cart): array { - $cart->loadMissing('lines.variant.product'); + $cart->loadMissing(['lines.variant.product.media', 'lines.variant.optionValues', 'lines.variant.inventoryItem']); + $subtotal = (int) $cart->lines->sum('line_subtotal_amount'); + $discount = (int) $cart->lines->sum('line_discount_amount'); return [ 'id' => $cart->id, + 'store_id' => $cart->store_id, + 'customer_id' => $cart->customer_id, 'currency' => $cart->currency, - 'status' => $cart->status, + 'status' => $cart->status instanceof \BackedEnum ? $cart->status->value : $cart->status, 'cart_version' => $cart->cart_version, - 'subtotal_amount' => (int) $cart->lines->sum('line_subtotal_amount'), 'lines' => $cart->lines->map(fn ($line): array => [ 'id' => $line->id, 'variant_id' => $line->variant_id, - 'title' => $line->variant?->product?->title, + 'product_title' => $line->variant?->product?->title, + 'variant_title' => $line->variant?->optionValues->pluck('value')->implode(' / ') ?: 'Default', + 'sku' => $line->variant?->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_subtotal_amount' => $line->line_subtotal_amount, 'line_discount_amount' => $line->line_discount_amount, 'line_total_amount' => $line->line_total_amount, + 'image_url' => $line->variant?->product?->media->first() === null ? null : url(Storage::disk('public')->url($line->variant->product->media->first()->storage_key)), + 'requires_shipping' => (bool) $line->variant?->requires_shipping, + 'available_quantity' => $line->variant?->inventoryItem?->availableQuantity(), ])->values(), + 'totals' => [ + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => max(0, $subtotal - $discount), + 'currency' => $cart->currency, + 'line_count' => $cart->lines->count(), + 'item_count' => (int) $cart->lines->sum('quantity'), + ], + 'created_at' => $cart->created_at?->toIso8601String(), + 'updated_at' => $cart->updated_at?->toIso8601String(), ]; } } diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php index b82367e5..14e27a56 100644 --- a/app/Http/Controllers/Api/Storefront/CheckoutController.php +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -26,72 +26,87 @@ public function __construct( public function store(Request $request): JsonResponse { - $validated = $request->validate(['cart_id' => ['required', 'integer']]); - $cart = Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines')->findOrFail($validated['cart_id']); + $validated = $request->validate([ + 'cart_id' => ['required', 'integer'], + 'email' => ['required', 'email:rfc', 'max:255'], + ]); + $cart = $this->cart($request, (int) $validated['cart_id']); try { - $checkout = $this->checkouts->create($cart); + $checkout = $this->checkouts->create($cart, $validated['email']); } catch (InvalidCheckoutTransitionException $exception) { throw ValidationException::withMessages(['cart_id' => $exception->getMessage()]); } + $request->session()->put('api_checkout_ids', collect($request->session()->get('api_checkout_ids', [])) + ->push($checkout->id)->unique()->take(-20)->values()->all()); - return response()->json(['data' => $this->data($checkout)], 201); + return response()->json($this->data($checkout), 201); } - public function show(int $checkoutId): JsonResponse + public function show(Request $request, int $checkoutId): JsonResponse { - return response()->json(['data' => $this->data($this->checkout($checkoutId))]); + return response()->json($this->data($this->checkout($request, $checkoutId))); } public function address(Request $request, int $checkoutId): JsonResponse { + $current = $this->checkout($request, $checkoutId); + $current->loadMissing('cart.lines.variant'); + $requiresShipping = $this->shipping->requiresShipping($current->cart); $validated = $request->validate([ - 'email' => ['required', 'email'], - 'shipping_address' => ['required', 'array'], - 'shipping_address.first_name' => ['required', 'string'], - 'shipping_address.last_name' => ['required', 'string'], - 'shipping_address.address1' => ['required', 'string'], - 'shipping_address.city' => ['required', 'string'], - 'shipping_address.country' => ['required_without:shipping_address.country_code', 'nullable', 'string', 'size:2'], - 'shipping_address.country_code' => ['required_without:shipping_address.country', 'nullable', 'string', 'size:2'], - 'shipping_address.postal_code' => ['required_without:shipping_address.zip', 'nullable', 'string'], - 'shipping_address.zip' => ['required_without:shipping_address.postal_code', 'nullable', 'string'], - 'billing_address' => ['sometimes', 'array'], + 'email' => ['sometimes', 'email:rfc', 'max:255'], + 'shipping_address' => [$requiresShipping ? 'required' : 'nullable', 'array'], + 'shipping_address.first_name' => [$requiresShipping ? 'required' : 'nullable', 'string', 'max:255'], + 'shipping_address.last_name' => [$requiresShipping ? 'required' : 'nullable', 'string', 'max:255'], + 'shipping_address.address1' => [$requiresShipping ? 'required' : 'nullable', 'string', 'max:500'], + 'shipping_address.address2' => ['nullable', 'string', 'max:500'], + 'shipping_address.city' => [$requiresShipping ? 'required' : 'nullable', 'string', 'max:255'], + 'shipping_address.province' => ['nullable', 'string', 'max:255'], + 'shipping_address.province_code' => ['nullable', 'string', 'max:10'], + 'shipping_address.country' => [$requiresShipping ? 'required' : 'nullable', 'string', 'max:255'], + 'shipping_address.country_code' => [$requiresShipping ? 'required' : 'nullable', 'string', 'size:2'], + 'shipping_address.postal_code' => [$requiresShipping ? 'required' : 'nullable', 'string', 'max:20'], + 'shipping_address.phone' => ['nullable', 'string', 'max:50'], + 'billing_address' => ['nullable', 'array'], + 'use_shipping_as_billing' => ['sometimes', 'boolean'], ]); - $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $validated); - $rates = $this->shipping->getAvailableRates(app('current_store'), (array) $checkout->shipping_address_json, $checkout->cart); + try { + $checkout = $this->checkouts->setAddress($current, $validated); + } catch (ShippingUnavailableException $exception) { + throw ValidationException::withMessages(['shipping_address' => $exception->getMessage()]); + } - return response()->json(['data' => $this->data($checkout), 'available_shipping_rates' => $rates]); + return response()->json($this->data($checkout)); } public function shipping(Request $request, int $checkoutId): JsonResponse { - $validated = $request->validate(['shipping_rate_id' => ['nullable', 'integer']]); + $validated = $request->validate(['shipping_method_id' => ['nullable', 'integer']]); try { - $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $validated['shipping_rate_id'] ?? null); + $checkout = $this->checkouts->setShippingMethod($this->checkout($request, $checkoutId), $validated['shipping_method_id'] ?? null); } catch (ShippingUnavailableException $exception) { - throw ValidationException::withMessages(['shipping_rate_id' => $exception->getMessage()]); + throw ValidationException::withMessages(['shipping_method_id' => $exception->getMessage()]); } - return response()->json(['data' => $this->data($checkout)]); + return response()->json($this->data($checkout)); } public function payment(Request $request, int $checkoutId): JsonResponse { $validated = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]); try { - $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $validated['payment_method']); + $checkout = $this->checkouts->selectPaymentMethod($this->checkout($request, $checkoutId), $validated['payment_method']); } catch (InsufficientInventoryException $exception) { throw ValidationException::withMessages(['payment_method' => $exception->getMessage()]); } - return response()->json(['data' => $this->data($checkout)]); + return response()->json($this->data($checkout)); } public function discount(Request $request, int $checkoutId): JsonResponse { $validated = $request->validate(['code' => ['required', 'string', 'max:100']]); try { - $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), $validated['code']); + $checkout = $this->checkouts->applyDiscount($this->checkout($request, $checkoutId), $validated['code']); } catch (InvalidDiscountException $exception) { return response()->json([ 'message' => $exception->getMessage(), @@ -100,28 +115,68 @@ public function discount(Request $request, int $checkoutId): JsonResponse ], 422); } - return response()->json(['data' => $this->data($checkout)]); + return response()->json($this->data($checkout)); } - public function removeDiscount(int $checkoutId): JsonResponse + public function removeDiscount(Request $request, int $checkoutId): JsonResponse { - $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), null); + $current = $this->checkout($request, $checkoutId); + abort_if($current->discount_code === null, 404); + $checkout = $this->checkouts->applyDiscount($current, null); - return response()->json(['data' => $this->data($checkout)]); + return response()->json($this->data($checkout)); } public function pay(Request $request, int $checkoutId): JsonResponse { $details = $request->validate([ - 'card_number' => ['sometimes', 'string', 'max:30'], - 'expiry' => ['sometimes', 'string', 'max:10'], - 'cvc' => ['sometimes', 'string', 'max:4'], - 'cardholder_name' => ['sometimes', 'string', 'max:255'], + 'payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], + 'card_number' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'regex:/^(?:\d[ -]?){12,19}$/'], + 'card_expiry' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'date_format:m/y'], + 'card_cvc' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'regex:/^\d{3,4}$/'], + 'card_holder' => ['required_if:payment_method,credit_card', 'nullable', 'string', 'max:255'], ]); try { - $order = $this->checkouts->completeCheckout($this->checkout($checkoutId), $details); + $checkout = $this->checkout($request, $checkoutId); + $selected = $checkout->payment_method instanceof \BackedEnum + ? $checkout->payment_method->value + : (string) $checkout->payment_method; + if ($selected !== $details['payment_method']) { + throw ValidationException::withMessages(['payment_method' => 'The payment method must match the selected checkout payment method.']); + } + $paymentDetails = [ + 'card_number' => isset($details['card_number']) ? preg_replace('/\D+/', '', $details['card_number']) : null, + 'expiry' => $details['card_expiry'] ?? null, + 'cvc' => $details['card_cvc'] ?? null, + 'cardholder_name' => $details['card_holder'] ?? null, + ]; + $order = $this->checkouts->completeCheckout($checkout, array_filter($paymentDetails, fn ($value): bool => $value !== null)); + + $method = $order->payment_method instanceof \BackedEnum ? $order->payment_method->value : (string) $order->payment_method; + $response = [ + 'checkout_id' => $checkoutId, + 'status' => 'completed', + 'order' => [ + 'id' => $order->id, + 'order_number' => $order->order_number, + 'status' => $order->status instanceof \BackedEnum ? $order->status->value : $order->status, + 'financial_status' => $order->financial_status instanceof \BackedEnum ? $order->financial_status->value : $order->financial_status, + 'payment_method' => $method, + 'total_amount' => $order->total_amount, + 'currency' => $order->currency, + ], + ]; + if ($method === 'bank_transfer') { + $response['bank_transfer_instructions'] = [ + 'bank_name' => 'Mock Bank AG', + 'iban' => 'DE89 3704 0044 0532 0130 00', + 'bic' => 'COBADEFFXXX', + 'reference' => $order->order_number, + 'amount_formatted' => number_format($order->total_amount / 100, 2, '.', '').' '.$order->currency, + ]; + } - return response()->json(['data' => ['checkout' => $this->data($this->checkout($checkoutId)), 'order' => $order->load(['lines', 'payments'])]]); + return response()->json($response); } catch (PaymentFailedException $exception) { return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422); } catch (DomainException $exception) { @@ -129,26 +184,91 @@ public function pay(Request $request, int $checkoutId): JsonResponse } } - private function checkout(int $id): Checkout + private function cart(Request $request, int $id): Cart + { + $cart = Cart::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->where('status', 'active') + ->with('lines') + ->findOrFail($id); + + $this->assertCartAccess($request, $cart); + + return $cart; + } + + private function checkout(Request $request, int $id): Checkout + { + $checkout = Checkout::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->with(['cart.lines.variant', 'store']) + ->findOrFail($id); + $customerId = auth('customer')->id(); + $sessionIds = collect($request->session()->get('api_checkout_ids', []))->map(fn ($value): int => (int) $value); + $ownedByCustomer = $customerId !== null && (int) $checkout->customer_id === (int) $customerId; + $ownedBySession = $checkout->customer_id === null && $sessionIds->contains((int) $checkout->id); + abort_unless($ownedByCustomer || $ownedBySession, 404); + abort_if($checkout->expires_at?->isPast() && ! in_array((string) ($checkout->status instanceof \BackedEnum ? $checkout->status->value : $checkout->status), ['completed', 'expired'], true), 410); + + return $checkout; + } + + private function assertCartAccess(Request $request, Cart $cart): void { - return Checkout::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with(['cart.lines.variant', 'store'])->findOrFail($id); + $customerId = auth('customer')->id(); + $sessionIds = collect($request->session()->get('api_cart_ids', [])) + ->push($request->session()->get('cart_id')) + ->filter() + ->map(fn ($value): int => (int) $value); + $ownedByCustomer = $customerId !== null && (int) $cart->customer_id === (int) $customerId; + $ownedBySession = $cart->customer_id === null && $sessionIds->contains((int) $cart->id); + abort_unless($ownedByCustomer || $ownedBySession, 404); } /** @return array */ private function data(Checkout $checkout): array { + $checkout->loadMissing(['cart.lines.variant.product', 'cart.lines.variant.optionValues', 'store']); + $requiresShipping = $this->shipping->requiresShipping($checkout->cart); + $rates = $checkout->shipping_address_json === null + ? collect() + : $this->shipping->getAvailableRates($checkout->store, (array) $checkout->shipping_address_json, $checkout->cart); + return [ 'id' => $checkout->id, + 'store_id' => $checkout->store_id, 'cart_id' => $checkout->cart_id, - 'status' => $checkout->status, + 'customer_id' => $checkout->customer_id, + 'status' => $checkout->status instanceof \BackedEnum ? $checkout->status->value : $checkout->status, 'email' => $checkout->email, - 'shipping_address' => $checkout->shipping_address_json, - 'billing_address' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'billing_address_json' => $checkout->billing_address_json, 'shipping_method_id' => $checkout->shipping_method_id, - 'payment_method' => $checkout->payment_method, + 'payment_method' => $checkout->payment_method instanceof \BackedEnum ? $checkout->payment_method->value : $checkout->payment_method, 'discount_code' => $checkout->discount_code, + 'lines' => $checkout->cart->lines->map(fn ($line): array => [ + 'variant_id' => $line->variant_id, + 'product_title' => $line->variant?->product?->title, + 'variant_title' => $line->variant?->optionValues->pluck('value')->implode(' / ') ?: 'Default', + 'sku' => $line->variant?->sku, + 'quantity' => $line->quantity, + 'unit_price_amount' => $line->unit_price_amount, + 'line_total_amount' => $line->line_total_amount, + ])->values(), 'totals' => $checkout->totals_json, - 'expires_at' => $checkout->expires_at, + 'tax_provider_snapshot_json' => $checkout->tax_provider_snapshot_json, + 'requires_shipping' => $requiresShipping, + 'available_shipping_methods' => $rates->map(fn ($rate): array => [ + 'id' => $rate->id, + 'name' => $rate->name, + 'type' => $rate->type instanceof \BackedEnum ? $rate->type->value : $rate->type, + 'price_amount' => (int) $rate->calculated_amount, + 'currency' => $checkout->cart->currency, + 'estimated_days_min' => data_get($rate->config_json, 'estimated_days_min'), + 'estimated_days_max' => data_get($rate->config_json, 'estimated_days_max'), + ])->values(), + 'expires_at' => $checkout->expires_at?->toIso8601String(), + 'created_at' => $checkout->created_at?->toIso8601String(), ]; } } diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php index 7ddea724..ef976d2a 100644 --- a/app/Http/Controllers/Api/Storefront/OrderController.php +++ b/app/Http/Controllers/Api/Storefront/OrderController.php @@ -4,16 +4,18 @@ use App\Http\Controllers\Controller; use App\Models\Order; +use App\Services\OrderStatusLink; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; final class OrderController extends Controller { + public function __construct(private readonly OrderStatusLink $links) {} + public function show(Request $request, string $orderNumber): JsonResponse { $order = Order::withoutGlobalScopes()->where('store_id', app('current_store')->id)->where('order_number', $orderNumber)->with(['lines', 'fulfillments'])->firstOrFail(); - $expected = hash_hmac('sha256', $order->order_number.'|'.$order->email, (string) config('app.key')); - abort_unless(is_string($request->query('token')) && hash_equals($expected, $request->query('token')), 401); + abort_unless($this->links->verify($order, $request->query('token')), 401); return response()->json([ 'order_number' => $order->order_number, diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php index 3db5d778..4358f780 100644 --- a/app/Http/Controllers/Api/Storefront/SearchController.php +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -6,6 +6,8 @@ use App\Services\SearchService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use JsonException; final class SearchController extends Controller { @@ -13,16 +15,80 @@ public function __construct(private readonly SearchService $search) {} public function index(Request $request): JsonResponse { - $validated = $request->validate(['q' => ['required', 'string', 'min:1', 'max:200'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50']]); - $results = $this->search->search(app('current_store'), $validated['q'], $request->only(['vendor', 'collection_id']), $validated['per_page'] ?? 12); + $validated = $request->validate([ + 'q' => ['required', 'string', 'min:1', 'max:200'], + 'filters' => ['sometimes', 'string', 'max:10000'], + 'sort' => ['sometimes', 'in:relevance,price_asc,price_desc,newest,best_selling'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'], + ]); + try { + $filters = isset($validated['filters']) ? json_decode($validated['filters'], true, 8, JSON_THROW_ON_ERROR) : []; + } catch (JsonException) { + return response()->json(['message' => 'The filters parameter must be valid JSON.'], 400); + } + if (! is_array($filters) || ($filters !== [] && array_is_list($filters))) { + return response()->json(['message' => 'The filters parameter must be a JSON object.'], 400); + } + validator($filters, [ + 'collection_id' => ['sometimes', 'integer'], + 'price_min' => ['sometimes', 'integer', 'min:0'], + 'price_max' => ['sometimes', 'integer', 'gte:price_min'], + 'in_stock' => ['sometimes', 'boolean'], + 'tags' => ['sometimes', 'array', 'max:20'], + 'tags.*' => ['string', 'max:100'], + 'vendor' => ['sometimes', 'string', 'max:255'], + ])->validate(); + $payload = $this->search->searchWithFacets(app('current_store'), $validated['q'], $filters, $validated['sort'] ?? 'relevance', $validated['per_page'] ?? 24); + $results = $payload['paginator']; - return response()->json(['data' => $results->items(), 'meta' => ['current_page' => $results->currentPage(), 'last_page' => $results->lastPage(), 'total' => $results->total()]]); + return response()->json([ + 'query' => $validated['q'], + 'results' => collect($results->items())->map(fn ($product): array => $this->productData($product))->values(), + 'facets' => $payload['facets'], + 'pagination' => ['current_page' => $results->currentPage(), 'per_page' => $results->perPage(), 'total' => $results->total(), 'last_page' => $results->lastPage()], + ]); } public function suggest(Request $request): JsonResponse { - $validated = $request->validate(['q' => ['required', 'string', 'min:2', 'max:100']]); + $validated = $request->validate(['q' => ['required', 'string', 'min:1', 'max:100'], 'limit' => ['sometimes', 'integer', 'min:1', 'max:10']]); + $suggestions = $this->search->suggestions(app('current_store'), $validated['q'], $validated['limit'] ?? 5) + ->map(function (array $suggestion): array { + $model = $suggestion['model']; + if ($suggestion['type'] === 'collection') { + return ['type' => 'collection', 'title' => $model->title, 'handle' => $model->handle, 'image_url' => null]; + } + $data = $this->productData($model); - return response()->json(['data' => $this->search->autocomplete(app('current_store'), $validated['q'])]); + return ['type' => 'product', 'title' => $data['title'], 'handle' => $data['handle'], 'image_url' => $data['image_url'], 'price_amount' => $data['price_amount'], 'currency' => $data['currency']]; + }); + + return response()->json(['query' => $validated['q'], 'suggestions' => $suggestions]); + } + + /** @return array */ + private function productData($product): array + { + $variant = $product->variants->where('status.value', 'active')->sortBy('price_amount')->first() + ?? $product->variants->sortBy('price_amount')->first(); + $media = $product->media->first(); + $inStock = $product->variants->contains(fn ($item): bool => $item->inventoryItem === null + || (string) ($item->inventoryItem->policy instanceof \BackedEnum ? $item->inventoryItem->policy->value : $item->inventoryItem->policy) === 'continue' + || $item->inventoryItem->availableQuantity() > 0); + + return [ + 'id' => $product->id, + 'title' => $product->title, + 'handle' => $product->handle, + 'vendor' => $product->vendor, + 'product_type' => $product->product_type, + 'price_amount' => (int) ($variant?->price_amount ?? 0), + 'compare_at_amount' => $variant?->compare_at_amount, + 'currency' => $variant?->currency ?? app('current_store')->default_currency, + 'image_url' => $media === null ? null : url(Storage::disk('public')->url($media->storage_key)), + 'in_stock' => $inStock, + 'tags' => (array) $product->tags, + ]; } } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 8677cd5c..e7f7c94b 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; + abstract class Controller { - // + use AuthorizesRequests; } diff --git a/app/Http/Middleware/EnsureUserIsActive.php b/app/Http/Middleware/EnsureUserIsActive.php new file mode 100644 index 00000000..b4a3d38d --- /dev/null +++ b/app/Http/Middleware/EnsureUserIsActive.php @@ -0,0 +1,45 @@ +user(); + if (! $user instanceof User || $this->status($user) === UserStatus::Active->value) { + return $next($request); + } + + $token = $user->currentAccessToken(); + if ($token instanceof PersonalAccessToken) { + $token->delete(); + } + + Auth::guard('web')->logout(); + if ($request->hasSession()) { + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + if ($request->expectsJson() || $request->is('api/*')) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + return redirect()->guest($request->is('admin', 'admin/*') ? '/admin/login' : '/login'); + } + + private function status(User $user): string + { + return $user->status instanceof BackedEnum ? (string) $user->status->value : (string) $user->status; + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php index 76d2e289..bdc7cc5a 100644 --- a/app/Http/Middleware/ResolveStore.php +++ b/app/Http/Middleware/ResolveStore.php @@ -37,7 +37,13 @@ public function handle(Request $request, Closure $next): Response private function fromHostname(Request $request): Store { $hostname = mb_strtolower($request->getHost()); - $storeId = Cache::remember("store_domain:{$hostname}", now()->addMinutes(5), fn (): ?int => StoreDomain::query()->where('hostname', $hostname)->value('store_id')); + $cacheKey = "store_domain:{$hostname}"; + $storeId = Cache::get($cacheKey); + if ($storeId !== null && ! StoreDomain::withoutGlobalScopes()->where('hostname', $hostname)->where('store_id', $storeId)->exists()) { + Cache::forget($cacheKey); + $storeId = null; + } + $storeId ??= Cache::remember($cacheKey, now()->addMinutes(5), fn (): ?int => StoreDomain::withoutGlobalScopes()->where('hostname', $hostname)->value('store_id')); abort_if($storeId === null, 404); return Store::query()->findOrFail($storeId); @@ -65,6 +71,12 @@ private function fromAdminApiRoute(Request $request): Store abort_if($user === null, 401); $storeId = (int) $request->route('storeId'); abort_unless($storeId > 0 && $user->stores()->whereKey($storeId)->exists(), 403); + abort_unless( + $user->currentAccessToken() !== null + && ($user->tokenCan("store:{$storeId}") || $user->tokenCan('manage-platform')), + 403, + 'This API token is not authorized for the requested store.', + ); return Store::query()->findOrFail($storeId); } diff --git a/app/Http/Requests/ApplyDiscountRequest.php b/app/Http/Requests/ApplyDiscountRequest.php new file mode 100644 index 00000000..c84adca5 --- /dev/null +++ b/app/Http/Requests/ApplyDiscountRequest.php @@ -0,0 +1,18 @@ +bound('current_store'); + } + + public function rules(): array + { + return ['code' => ['required', 'string', 'max:100']]; + } +} diff --git a/app/Http/Requests/CreateFulfillmentRequest.php b/app/Http/Requests/CreateFulfillmentRequest.php index 68d161c9..ff3a0f6f 100644 --- a/app/Http/Requests/CreateFulfillmentRequest.php +++ b/app/Http/Requests/CreateFulfillmentRequest.php @@ -2,17 +2,29 @@ namespace App\Http\Requests; +use App\Models\Order; +use App\Support\SafeUrl; use Illuminate\Foundation\Http\FormRequest; class CreateFulfillmentRequest extends FormRequest { public function authorize(): bool { - return true; + $order = Order::withoutGlobalScopes()->where('store_id', app('current_store')->id) + ->find($this->route('orderId') ?? $this->route('order')); + + return $order !== null && ($this->user()?->can('createFulfillment', $order) ?? false); } public function rules(): array { - return ['lines' => ['required', 'array', 'min:1'], 'lines.*' => ['required', 'integer', 'min:1'], 'tracking_company' => ['nullable', 'string', 'max:100'], 'tracking_number' => ['nullable', 'string', 'max:255'], 'tracking_url' => ['nullable', 'url']]; + return ['line_items' => ['required_without:lines', 'array', 'min:1'], 'line_items.*.order_line_id' => ['required_with:line_items', 'integer'], 'line_items.*.quantity' => ['required_with:line_items', 'integer', 'min:1'], 'lines' => ['required_without:line_items', 'array', 'min:1'], 'lines.*' => ['required_with:lines', 'integer', 'min:1'], 'tracking_company' => ['nullable', 'string', 'max:100'], 'tracking_number' => ['nullable', 'string', 'max:255'], 'tracking_url' => [ + 'nullable', 'string', 'max:2048', + function (string $attribute, mixed $value, \Closure $fail): void { + if (! SafeUrl::isAllowed($value)) { + $fail('The tracking link must be a relative, HTTP, or HTTPS URL.'); + } + }, + ], 'notify_customer' => ['sometimes', 'boolean']]; } } diff --git a/app/Http/Requests/CreateRefundRequest.php b/app/Http/Requests/CreateRefundRequest.php index ac02fbc1..e2141c71 100644 --- a/app/Http/Requests/CreateRefundRequest.php +++ b/app/Http/Requests/CreateRefundRequest.php @@ -2,17 +2,21 @@ namespace App\Http\Requests; +use App\Models\Order; use Illuminate\Foundation\Http\FormRequest; class CreateRefundRequest extends FormRequest { public function authorize(): bool { - return true; + $order = Order::withoutGlobalScopes()->where('store_id', app('current_store')->id) + ->find($this->route('orderId') ?? $this->route('order')); + + return $order !== null && ($this->user()?->can('createRefund', $order) ?? false); } public function rules(): array { - return ['amount' => ['required_without:lines', 'integer', 'min:1'], 'lines' => ['required_without:amount', 'array'], 'lines.*' => ['integer', 'min:1'], 'reason' => ['nullable', 'string', 'max:1000'], 'restock' => ['sometimes', 'boolean']]; + return ['amount' => ['required_without_all:lines,line_items', 'nullable', 'integer', 'min:1'], 'lines' => ['sometimes', 'array'], 'lines.*' => ['integer', 'min:1'], 'line_items' => ['sometimes', 'array'], 'line_items.*.order_line_id' => ['required_with:line_items', 'integer'], 'line_items.*.quantity' => ['required_with:line_items', 'integer', 'min:1'], 'reason' => ['nullable', 'string', 'max:1000'], 'restock' => ['sometimes', 'boolean'], 'notify_customer' => ['sometimes', 'boolean']]; } } diff --git a/app/Http/Requests/InviteStaffRequest.php b/app/Http/Requests/InviteStaffRequest.php new file mode 100644 index 00000000..1e89b759 --- /dev/null +++ b/app/Http/Requests/InviteStaffRequest.php @@ -0,0 +1,19 @@ +user() !== null && Gate::forUser($this->user())->allows('manage-staff'); + } + + public function rules(): array + { + return ['email' => ['required', 'email', 'max:255'], 'role' => ['required', 'in:admin,staff,support']]; + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 00000000..c5f5aeea --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,18 @@ + ['required', 'email', 'max:255'], 'password' => ['required', 'string']]; + } +} diff --git a/app/Http/Requests/StoreCollectionRequest.php b/app/Http/Requests/StoreCollectionRequest.php index 4e0fe0e7..5f223020 100644 --- a/app/Http/Requests/StoreCollectionRequest.php +++ b/app/Http/Requests/StoreCollectionRequest.php @@ -2,17 +2,21 @@ namespace App\Http\Requests; +use App\Models\Collection; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreCollectionRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user()?->can('create', Collection::class) ?? false; } public function rules(): array { - return ['title' => ['required', 'string', 'max:255'], 'handle' => ['sometimes', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'type' => ['sometimes', 'in:manual,automated'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array'], 'product_ids.*' => ['integer']]; + $storeId = (int) ($this->route('storeId') ?? app('current_store')->id); + + return ['title' => ['required', 'string', 'max:255'], 'handle' => ['sometimes', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'type' => ['sometimes', 'in:manual,automated'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array'], 'product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)], 'add_product_ids' => ['sometimes', 'array'], 'add_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)], 'remove_product_ids' => ['sometimes', 'array'], 'remove_product_ids.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $storeId)]]; } } diff --git a/app/Http/Requests/StoreCustomerAddressRequest.php b/app/Http/Requests/StoreCustomerAddressRequest.php index 9a314346..9069b73d 100644 --- a/app/Http/Requests/StoreCustomerAddressRequest.php +++ b/app/Http/Requests/StoreCustomerAddressRequest.php @@ -3,12 +3,13 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Auth; class StoreCustomerAddressRequest extends FormRequest { public function authorize(): bool { - return true; + return Auth::guard('customer')->check(); } public function rules(): array diff --git a/app/Http/Requests/StoreDiscountRequest.php b/app/Http/Requests/StoreDiscountRequest.php index 0a8e3974..f32983b3 100644 --- a/app/Http/Requests/StoreDiscountRequest.php +++ b/app/Http/Requests/StoreDiscountRequest.php @@ -2,13 +2,14 @@ namespace App\Http\Requests; +use App\Models\Discount; use Illuminate\Foundation\Http\FormRequest; class StoreDiscountRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user()?->can('create', Discount::class) ?? false; } public function rules(): array diff --git a/app/Http/Requests/StorePageRequest.php b/app/Http/Requests/StorePageRequest.php index 3ea43db3..2e2b0423 100644 --- a/app/Http/Requests/StorePageRequest.php +++ b/app/Http/Requests/StorePageRequest.php @@ -2,13 +2,14 @@ namespace App\Http\Requests; +use App\Models\Page; use Illuminate\Foundation\Http\FormRequest; class StorePageRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user()?->can('create', Page::class) ?? false; } public function rules(): array diff --git a/app/Http/Requests/StoreProductRequest.php b/app/Http/Requests/StoreProductRequest.php index 9dd904a6..53e19e84 100644 --- a/app/Http/Requests/StoreProductRequest.php +++ b/app/Http/Requests/StoreProductRequest.php @@ -2,13 +2,14 @@ namespace App\Http\Requests; +use App\Models\Product; use Illuminate\Foundation\Http\FormRequest; class StoreProductRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user()?->can('create', Product::class) ?? false; } /** @return array */ diff --git a/app/Http/Requests/StoreShippingRateRequest.php b/app/Http/Requests/StoreShippingRateRequest.php index 1f42c122..9c55e0b3 100644 --- a/app/Http/Requests/StoreShippingRateRequest.php +++ b/app/Http/Requests/StoreShippingRateRequest.php @@ -3,12 +3,13 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Gate; class StoreShippingRateRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user() !== null && Gate::forUser($this->user())->allows('manage-shipping'); } public function rules(): array diff --git a/app/Http/Requests/StoreShippingZoneRequest.php b/app/Http/Requests/StoreShippingZoneRequest.php index 8b2f6305..e4c0858d 100644 --- a/app/Http/Requests/StoreShippingZoneRequest.php +++ b/app/Http/Requests/StoreShippingZoneRequest.php @@ -3,12 +3,13 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Gate; class StoreShippingZoneRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user() !== null && Gate::forUser($this->user())->allows('manage-shipping'); } public function rules(): array diff --git a/app/Http/Requests/UpdateCollectionRequest.php b/app/Http/Requests/UpdateCollectionRequest.php index 8186c177..f3b9e9e1 100644 --- a/app/Http/Requests/UpdateCollectionRequest.php +++ b/app/Http/Requests/UpdateCollectionRequest.php @@ -2,8 +2,20 @@ namespace App\Http\Requests; +use App\Models\Collection; + class UpdateCollectionRequest extends StoreCollectionRequest { + public function authorize(): bool + { + $collection = $this->route('collection'); + $collection = $collection instanceof Collection ? $collection : Collection::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->find($this->route('collectionId')); + + return $collection !== null && ($this->user()?->can('update', $collection) ?? false); + } + public function rules(): array { return ['title' => ['sometimes', 'string', 'max:255'], ...collect(parent::rules())->except('title')->all()]; diff --git a/app/Http/Requests/UpdateDiscountRequest.php b/app/Http/Requests/UpdateDiscountRequest.php index 42490bc8..ada09bf1 100644 --- a/app/Http/Requests/UpdateDiscountRequest.php +++ b/app/Http/Requests/UpdateDiscountRequest.php @@ -2,8 +2,20 @@ namespace App\Http\Requests; +use App\Models\Discount; + class UpdateDiscountRequest extends StoreDiscountRequest { + public function authorize(): bool + { + $discount = $this->route('discount'); + $discount = $discount instanceof Discount ? $discount : Discount::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->find($this->route('discountId')); + + return $discount !== null && ($this->user()?->can('update', $discount) ?? false); + } + public function rules(): array { return collect(parent::rules())->map(fn (array $rules): array => ['sometimes', ...array_values(array_filter($rules, fn (string $rule): bool => $rule !== 'required'))])->all(); diff --git a/app/Http/Requests/UpdatePageRequest.php b/app/Http/Requests/UpdatePageRequest.php index 78f02005..67d1ec0e 100644 --- a/app/Http/Requests/UpdatePageRequest.php +++ b/app/Http/Requests/UpdatePageRequest.php @@ -2,8 +2,20 @@ namespace App\Http\Requests; +use App\Models\Page; + class UpdatePageRequest extends StorePageRequest { + public function authorize(): bool + { + $page = $this->route('page'); + $page = $page instanceof Page ? $page : Page::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->find($this->route('pageId')); + + return $page !== null && ($this->user()?->can('update', $page) ?? false); + } + public function rules(): array { return ['title' => ['sometimes', 'string', 'max:255'], ...collect(parent::rules())->except('title')->all()]; diff --git a/app/Http/Requests/UpdateProductRequest.php b/app/Http/Requests/UpdateProductRequest.php index 886f485b..e247a713 100644 --- a/app/Http/Requests/UpdateProductRequest.php +++ b/app/Http/Requests/UpdateProductRequest.php @@ -2,8 +2,20 @@ namespace App\Http\Requests; +use App\Models\Product; + class UpdateProductRequest extends StoreProductRequest { + public function authorize(): bool + { + $product = $this->route('product'); + $product = $product instanceof Product ? $product : Product::withoutGlobalScopes() + ->where('store_id', app('current_store')->id) + ->find($this->route('productId')); + + return $product !== null && ($this->user()?->can('update', $product) ?? false); + } + public function rules(): array { return collect(parent::rules())->map(fn (array $rules): array => ['sometimes', ...array_values(array_filter($rules, fn (string $rule): bool => $rule !== 'required'))])->all(); diff --git a/app/Http/Requests/UpdateStoreSettingsRequest.php b/app/Http/Requests/UpdateStoreSettingsRequest.php new file mode 100644 index 00000000..8fbcae35 --- /dev/null +++ b/app/Http/Requests/UpdateStoreSettingsRequest.php @@ -0,0 +1,24 @@ +user() !== null && Gate::forUser($this->user())->allows('manage-store-settings'); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'default_currency' => ['required', 'string', 'size:3'], + 'default_locale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'timezone'], + ]; + } +} diff --git a/app/Http/Requests/UpdateTaxSettingsRequest.php b/app/Http/Requests/UpdateTaxSettingsRequest.php index 316ca167..eaa2c8fa 100644 --- a/app/Http/Requests/UpdateTaxSettingsRequest.php +++ b/app/Http/Requests/UpdateTaxSettingsRequest.php @@ -3,12 +3,13 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Gate; class UpdateTaxSettingsRequest extends FormRequest { public function authorize(): bool { - return true; + return $this->user() !== null && Gate::forUser($this->user())->allows('manage-taxes'); } public function rules(): array diff --git a/app/Jobs/AggregateAnalytics.php b/app/Jobs/AggregateAnalytics.php index 38b6bd8a..fa2a81d4 100644 --- a/app/Jobs/AggregateAnalytics.php +++ b/app/Jobs/AggregateAnalytics.php @@ -14,6 +14,13 @@ final class AggregateAnalytics implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public int $tries = 3; + + public int $timeout = 300; + + /** @var list */ + public array $backoff = [10, 60, 300]; + public function __construct(public readonly ?string $date = null) {} public function handle(AnalyticsService $analytics): void diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php index 5c57032a..82852d7b 100644 --- a/app/Jobs/CancelUnpaidBankTransferOrders.php +++ b/app/Jobs/CancelUnpaidBankTransferOrders.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Jobs\Concerns\RestoresCurrentStore; use App\Models\Order; use App\Services\OrderService; use Illuminate\Bus\Queueable; @@ -13,21 +14,31 @@ final class CancelUnpaidBankTransferOrders implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use RestoresCurrentStore; + + public int $tries = 3; + + public int $timeout = 300; + + /** @var list */ + public array $backoff = [10, 60, 300]; public function handle(OrderService $orders): void { - Order::withoutGlobalScopes() - ->where('payment_method', 'bank_transfer') - ->where('financial_status', 'pending') - ->with('store.settings') - ->chunkById(100, function ($pending) use ($orders): void { - foreach ($pending as $order) { - app()->instance('current_store', $order->store); - $days = (int) data_get($order->store?->settings?->settings_json, 'bank_transfer_cancel_days', 7); - if ($order->placed_at?->lt(now()->subDays($days))) { - $orders->cancel($order, 'Bank transfer payment timeout'); + $this->restoringCurrentStore(function () use ($orders): void { + Order::withoutGlobalScopes() + ->where('payment_method', 'bank_transfer') + ->where('financial_status', 'pending') + ->with('store.settings') + ->chunkById(100, function ($pending) use ($orders): void { + foreach ($pending as $order) { + app()->instance('current_store', $order->store); + $days = (int) data_get($order->store?->settings?->settings_json, 'bank_transfer_cancel_days', 7); + if ($order->placed_at?->lt(now()->subDays($days))) { + $orders->cancel($order, 'Bank transfer payment timeout'); + } } - } - }); + }); + }); } } diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php index f33301d3..0ba06f37 100644 --- a/app/Jobs/CleanupAbandonedCarts.php +++ b/app/Jobs/CleanupAbandonedCarts.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Jobs\Concerns\RestoresCurrentStore; use App\Models\Cart; use App\Models\Store; use App\Services\CheckoutService; @@ -14,19 +15,29 @@ final class CleanupAbandonedCarts implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use RestoresCurrentStore; + + public int $tries = 3; + + public int $timeout = 300; + + /** @var list */ + public array $backoff = [10, 60, 300]; public function handle(CheckoutService $checkouts): void { - Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14)) - ->with(['checkouts' => fn ($query) => $query->withoutGlobalScopes()]) - ->chunkById(100, function ($carts) use ($checkouts): void { - foreach ($carts as $cart) { - app()->instance('current_store', Store::query()->findOrFail($cart->store_id)); - foreach ($cart->checkouts->whereNotIn('status', ['completed', 'expired']) as $checkout) { - $checkouts->expireCheckout($checkout); + $this->restoringCurrentStore(function () use ($checkouts): void { + Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14)) + ->with(['checkouts' => fn ($query) => $query->withoutGlobalScopes()]) + ->chunkById(100, function ($carts) use ($checkouts): void { + foreach ($carts as $cart) { + app()->instance('current_store', Store::query()->findOrFail($cart->store_id)); + foreach ($cart->checkouts->whereNotIn('status', ['completed', 'expired']) as $checkout) { + $checkouts->expireCheckout($checkout); + } + $cart->update(['status' => 'abandoned']); } - $cart->update(['status' => 'abandoned']); - } - }); + }); + }); } } diff --git a/app/Jobs/Concerns/RestoresCurrentStore.php b/app/Jobs/Concerns/RestoresCurrentStore.php new file mode 100644 index 00000000..d6f5d125 --- /dev/null +++ b/app/Jobs/Concerns/RestoresCurrentStore.php @@ -0,0 +1,24 @@ +bound('current_store'); + $previousStore = $hadStore ? app('current_store') : null; + + try { + return $callback(); + } finally { + if ($hadStore) { + app()->instance('current_store', $previousStore); + } else { + app()->forgetInstance('current_store'); + } + } + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php index c40ef75e..ed3f1e22 100644 --- a/app/Jobs/DeliverWebhook.php +++ b/app/Jobs/DeliverWebhook.php @@ -4,10 +4,11 @@ use App\Models\WebhookDelivery; use App\Services\WebhookService; +use App\Services\WebhookTargetValidator; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; -use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -19,6 +20,8 @@ final class DeliverWebhook implements ShouldQueue public int $tries = 6; + public int $timeout = 30; + /** @var list */ public array $backoff = [60, 300, 1800, 7200, 43200]; @@ -40,17 +43,23 @@ public function handle(WebhookService $webhooks): void $body = json_encode($this->payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); $secret = (string) $delivery->subscription->signing_secret_encrypted; + $timestamp = (string) now()->timestamp; $delivery->increment('attempt_count'); $delivery->update(['last_attempt_at' => now()]); try { - $response = Http::timeout(10)->withHeaders([ - 'X-Platform-Signature' => $webhooks->sign($body, $secret), + $target = app(WebhookTargetValidator::class)->validate($delivery->subscription->target_url); + $options = ['allow_redirects' => false]; + if (defined('CURLOPT_RESOLVE')) { + $options['curl'] = [CURLOPT_RESOLVE => [$target->curlResolveEntry()]]; + } + $response = Http::timeout(10)->connectTimeout(5)->withOptions($options)->withHeaders([ + 'X-Platform-Signature' => $webhooks->sign("{$timestamp}.{$body}", $secret), 'X-Platform-Event' => $this->eventType, 'X-Platform-Delivery-Id' => $delivery->event_id, - 'X-Platform-Timestamp' => (string) now()->timestamp, + 'X-Platform-Timestamp' => $timestamp, 'Content-Type' => 'application/json', - ])->withBody($body, 'application/json')->post($delivery->subscription->target_url); + ])->withBody($body, 'application/json')->post($target->url); $delivery->update([ 'status' => $response->successful() ? 'success' : 'failed', @@ -58,8 +67,7 @@ public function handle(WebhookService $webhooks): void 'response_body_snippet' => mb_substr($response->body(), 0, 1000), ]); if (! $response->successful()) { - $webhooks->recordFailure($delivery); - $response->throw(); + throw new RequestException($response); } } catch (Throwable $exception) { $delivery->update(['status' => 'failed', 'response_body_snippet' => mb_substr($exception->getMessage(), 0, 1000)]); diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php index 0f83873c..3840beba 100644 --- a/app/Jobs/ExpireAbandonedCheckouts.php +++ b/app/Jobs/ExpireAbandonedCheckouts.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Jobs\Concerns\RestoresCurrentStore; use App\Models\Checkout; use App\Models\Store; use App\Services\CheckoutService; @@ -14,17 +15,27 @@ final class ExpireAbandonedCheckouts implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use RestoresCurrentStore; + + public int $tries = 3; + + public int $timeout = 300; + + /** @var list */ + public array $backoff = [10, 60, 300]; public function handle(CheckoutService $service): void { - Checkout::withoutGlobalScopes() - ->whereNotIn('status', ['completed', 'expired']) - ->where(function ($query): void { - $query->where('expires_at', '<', now())->orWhere('updated_at', '<', now()->subDay()); - }) - ->chunkById(100, fn ($checkouts) => $checkouts->each(function (Checkout $checkout) use ($service): void { - app()->instance('current_store', Store::query()->findOrFail($checkout->store_id)); - $service->expireCheckout($checkout); - })); + $this->restoringCurrentStore(function () use ($service): void { + Checkout::withoutGlobalScopes() + ->whereNotIn('status', ['completed', 'expired']) + ->where(function ($query): void { + $query->where('expires_at', '<', now())->orWhere('updated_at', '<', now()->subDay()); + }) + ->chunkById(100, fn ($checkouts) => $checkouts->each(function (Checkout $checkout) use ($service): void { + app()->instance('current_store', Store::query()->findOrFail($checkout->store_id)); + $service->expireCheckout($checkout); + })); + }); } } diff --git a/app/Jobs/GenerateOrderExport.php b/app/Jobs/GenerateOrderExport.php index 1112e444..5c548c65 100644 --- a/app/Jobs/GenerateOrderExport.php +++ b/app/Jobs/GenerateOrderExport.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Models\Checkout; use App\Models\Order; use App\Models\OrderExport; use Illuminate\Bus\Queueable; @@ -18,6 +19,11 @@ final class GenerateOrderExport implements ShouldQueue public int $tries = 3; + public int $timeout = 120; + + /** @var list */ + public array $backoff = [10, 60, 300]; + public function __construct(public readonly OrderExport $export) {} public function handle(): void @@ -37,15 +43,21 @@ public function handle(): void ->when($filters['financial_status'] ?? null, fn ($query, $status) => $query->where('financial_status', $status)) ->when($filters['created_after'] ?? null, fn ($query, $date) => $query->where('created_at', '>=', $date)) ->when($filters['created_before'] ?? null, fn ($query, $date) => $query->where('created_at', '<=', $date)) - ->with('fulfillments') + ->with(['customer', 'fulfillments']) ->orderBy('id') ->get(); + $shippingMethods = Checkout::withoutGlobalScopes() + ->where('store_id', $export->store_id) + ->where('status', 'completed') + ->with('shippingRate') + ->get() + ->mapWithKeys(fn (Checkout $checkout): array => [(int) data_get($checkout->totals_json, 'order_id') => $checkout->shippingRate?->name]); $stream = fopen('php://temp', 'w+b'); if ($stream === false) { throw new \RuntimeException('Could not create the export stream.'); } - fputcsv($stream, ['order_number', 'created_at', 'status', 'financial_status', 'fulfillment_status', 'customer_email', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'currency', 'tracking_number']); + fputcsv($stream, ['order_number', 'created_at', 'status', 'financial_status', 'fulfillment_status', 'customer_email', 'customer_name', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'currency', 'shipping_method', 'tracking_number']); foreach ($orders as $order) { fputcsv($stream, [ $order->order_number, @@ -54,12 +66,14 @@ public function handle(): void $order->financial_status instanceof \BackedEnum ? $order->financial_status->value : $order->financial_status, $order->fulfillment_status instanceof \BackedEnum ? $order->fulfillment_status->value : $order->fulfillment_status, $order->email, + $order->customer?->name ?: trim((string) data_get($order->shipping_address_json, 'first_name').' '.(string) data_get($order->shipping_address_json, 'last_name')), $order->subtotal_amount, $order->discount_amount, $order->shipping_amount, $order->tax_amount, $order->total_amount, $order->currency, + $shippingMethods->get($order->id), $order->fulfillments->first()?->tracking_number, ]); } diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php index 09eadcb8..0b416257 100644 --- a/app/Jobs/ProcessMediaUpload.php +++ b/app/Jobs/ProcessMediaUpload.php @@ -18,6 +18,11 @@ final class ProcessMediaUpload implements ShouldQueue public int $tries = 3; + public int $timeout = 120; + + /** @var list */ + public array $backoff = [10, 60, 300]; + public function __construct(public readonly ProductMedia $media) {} public function handle(): void diff --git a/app/Jobs/ReindexProducts.php b/app/Jobs/ReindexProducts.php new file mode 100644 index 00000000..2b674fd6 --- /dev/null +++ b/app/Jobs/ReindexProducts.php @@ -0,0 +1,77 @@ + */ + public array $backoff = [10, 60, 300]; + + public function __construct(public readonly int $storeId) {} + + public function handle(SearchService $search): void + { + $startedAt = microtime(true); + $previous = app()->bound('current_store') ? app('current_store') : null; + $store = Store::query()->findOrFail($this->storeId); + app()->instance('current_store', $store); + + try { + $products = Product::withoutGlobalScopes()->where('store_id', $store->id)->orderBy('id')->get(); + $total = max(1, $products->count()); + $this->status('processing', 0, ['pending_updates' => $products->count()]); + foreach ($products as $index => $product) { + $search->syncProduct($product); + $this->status('processing', (int) round((($index + 1) / $total) * 100), [ + 'pending_updates' => max(0, $products->count() - $index - 1), + ]); + } + $this->status('ready', 100, [ + 'last_reindex_at' => now()->toIso8601String(), + 'last_reindex_duration_seconds' => max(0, (int) round(microtime(true) - $startedAt)), + 'documents_count' => $products->count(), + 'pending_updates' => 0, + ]); + } catch (Throwable $exception) { + $this->status('failed', 0, ['pending_updates' => 0]); + throw $exception; + } finally { + if ($previous !== null) { + app()->instance('current_store', $previous); + } else { + app()->forgetInstance('current_store'); + } + } + } + + /** @param array $details */ + private function status(string $status, int $progress, array $details = []): void + { + $record = StoreSettings::withoutGlobalScopes()->firstOrNew(['store_id' => $this->storeId]); + $settings = (array) $record->settings_json; + data_set($settings, 'search.status', $status); + data_set($settings, 'search.progress', $progress); + foreach ($details as $key => $value) { + data_set($settings, 'search.'.$key, $value); + } + $record->settings_json = $settings; + $record->save(); + } +} diff --git a/app/Listeners/ShopEventSubscriber.php b/app/Listeners/ShopEventSubscriber.php index ec1c7ac4..be063ee9 100644 --- a/app/Listeners/ShopEventSubscriber.php +++ b/app/Listeners/ShopEventSubscriber.php @@ -3,17 +3,22 @@ namespace App\Listeners; use App\Events\CheckoutCompleted; +use App\Events\FulfillmentCreated; use App\Events\FulfillmentDelivered; use App\Events\FulfillmentShipped; use App\Events\OrderCancelled; use App\Events\OrderCreated; +use App\Events\OrderFulfilled; +use App\Events\OrderPaid; use App\Events\OrderRefunded; use App\Events\ProductCreated; use App\Events\ProductDeleted; use App\Events\ProductUpdated; use App\Models\Store; +use App\Models\StoreSettings; use App\Notifications\OrderLifecycleNotification; use App\Services\AnalyticsService; +use App\Services\OutboundDispatcher; use App\Services\WebhookService; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Facades\Notification; @@ -23,6 +28,7 @@ final class ShopEventSubscriber public function __construct( private readonly WebhookService $webhooks, private readonly AnalyticsService $analytics, + private readonly OutboundDispatcher $outbound, ) {} public function orderCreated(OrderCreated $event): void @@ -43,12 +49,34 @@ public function orderCreated(OrderCreated $event): void public function orderRefunded(OrderRefunded $event): void { $store = Store::query()->findOrFail($event->order->store_id); - $this->notify($event->order->email, new OrderLifecycleNotification($event->order, 'refunded', $event->refund)); + if ($event->notifyCustomer) { + $this->notify($event->order->email, new OrderLifecycleNotification($event->order, 'refunded', $event->refund)); + } $this->webhooks->dispatch($store, 'order.refunded', [ ...$this->orderPayload($event->order), 'refund_id' => $event->refund->id, 'amount' => $event->refund->amount, ]); + $this->webhooks->dispatch($store, 'refund.created', [ + 'refund_id' => $event->refund->id, + 'order_id' => $event->order->id, + 'amount' => $event->refund->amount, + ]); + $this->webhooks->dispatch($store, 'order.updated', $this->orderPayload($event->order)); + } + + public function orderPaid(OrderPaid $event): void + { + $store = Store::query()->findOrFail($event->order->store_id); + $this->webhooks->dispatch($store, 'order.paid', $this->orderPayload($event->order)); + $this->webhooks->dispatch($store, 'order.updated', $this->orderPayload($event->order)); + } + + public function orderFulfilled(OrderFulfilled $event): void + { + $store = Store::query()->findOrFail($event->order->store_id); + $this->webhooks->dispatch($store, 'order.fulfilled', $this->orderPayload($event->order)); + $this->webhooks->dispatch($store, 'order.updated', $this->orderPayload($event->order)); } public function checkoutCompleted(CheckoutCompleted $event): void @@ -65,11 +93,19 @@ public function fulfillmentShipped(FulfillmentShipped $event): void { $order = $event->fulfillment->order()->withoutGlobalScopes()->firstOrFail(); $store = Store::query()->findOrFail($order->store_id); - $this->notify($order->email, new OrderLifecycleNotification($order, 'shipped', $event->fulfillment)); - $this->webhooks->dispatch($store, 'order.fulfilled', [ + if ($event->notifyCustomer) { + $this->notify($order->email, new OrderLifecycleNotification($order, 'shipped', $event->fulfillment)); + } + $this->webhooks->dispatch($store, 'order.updated', $this->orderPayload($order)); + } + + public function fulfillmentCreated(FulfillmentCreated $event): void + { + $order = $event->fulfillment->order()->withoutGlobalScopes()->firstOrFail(); + $store = Store::query()->findOrFail($order->store_id); + $this->webhooks->dispatch($store, 'fulfillment.created', [ ...$this->orderPayload($order), 'fulfillment_id' => $event->fulfillment->id, - 'tracking_number' => $event->fulfillment->tracking_number, ]); } @@ -91,6 +127,7 @@ public function orderCancelled(OrderCancelled $event): void ...$this->orderPayload($event->order), 'reason' => $event->reason, ]); + $this->webhooks->dispatch($store, 'order.updated', $this->orderPayload($event->order)); } public function productCreated(ProductCreated $event): void @@ -111,9 +148,12 @@ public function productDeleted(ProductDeleted $event): void public function subscribe(Dispatcher $events): void { $events->listen(OrderCreated::class, [self::class, 'orderCreated']); + $events->listen(OrderPaid::class, [self::class, 'orderPaid']); + $events->listen(OrderFulfilled::class, [self::class, 'orderFulfilled']); $events->listen(OrderRefunded::class, [self::class, 'orderRefunded']); $events->listen(CheckoutCompleted::class, [self::class, 'checkoutCompleted']); $events->listen(FulfillmentShipped::class, [self::class, 'fulfillmentShipped']); + $events->listen(FulfillmentCreated::class, [self::class, 'fulfillmentCreated']); $events->listen(FulfillmentDelivered::class, [self::class, 'fulfillmentDelivered']); $events->listen(OrderCancelled::class, [self::class, 'orderCancelled']); $events->listen(ProductCreated::class, [self::class, 'productCreated']); @@ -140,8 +180,21 @@ private function productWebhook(int $storeId, string $event, int $productId): vo private function notify(?string $email, OrderLifecycleNotification $notification): void { - if ($email !== null && $email !== '') { - Notification::route('mail', $email)->notify($notification); + $preference = match ($notification->type) { + 'confirmed' => 'order_confirmation', + 'shipped' => 'shipping_confirmation', + 'refunded' => 'refund_confirmation', + 'cancelled' => 'cancellation_confirmation', + default => null, + }; + $settings = (array) StoreSettings::withoutGlobalScopes() + ->where('store_id', $notification->order->store_id) + ->value('settings_json'); + $enabled = $preference === null || (bool) data_get($settings, 'notifications.'.$preference, true); + if ($enabled && $email !== null && $email !== '') { + $this->outbound->afterCommit( + fn () => Notification::route('mail', $email)->notify($notification), + ); } } } diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php index 45993bb8..6ba23804 100644 --- a/app/Livewire/Actions/Logout.php +++ b/app/Livewire/Actions/Logout.php @@ -17,6 +17,6 @@ public function __invoke() Session::invalidate(); Session::regenerateToken(); - return redirect('/'); + return redirect()->route('storefront.home'); } } diff --git a/app/Livewire/Admin/AdminComponent.php b/app/Livewire/Admin/AdminComponent.php new file mode 100644 index 00000000..842dc824 --- /dev/null +++ b/app/Livewire/Admin/AdminComponent.php @@ -0,0 +1,80 @@ +isMethod('GET') || ! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + $status = $store->status instanceof \BackedEnum ? $store->status->value : $store->status; + abort_unless($status === 'active', 403, 'This store is suspended.'); + } + + protected function currentStore(): Store + { + abort_unless(app()->bound('current_store'), 404, 'Store not found.'); + + /** @var Store $store */ + $store = app('current_store'); + + return $store; + } + + protected function adminUser(): User + { + /** @var User|null $user */ + $user = Auth::guard('web')->user(); + abort_unless($user, 401); + + return $user; + } + + protected function authorizeAction(string $ability, mixed $arguments): void + { + Gate::forUser($this->adminUser())->authorize($ability, $arguments); + } + + /** @param list $roles */ + protected function requireRoles(array $roles): void + { + $role = $this->adminUser()->roleForStore($this->currentStore())?->value; + + throw_unless(in_array($role, $roles, true), AuthorizationException::class); + } + + /** @param list $breadcrumbs */ + protected function admin(View $view, string $title, array $breadcrumbs = []): View + { + $data = [ + 'adminStore' => $this->currentStore(), + 'adminUser' => $this->adminUser(), + 'adminRole' => $this->adminUser()->roleForStore($this->currentStore())?->value, + 'breadcrumbs' => $breadcrumbs, + ]; + + return $view->with($data)->layout('admin.layouts.app', ['title' => $title, ...$data]); + } + + protected function toast(string $message, string $type = 'success'): void + { + $this->dispatch('toast', type: $type, message: $message); + } + + protected function enumValue(mixed $value): mixed + { + return $value instanceof \BackedEnum ? $value->value : $value; + } +} diff --git a/app/Livewire/Admin/Analytics/Index.php b/app/Livewire/Admin/Analytics/Index.php new file mode 100644 index 00000000..bb1f3549 --- /dev/null +++ b/app/Livewire/Admin/Analytics/Index.php @@ -0,0 +1,194 @@ +> */ + public array $salesChartData = []; + + /** @var list> */ + public array $topProducts = []; + + /** @var list> */ + public array $topReferrers = []; + + public int $visitsCount = 0; + + public int $addToCartCount = 0; + + public int $checkoutStartedCount = 0; + + public bool $isExporting = false; + + public ?string $exportUrl = null; + + public function mount(): void + { + $this->authorizeAnalytics(); + $this->loadAnalytics(); + } + + public function updatedDateRange(): void + { + $this->authorizeAnalytics(); + $this->loadAnalytics(); + } + + public function updatedChannelFilter(): void + { + $this->authorizeAnalytics(); + $this->loadAnalytics(); + } + + public function updatedDeviceFilter(): void + { + $this->authorizeAnalytics(); + $this->loadAnalytics(); + } + + public function updatedCustomStartDate(): void + { + $this->authorizeAnalytics(); + if ($this->dateRange === 'custom') { + $this->loadAnalytics(); + } + } + + public function updatedCustomEndDate(): void + { + $this->authorizeAnalytics(); + if ($this->dateRange === 'custom') { + $this->loadAnalytics(); + } + } + + public function loadAnalytics(): void + { + $this->authorizeAnalytics(); + $this->validate([ + 'dateRange' => ['required', Rule::in(['today', '7_days', '30_days', 'custom'])], + 'customStartDate' => ['nullable', 'date'], 'customEndDate' => ['nullable', 'date', 'after_or_equal:customStartDate'], + 'channelFilter' => ['required', Rule::in(['all', 'storefront', 'api'])], + 'deviceFilter' => ['required', Rule::in(['all', 'desktop', 'mobile', 'tablet'])], + ]); + [$start, $end] = $this->dates(); + /** @var AnalyticsService $service */ + $service = app(AnalyticsService::class); + $metrics = $service->getDailyMetrics($this->currentStore(), $start->toDateString(), $end->toDateString()); + $this->totalSales = (int) $metrics->sum('revenue_amount'); + $this->ordersCount = (int) $metrics->sum('orders_count'); + $this->averageOrderValue = $this->ordersCount > 0 ? intdiv($this->totalSales, $this->ordersCount) : 0; + $this->visitsCount = (int) $metrics->sum('visits_count'); + $this->addToCartCount = (int) $metrics->sum('add_to_cart_count'); + $this->checkoutStartedCount = (int) $metrics->sum('checkout_started_count'); + $completed = (int) $metrics->sum('checkout_completed_count'); + $this->conversionRate = $this->visitsCount > 0 ? round(($completed / $this->visitsCount) * 100, 2) : 0; + $this->salesChartData = $metrics->map(fn ($metric): array => ['date' => $metric->date->toDateString(), 'revenue' => $metric->revenue_amount, 'orders' => $metric->orders_count])->all(); + $this->loadTopProducts($start, $end); + $this->loadTopReferrers($start, $end); + $this->exportUrl = null; + } + + public function exportCsv(): void + { + $this->authorizeAnalytics(); + $this->isExporting = true; + $rows = ["date,revenue,orders\n"]; + foreach ($this->salesChartData as $row) { + $rows[] = $row['date'].','.$row['revenue'].','.$row['orders']."\n"; + } + $this->exportUrl = 'data:text/csv;charset=utf-8,'.rawurlencode(implode('', $rows)); + $this->isExporting = false; + $this->toast('Analytics export ready'); + } + + public function render(): View + { + return $this->admin(view('admin.analytics.index'), 'Analytics', [['label' => 'Analytics']]); + } + + private function authorizeAnalytics(): void + { + $this->requireRoles(['owner', 'admin', 'staff']); + $this->authorizeAction('view', $this->currentStore()); + } + + /** @return array{CarbonImmutable, CarbonImmutable} */ + private function dates(): array + { + $end = $this->dateRange === 'custom' && $this->customEndDate ? CarbonImmutable::parse($this->customEndDate)->endOfDay() : CarbonImmutable::today()->endOfDay(); + $start = match ($this->dateRange) { + 'today' => $end->startOfDay(), + '7_days' => $end->subDays(6)->startOfDay(), + 'custom' => $this->customStartDate ? CarbonImmutable::parse($this->customStartDate)->startOfDay() : $end->subDays(29)->startOfDay(), + default => $end->subDays(29)->startOfDay(), + }; + + return [$start, $end]; + } + + private function loadTopProducts(CarbonImmutable $start, CarbonImmutable $end): void + { + $rows = OrderLine::query() + ->join('orders', 'orders.id', '=', 'order_lines.order_id') + ->where('orders.store_id', $this->currentStore()->id) + ->whereBetween('orders.placed_at', [$start, $end]) + ->selectRaw('order_lines.product_id, order_lines.title_snapshot as title, SUM(order_lines.quantity) as units, SUM(order_lines.total_amount) as revenue') + ->groupBy('order_lines.product_id', 'order_lines.title_snapshot') + ->orderByDesc('revenue')->limit(20)->get(); + $total = max(1, (int) $rows->sum('revenue')); + $this->topProducts = $rows->map(fn ($row): array => [ + 'title' => $row->title, 'units' => (int) $row->units, 'revenue' => (int) $row->revenue, + 'percentage' => round(((int) $row->revenue / $total) * 100, 1), + ])->all(); + } + + private function loadTopReferrers(CarbonImmutable $start, CarbonImmutable $end): void + { + /** @var Collection $events */ + $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $this->currentStore()->id) + ->whereBetween('occurred_at', [$start, $end])->get(); + $events = $events->filter(function (AnalyticsEvent $event): bool { + $channel = (string) data_get($event->properties_json, 'channel', 'storefront'); + $device = (string) data_get($event->properties_json, 'device', 'desktop'); + + return ($this->channelFilter === 'all' || $channel === $this->channelFilter) + && ($this->deviceFilter === 'all' || $device === $this->deviceFilter); + }); + $this->topReferrers = $events->groupBy(fn (AnalyticsEvent $event): string => (string) data_get($event->properties_json, 'referrer', 'Direct')) + ->map(function (Collection $sourceEvents, string $source): array { + $sessions = max(1, $sourceEvents->pluck('session_id')->filter()->unique()->count()); + $orders = $sourceEvents->where('type', 'checkout_completed')->count(); + + return ['source' => $source, 'sessions' => $sessions, 'orders' => $orders, 'conversion' => round(($orders / $sessions) * 100, 2)]; + })->sortByDesc('sessions')->take(20)->values()->all(); + } +} diff --git a/app/Livewire/Admin/Apps/Index.php b/app/Livewire/Admin/Apps/Index.php new file mode 100644 index 00000000..b8d58cb7 --- /dev/null +++ b/app/Livewire/Admin/Apps/Index.php @@ -0,0 +1,67 @@ +authorizeApps(); + } + + public function installApp(int $appId): void + { + $this->authorizeApps(); + $app = App::query()->where('status', 'active')->findOrFail($appId); + AppInstallation::withoutGlobalScopes()->updateOrCreate([ + 'store_id' => $this->currentStore()->id, 'app_id' => $app->id, + ], [ + 'scopes_json' => ['read_products', 'read_orders'], 'status' => 'active', 'installed_at' => now(), + ]); + unset($this->installedApps, $this->availableApps); + $this->toast('App installed'); + } + + public function uninstallApp(int $appId): void + { + $this->authorizeApps(); + $installation = AppInstallation::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id)->where('app_id', $appId)->firstOrFail(); + $installation->update(['status' => 'uninstalled']); + unset($this->installedApps, $this->availableApps); + $this->toast('App uninstalled'); + } + + #[Computed] + public function installedApps(): mixed + { + return AppInstallation::withoutGlobalScopes()->where('store_id', $this->currentStore()->id) + ->where('status', '!=', 'uninstalled')->with('app')->orderByDesc('installed_at')->get(); + } + + #[Computed] + public function availableApps(): mixed + { + $installedIds = AppInstallation::withoutGlobalScopes()->where('store_id', $this->currentStore()->id) + ->where('status', '!=', 'uninstalled')->pluck('app_id'); + + return App::query()->where('status', 'active')->whereNotIn('id', $installedIds)->orderBy('name')->get(); + } + + public function render(): View + { + return $this->admin(view('admin.apps.index'), 'Apps', [['label' => 'Apps']]); + } + + private function authorizeApps(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } +} diff --git a/app/Livewire/Admin/Apps/Show.php b/app/Livewire/Admin/Apps/Show.php new file mode 100644 index 00000000..ddeb990f --- /dev/null +++ b/app/Livewire/Admin/Apps/Show.php @@ -0,0 +1,78 @@ +authorizeApps(); + abort_unless((int) $installation->store_id === (int) $this->currentStore()->id, 404); + $this->installation = $installation->load('app'); + } + + public function suspend(): void + { + $this->authorizeApps(); + $this->scopedInstallation()->update(['status' => 'suspended']); + $this->installation->refresh()->load('app'); + $this->toast('App suspended'); + } + + public function resume(): void + { + $this->authorizeApps(); + $this->scopedInstallation()->update(['status' => 'active']); + $this->installation->refresh()->load('app'); + $this->toast('App resumed'); + } + + public function uninstall(): mixed + { + $this->authorizeApps(); + $this->scopedInstallation()->update(['status' => 'uninstalled']); + $this->toast('App uninstalled'); + + return $this->redirect('/admin/apps', navigate: true); + } + + #[Computed] + public function webhooks(): mixed + { + return $this->scopedInstallation()->webhookSubscriptions()->with('deliveries')->orderBy('event_type')->get(); + } + + #[Computed] + public function usage(): array + { + $deliveries = WebhookDelivery::query()->whereHas('subscription', fn ($query) => $query->where('app_installation_id', $this->installation->id))->get(); + + return ['calls' => $deliveries->count(), 'last_call_at' => $deliveries->max('last_attempt_at')]; + } + + public function render(): View + { + return $this->admin(view('admin.apps.show'), $this->installation->app->name, [ + ['label' => 'Apps', 'url' => url('/admin/apps')], ['label' => $this->installation->app->name], + ]); + } + + private function authorizeApps(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } + + private function scopedInstallation(): AppInstallation + { + return AppInstallation::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->findOrFail($this->installation->id); + } +} diff --git a/app/Livewire/Admin/Auth/ForgotPassword.php b/app/Livewire/Admin/Auth/ForgotPassword.php new file mode 100644 index 00000000..2ecdc793 --- /dev/null +++ b/app/Livewire/Admin/Auth/ForgotPassword.php @@ -0,0 +1,26 @@ +validate(['email' => ['required', 'email']]); + Password::broker('users')->sendResetLink(['email' => $this->email]); + $this->sent = true; + } + + public function render(): View + { + return view('admin.auth.forgot-password')->layout('admin.layouts.auth', ['title' => 'Reset your password']); + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..d9384352 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,80 @@ +check()) { + $this->redirect('/admin', navigate: true); + } + } + + public function authenticate(): void + { + $credentials = $this->validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $key = Str::transliterate(Str::lower($this->email).'|'.request()->ip()); + $ipKey = 'login-ip:'.request()->ip(); + if (RateLimiter::tooManyAttempts($key, 5) || RateLimiter::tooManyAttempts($ipKey, 5)) { + throw ValidationException::withMessages([ + 'email' => 'Too many attempts. Try again in '.max(RateLimiter::availableIn($key), RateLimiter::availableIn($ipKey)).' seconds.', + ]); + } + + if (! Auth::guard('web')->attempt($credentials, $this->remember)) { + RateLimiter::hit($key, 60); + RateLimiter::hit($ipKey, 60); + throw ValidationException::withMessages(['email' => 'Invalid credentials']); + } + + $user = Auth::guard('web')->user(); + $status = $user?->status instanceof \BackedEnum ? $user->status->value : $user?->status; + if ($status !== 'active') { + Auth::guard('web')->logout(); + RateLimiter::hit($key, 60); + RateLimiter::hit($ipKey, 60); + throw ValidationException::withMessages(['email' => 'Invalid credentials']); + } + + $storeId = StoreUser::query()->where('user_id', $user?->getAuthIdentifier())->orderBy('store_id')->value('store_id'); + if (! $storeId) { + Auth::guard('web')->logout(); + RateLimiter::hit($key, 60); + RateLimiter::hit($ipKey, 60); + throw ValidationException::withMessages(['email' => 'Invalid credentials']); + } + + RateLimiter::clear($key); + RateLimiter::clear($ipKey); + request()->session()->regenerate(); + + $user?->forceFill(['last_login_at' => now()])->save(); + session(['current_store_id' => (int) $storeId]); + + $this->redirectIntended('/admin', navigate: true); + } + + public function render(): View + { + return view('admin.auth.login')->layout('admin.layouts.auth', ['title' => 'Admin login']); + } +} diff --git a/app/Livewire/Admin/Auth/ResetPassword.php b/app/Livewire/Admin/Auth/ResetPassword.php new file mode 100644 index 00000000..412f25e4 --- /dev/null +++ b/app/Livewire/Admin/Auth/ResetPassword.php @@ -0,0 +1,59 @@ +token = $token; + $this->email = (string) request()->query('email', ''); + } + + public function resetPassword(): void + { + $credentials = $this->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', PasswordRule::defaults()], + ]); + + $status = Password::broker('users')->reset($credentials, function (User $user, string $password): void { + $user->forceFill([ + 'password_hash' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + event(new PasswordReset($user)); + }); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages(['email' => __($status)]); + } + + session()->flash('status', 'Your password has been reset. You can sign in now.'); + $this->redirect('/admin/login', navigate: true); + } + + public function render(): View + { + return view('admin.auth.reset-password')->layout('admin.layouts.auth', ['title' => 'Choose a new password']); + } +} diff --git a/app/Livewire/Admin/Collections/Form.php b/app/Livewire/Admin/Collections/Form.php new file mode 100644 index 00000000..f954d574 --- /dev/null +++ b/app/Livewire/Admin/Collections/Form.php @@ -0,0 +1,134 @@ + */ + public array $assignedProductIds = []; + + public function mount(?Collection $collection = null): void + { + if ($collection?->exists) { + abort_unless((int) $collection->store_id === (int) $this->currentStore()->id, 404); + $this->authorizeAction('update', $collection); + $this->collection = $collection->load('products'); + $this->title = $collection->title; + $this->handle = $collection->handle; + $this->descriptionHtml = (string) $collection->description_html; + $this->status = (string) $this->enumValue($collection->status); + $this->assignedProductIds = $collection->products->modelKeys(); + } else { + $this->authorizeAction('create', Collection::class); + } + } + + public function updatedTitle(string $value): void + { + if (! $this->collection && $this->handle === '') { + $this->handle = Str::slug($value); + } + } + + public function addProduct(int $productId): void + { + abort_unless(Product::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->whereKey($productId)->exists(), 404); + if (! in_array($productId, $this->assignedProductIds, true)) { + $this->assignedProductIds[] = $productId; + } + $this->productSearch = ''; + } + + public function removeProduct(int $productId): void + { + $this->assignedProductIds = array_values(array_filter($this->assignedProductIds, fn (int $id): bool => $id !== $productId)); + } + + /** @param list $order */ + public function reorderProducts(array $order): void + { + $allowed = array_flip($this->assignedProductIds); + $this->assignedProductIds = array_values(array_filter(array_map('intval', $order), fn (int $id): bool => isset($allowed[$id]))); + } + + public function moveProduct(int $productId, string $direction): void + { + abort_unless(in_array($direction, ['up', 'down'], true), 400); + $index = array_search($productId, $this->assignedProductIds, true); + abort_if($index === false, 404); + $target = $direction === 'up' ? $index - 1 : $index + 1; + if (! isset($this->assignedProductIds[$target])) { + return; + } + [$this->assignedProductIds[$index], $this->assignedProductIds[$target]] = [$this->assignedProductIds[$target], $this->assignedProductIds[$index]]; + } + + public function save(): void + { + $data = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'alpha_dash', 'max:255', Rule::unique('collections', 'handle')->where('store_id', $this->currentStore()->id)->ignore($this->collection?->id)], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['active', 'archived'])], + 'assignedProductIds' => ['array'], + 'assignedProductIds.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $this->currentStore()->id)], + ]); + $attributes = ['title' => $data['title'], 'handle' => Str::slug($data['handle']), 'description_html' => $data['descriptionHtml'] ?: null, 'status' => $data['status'], 'type' => 'manual']; + if ($this->collection) { + $this->authorizeAction('update', $this->collection); + $this->collection->update($attributes); + } else { + $this->authorizeAction('create', Collection::class); + $this->collection = Collection::query()->create([...$attributes, 'store_id' => $this->currentStore()->id]); + } + $this->collection->products()->sync(collect($data['assignedProductIds'])->mapWithKeys(fn ($id, $position) => [(int) $id => ['position' => $position]])->all()); + $this->toast('Collection saved successfully.'); + $this->redirect('/admin/collections/'.$this->collection->id.'/edit', navigate: true); + } + + #[Computed] + public function searchResults(): SupportCollection + { + if (mb_strlen(trim($this->productSearch)) < 2) { + return collect(); + } + + return Product::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->where('title', 'like', '%'.trim($this->productSearch).'%')->whereNotIn('id', $this->assignedProductIds)->orderBy('title')->limit(8)->get(); + } + + #[Computed] + public function assignedProducts(): SupportCollection + { + $items = Product::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->whereKey($this->assignedProductIds)->with('media')->get()->keyBy('id'); + + return collect($this->assignedProductIds)->map(fn (int $id) => $items->get($id))->filter()->values(); + } + + public function render(): View + { + $label = $this->collection?->title ?: 'Add collection'; + + return $this->admin(view('admin.collections.form'), $label, [['label' => 'Collections', 'url' => url('/admin/collections')], ['label' => $label]]); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..f122afd7 --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,57 @@ +authorizeAction('viewAny', Collection::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function deleteCollection(int $id): void + { + $collection = Collection::query()->findOrFail($id); + $this->authorizeAction('delete', $collection); + $collection->delete(); + $this->toast('Collection deleted.'); + } + + #[Computed] + public function collections(): LengthAwarePaginator + { + return Collection::query()->withCount('products') + ->when($this->search !== '', fn (Builder $query) => $query->where('title', 'like', '%'.$this->search.'%')) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->latest('updated_at')->paginate(20); + } + + public function render(): View + { + return $this->admin(view('admin.collections.index'), 'Collections', [['label' => 'Collections']]); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..f25f8234 --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,41 @@ +authorizeAction('viewAny', Customer::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + #[Computed] + public function customers(): LengthAwarePaginator + { + return Customer::query()->withCount('orders')->withSum('orders', 'total_amount') + ->when($this->search !== '', fn (Builder $query) => $query->where(fn (Builder $nested) => $nested->where('name', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%'))) + ->latest('created_at')->paginate(25); + } + + public function render(): View + { + return $this->admin(view('admin.customers.index'), 'Customers', [['label' => 'Customers']]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..7d972e83 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,129 @@ + */ + public array $addressJson = [ + 'first_name' => '', 'last_name' => '', 'company' => '', 'address1' => '', 'address2' => '', + 'city' => '', 'province' => '', 'province_code' => '', 'country' => '', 'country_code' => '', 'zip' => '', 'phone' => '', + ]; + + public bool $addressIsDefault = false; + + public function mount(Customer $customer): void + { + abort_unless((int) $customer->store_id === (int) $this->currentStore()->id, 404); + $this->authorizeAction('view', $customer); + $this->customer = $customer->load('addresses'); + } + + public function openAddressForm(CustomerAddress|int|null $address = null): void + { + $this->authorizeAction('update', $this->customer); + $resolved = $address instanceof CustomerAddress ? $address : ($address ? $this->customer->addresses()->findOrFail($address) : null); + if ($resolved) { + abort_unless((int) $resolved->customer_id === (int) $this->customer->id, 404); + } + $this->editingAddress = $resolved; + $this->addressLabel = (string) $resolved?->label; + $this->addressJson = array_replace($this->emptyAddress(), (array) $resolved?->address_json); + $this->addressIsDefault = (bool) ($resolved?->is_default ?? $this->customer->addresses->isEmpty()); + } + + public function saveAddress(): void + { + $this->authorizeAction('update', $this->customer); + $validated = $this->validate([ + 'addressLabel' => ['nullable', 'string', 'max:100'], + 'addressJson.first_name' => ['required', 'string', 'max:100'], 'addressJson.last_name' => ['required', 'string', 'max:100'], + 'addressJson.company' => ['nullable', 'string', 'max:255'], 'addressJson.address1' => ['required', 'string', 'max:255'], + 'addressJson.address2' => ['nullable', 'string', 'max:255'], 'addressJson.city' => ['required', 'string', 'max:100'], + 'addressJson.province' => ['nullable', 'string', 'max:100'], 'addressJson.province_code' => ['nullable', 'string', 'max:20'], + 'addressJson.country' => ['required', 'string', 'max:100'], 'addressJson.country_code' => ['required', 'string', 'size:2'], + 'addressJson.zip' => ['required', 'string', 'max:30'], 'addressJson.phone' => ['nullable', 'string', 'max:50'], + 'addressIsDefault' => ['boolean'], + ]); + DB::transaction(function () use ($validated): void { + if ($validated['addressIsDefault']) { + $this->customer->addresses()->update(['is_default' => false]); + } + $attributes = ['label' => $validated['addressLabel'] ?: null, 'address_json' => $validated['addressJson'], 'is_default' => $validated['addressIsDefault']]; + $attributes['address_json']['country_code'] = mb_strtoupper($attributes['address_json']['country_code']); + $attributes['address_json']['province_code'] = mb_strtoupper($attributes['address_json']['province_code']); + if ($this->editingAddress) { + $this->editingAddress->update($attributes); + } else { + $this->customer->addresses()->create($attributes); + } + }); + $this->reloadCustomer(); + $this->modal('address-form')->close(); + $this->toast('Address saved.'); + } + + public function deleteAddress(int $addressId): void + { + $this->authorizeAction('update', $this->customer); + $address = $this->customer->addresses()->findOrFail($addressId); + $wasDefault = $address->is_default; + $address->delete(); + if ($wasDefault) { + $this->customer->addresses()->orderBy('id')->first()?->update(['is_default' => true]); + } + $this->reloadCustomer(); + $this->toast('Address deleted.'); + } + + public function setDefaultAddress(int $addressId): void + { + $this->authorizeAction('update', $this->customer); + DB::transaction(function () use ($addressId): void { + $address = $this->customer->addresses()->findOrFail($addressId); + $this->customer->addresses()->update(['is_default' => false]); + $address->update(['is_default' => true]); + }); + $this->reloadCustomer(); + $this->toast('Default address updated.'); + } + + #[Computed] + public function orders(): LengthAwarePaginator + { + return $this->customer->orders()->latest('placed_at')->paginate(10); + } + + private function reloadCustomer(): void + { + $this->customer = $this->customer->refresh()->load('addresses'); + } + + /** @return array */ + private function emptyAddress(): array + { + return ['first_name' => '', 'last_name' => '', 'company' => '', 'address1' => '', 'address2' => '', 'city' => '', 'province' => '', 'province_code' => '', 'country' => '', 'country_code' => '', 'zip' => '', 'phone' => '']; + } + + public function render(): View + { + return $this->admin(view('admin.customers.show'), $this->customer->name ?: $this->customer->email, [['label' => 'Customers', 'url' => url('/admin/customers')], ['label' => $this->customer->name ?: $this->customer->email]]); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..1317c676 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,156 @@ + */ + public array $ordersChartData = []; + + /** @var list */ + public array $topProducts = []; + + /** @var array{visits: int, add_to_cart: int, checkout_started: int, checkout_completed: int} */ + public array $funnelData = ['visits' => 0, 'add_to_cart' => 0, 'checkout_started' => 0, 'checkout_completed' => 0]; + + public function mount(): void + { + $this->requireRoles(['owner', 'admin', 'staff']); + $this->loadAll(); + } + + public function updatedDateRange(): void + { + $this->loadAll(); + } + + public function updatedCustomStartDate(): void + { + if ($this->dateRange === 'custom' && $this->customEndDate) { + $this->loadAll(); + } + } + + public function updatedCustomEndDate(): void + { + if ($this->dateRange === 'custom' && $this->customStartDate) { + $this->loadAll(); + } + } + + public function loadAll(): void + { + [$start, $end] = $this->dates(); + $days = max(1, $start->diffInDays($end) + 1); + $previousEnd = $start->subDay(); + $previousStart = $previousEnd->subDays($days - 1); + + $current = $this->summary($start, $end); + $previous = $this->summary($previousStart, $previousEnd); + $this->totalSales = $current['sales']; + $this->ordersCount = $current['orders']; + $this->averageOrderValue = $this->ordersCount > 0 ? (int) round($this->totalSales / $this->ordersCount) : 0; + $this->visitorsCount = $current['visitors']; + $previousAov = $previous['orders'] > 0 ? (int) round($previous['sales'] / $previous['orders']) : 0; + $this->salesChange = $this->change($this->totalSales, $previous['sales']); + $this->ordersChange = $this->change($this->ordersCount, $previous['orders']); + $this->aovChange = $this->change($this->averageOrderValue, $previousAov); + $this->visitorsChange = $this->change($this->visitorsCount, $previous['visitors']); + $this->loadChart($start, $end); + $this->loadTopProducts($start, $end); + $this->loadFunnel($start, $end); + } + + private function loadChart(CarbonImmutable $start, CarbonImmutable $end): void + { + $rows = Order::query()->whereBetween('placed_at', [$start->startOfDay(), $end->endOfDay()]) + ->selectRaw('date(placed_at) as day, count(*) as aggregate')->groupBy('day')->pluck('aggregate', 'day'); + $this->ordersChartData = []; + for ($day = $start; $day->lte($end); $day = $day->addDay()) { + $this->ordersChartData[] = ['date' => $day->format('M j'), 'count' => (int) ($rows[$day->toDateString()] ?? 0)]; + } + } + + private function loadTopProducts(CarbonImmutable $start, CarbonImmutable $end): void + { + $this->topProducts = OrderLine::query()->whereHas('order', fn ($query) => $query->whereBetween('placed_at', [$start->startOfDay(), $end->endOfDay()])) + ->select('title_snapshot', DB::raw('sum(quantity) as units_sold'), DB::raw('sum(total_amount) as revenue')) + ->groupBy('title_snapshot')->orderByDesc('revenue')->limit(5)->get() + ->map(fn ($row): array => ['title' => $row->title_snapshot, 'units_sold' => (int) $row->units_sold, 'revenue' => (int) $row->revenue])->all(); + } + + private function loadFunnel(CarbonImmutable $start, CarbonImmutable $end): void + { + $daily = AnalyticsDaily::query()->whereBetween('date', [$start->toDateString(), $end->toDateString()]); + $this->funnelData = [ + 'visits' => (int) (clone $daily)->sum('visits_count'), + 'add_to_cart' => (int) (clone $daily)->sum('add_to_cart_count'), + 'checkout_started' => (int) (clone $daily)->sum('checkout_started_count'), + 'checkout_completed' => (int) (clone $daily)->sum('checkout_completed_count'), + ]; + } + + /** @return array{sales: int, orders: int, visitors: int} */ + private function summary(CarbonImmutable $start, CarbonImmutable $end): array + { + $orders = Order::query()->whereBetween('placed_at', [$start->startOfDay(), $end->endOfDay()])->whereNotIn('status', ['cancelled']); + + return [ + 'sales' => (int) (clone $orders)->sum('total_amount'), + 'orders' => (int) (clone $orders)->count(), + 'visitors' => (int) AnalyticsDaily::query()->whereBetween('date', [$start->toDateString(), $end->toDateString()])->sum('visits_count'), + ]; + } + + /** @return array{0: CarbonImmutable, 1: CarbonImmutable} */ + private function dates(): array + { + $end = CarbonImmutable::today(); + + return match ($this->dateRange) { + 'today' => [$end, $end], + 'last_7_days' => [$end->subDays(6), $end], + 'custom' => [CarbonImmutable::parse($this->customStartDate ?: $end->subDays(29)->toDateString())->startOfDay(), CarbonImmutable::parse($this->customEndDate ?: $end->toDateString())->startOfDay()], + default => [$end->subDays(29), $end], + }; + } + + private function change(int $current, int $previous): float + { + return $previous === 0 ? ($current > 0 ? 100.0 : 0.0) : round((($current - $previous) / $previous) * 100, 1); + } + + public function render(): View + { + return $this->admin(view('admin.dashboard'), 'Dashboard'); + } +} diff --git a/app/Livewire/Admin/Developers/Index.php b/app/Livewire/Admin/Developers/Index.php new file mode 100644 index 00000000..7aae19da --- /dev/null +++ b/app/Livewire/Admin/Developers/Index.php @@ -0,0 +1,176 @@ +authorizeDevelopers(); + } + + public function generateToken(): void + { + $this->authorizeDevelopers(); + $data = $this->validate(['newTokenName' => ['required', 'string', 'max:255']]); + $storeId = $this->currentStore()->id; + $token = $this->adminUser()->createToken( + 'store:'.$storeId.':'.trim($data['newTokenName']), + [ + 'store:'.$storeId, + 'read-products', 'write-products', + 'read-collections', 'write-collections', + 'read-orders', 'write-orders', + 'read-customers', 'write-customers', + 'read-discounts', 'write-discounts', + 'read-settings', 'write-settings', + 'read-content', 'write-content', + 'read-themes', 'write-themes', + 'read-analytics', + ], + ); + $this->generatedToken = Str::after($token->plainTextToken, '|'); + app(AuditLogger::class)->log( + 'api_token.created', + (int) $this->adminUser()->getAuthIdentifier(), + $storeId, + 'personal_access_token', + (int) $token->accessToken->id, + ['token_name' => $token->accessToken->name, 'abilities' => $token->accessToken->abilities], + ); + $this->reset('newTokenName'); + unset($this->tokens); + $this->dispatch('modal-close', name: 'generate-token'); + $this->toast('API token generated'); + } + + public function revokeToken(int $tokenId): void + { + $this->authorizeDevelopers(); + $token = $this->adminUser()->tokens()->where('name', 'like', $this->tokenPrefix().'%')->findOrFail($tokenId); + app(AuditLogger::class)->log( + 'api_token.revoked', + (int) $this->adminUser()->getAuthIdentifier(), + (int) $this->currentStore()->id, + 'personal_access_token', + (int) $token->id, + ['token_name' => $token->name], + ); + $token->delete(); + unset($this->tokens); + $this->toast('API token revoked'); + } + + public function openWebhookModal(?int $webhookId = null): void + { + $this->authorizeDevelopers(); + $this->editingWebhook = $webhookId ? $this->webhook($webhookId) : null; + $this->webhookEventType = (string) ($this->editingWebhook?->event_type ?? 'order.created'); + $this->webhookUrl = (string) ($this->editingWebhook?->target_url ?? ''); + $this->resetValidation(); + $this->dispatch('modal-show', name: 'webhook-form'); + } + + public function saveWebhook(): void + { + $this->authorizeDevelopers(); + $data = $this->validate([ + 'webhookEventType' => ['required', Rule::in($this->eventTypes())], + 'webhookUrl' => [ + 'required', 'url:https', 'max:2048', + function (string $attribute, mixed $value, \Closure $fail): void { + try { + app(WebhookTargetValidator::class)->validate((string) $value); + } catch (\Throwable $exception) { + $fail($exception->getMessage()); + } + }, + ], + ]); + $target = app(WebhookTargetValidator::class)->validate($data['webhookUrl']); + if ($this->editingWebhook) { + $webhook = $this->webhook($this->editingWebhook->id); + $webhook->update(['event_type' => $data['webhookEventType'], 'target_url' => $target->url]); + } else { + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $this->currentStore()->id, + 'app_installation_id' => null, + 'event_type' => $data['webhookEventType'], + 'target_url' => $target->url, + 'signing_secret_encrypted' => Str::random(64), + 'status' => 'active', + ]); + } + $this->editingWebhook = null; + unset($this->webhooks); + $this->dispatch('modal-close', name: 'webhook-form'); + $this->toast('Webhook saved'); + } + + public function deleteWebhook(int $webhookId): void + { + $this->authorizeDevelopers(); + $this->webhook($webhookId)->delete(); + unset($this->webhooks); + $this->toast('Webhook deleted'); + } + + #[Computed] + public function tokens(): mixed + { + return $this->adminUser()->tokens()->where('name', 'like', $this->tokenPrefix().'%')->latest()->get(); + } + + #[Computed] + public function webhooks(): mixed + { + return WebhookSubscription::withoutGlobalScopes()->where('store_id', $this->currentStore()->id) + ->with(['deliveries', 'installation.app'])->orderBy('event_type')->get(); + } + + public function render(): View + { + return $this->admin(view('admin.developers.index'), 'Developers', [['label' => 'Developers']]); + } + + /** @return list */ + public function eventTypes(): array + { + return ['order.created', 'order.updated', 'order.cancelled', 'product.created', 'product.updated', 'product.deleted', 'customer.created', 'checkout.completed', 'fulfillment.created', 'refund.created']; + } + + private function authorizeDevelopers(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } + + private function tokenPrefix(): string + { + return 'store:'.$this->currentStore()->id.':'; + } + + private function webhook(int $id): WebhookSubscription + { + return WebhookSubscription::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->findOrFail($id); + } +} diff --git a/app/Livewire/Admin/Discounts/Form.php b/app/Livewire/Admin/Discounts/Form.php new file mode 100644 index 00000000..a7703486 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Form.php @@ -0,0 +1,178 @@ + */ + public array $specificProductIds = []; + + /** @var list */ + public array $specificCollectionIds = []; + + public ?int $usageLimit = null; + + public bool $onePerCustomer = false; + + public string $startsAt = ''; + + public ?string $endsAt = null; + + public bool $isActive = true; + + public string $productSearch = ''; + + public string $collectionSearch = ''; + + public function mount(?Discount $discount = null): void + { + if ($discount?->exists) { + abort_unless((int) $discount->store_id === (int) $this->currentStore()->id, 404); + $this->authorizeAction('update', $discount); + $this->discount = $discount; + $rules = (array) $discount->rules_json; + $this->type = (string) $this->enumValue($discount->type); + $this->code = (string) $discount->code; + $this->valueType = (string) $this->enumValue($discount->value_type); + $this->valueAmount = (int) $discount->value_amount; + $this->minimumPurchaseAmount = data_get($rules, 'min_purchase_amount'); + $this->specificProductIds = array_map('intval', (array) data_get($rules, 'applicable_product_ids', [])); + $this->specificCollectionIds = array_map('intval', (array) data_get($rules, 'applicable_collection_ids', [])); + $this->usageLimit = $discount->usage_limit; + $this->onePerCustomer = (bool) data_get($rules, 'one_per_customer', false); + $this->startsAt = $discount->starts_at?->format('Y-m-d\TH:i') ?? now()->format('Y-m-d\TH:i'); + $this->endsAt = $discount->ends_at?->format('Y-m-d\TH:i'); + $this->isActive = $this->enumValue($discount->status) === 'active'; + } else { + $this->authorizeAction('create', Discount::class); + $this->startsAt = now()->format('Y-m-d\TH:i'); + } + } + + public function generateCode(): void + { + $this->code = Str::upper(Str::random(10)); + } + + public function addProduct(int $id): void + { + abort_unless(Product::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->whereKey($id)->exists(), 404); + if (! in_array($id, $this->specificProductIds, true)) { + $this->specificProductIds[] = $id; + } $this->productSearch = ''; + } + + public function removeProduct(int $id): void + { + $this->specificProductIds = array_values(array_diff($this->specificProductIds, [$id])); + } + + public function addCollection(int $id): void + { + abort_unless(Collection::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->whereKey($id)->exists(), 404); + if (! in_array($id, $this->specificCollectionIds, true)) { + $this->specificCollectionIds[] = $id; + } $this->collectionSearch = ''; + } + + public function removeCollection(int $id): void + { + $this->specificCollectionIds = array_values(array_diff($this->specificCollectionIds, [$id])); + } + + public function save(): void + { + $validated = $this->validate([ + 'type' => ['required', Rule::in(['code', 'automatic'])], + 'code' => [Rule::requiredIf($this->type === 'code'), 'nullable', 'string', 'max:255', function (string $attribute, mixed $value, \Closure $fail): void { + if ($this->type !== 'code' || blank($value)) { + return; + } + + $exists = Discount::query() + ->whereRaw('LOWER(code) = ?', [mb_strtolower(trim((string) $value))]) + ->when($this->discount, fn ($query) => $query->whereKeyNot($this->discount->id)) + ->exists(); + if ($exists) { + $fail('This discount code is already in use.'); + } + }], + 'valueType' => ['required', Rule::in(['percent', 'fixed', 'free_shipping'])], + 'valueAmount' => [Rule::requiredIf($this->valueType !== 'free_shipping'), 'nullable', 'integer', 'min:0', $this->valueType === 'percent' ? 'max:100' : 'max:999999999'], + 'minimumPurchaseAmount' => ['nullable', 'integer', 'min:0'], + 'specificProductIds.*' => ['integer', Rule::exists('products', 'id')->where('store_id', $this->currentStore()->id)], + 'specificCollectionIds.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $this->currentStore()->id)], + 'usageLimit' => ['nullable', 'integer', 'min:1'], 'onePerCustomer' => ['boolean'], + 'startsAt' => ['required', 'date'], 'endsAt' => ['nullable', 'date', 'after:startsAt'], 'isActive' => ['boolean'], + ]); + $attributes = [ + 'type' => $validated['type'], 'code' => $validated['type'] === 'code' ? Str::upper(trim($validated['code'])) : null, + 'value_type' => $validated['valueType'], 'value_amount' => $validated['valueType'] === 'free_shipping' ? 0 : (int) $validated['valueAmount'], + 'starts_at' => $validated['startsAt'], 'ends_at' => $validated['endsAt'] ?: null, 'usage_limit' => $validated['usageLimit'], + 'rules_json' => ['min_purchase_amount' => $validated['minimumPurchaseAmount'], 'applicable_product_ids' => array_map('intval', $this->specificProductIds), 'applicable_collection_ids' => array_map('intval', $this->specificCollectionIds), 'one_per_customer' => $validated['onePerCustomer']], + 'status' => $validated['isActive'] ? 'active' : 'disabled', + ]; + if ($this->discount) { + $this->authorizeAction('update', $this->discount); + $this->discount->update($attributes); + } else { + $this->authorizeAction('create', Discount::class); + $this->discount = Discount::query()->create([...$attributes, 'store_id' => $this->currentStore()->id, 'usage_count' => 0]); + } + $this->toast('Discount saved successfully.'); + $this->redirect('/admin/discounts/'.$this->discount->id.'/edit', navigate: true); + } + + #[Computed] + public function productResults(): SupportCollection + { + return mb_strlen(trim($this->productSearch)) < 2 ? collect() : Product::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->where('title', 'like', '%'.$this->productSearch.'%')->whereNotIn('id', $this->specificProductIds)->limit(8)->get(); + } + + #[Computed] + public function collectionResults(): SupportCollection + { + return mb_strlen(trim($this->collectionSearch)) < 2 ? collect() : Collection::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->where('title', 'like', '%'.$this->collectionSearch.'%')->whereNotIn('id', $this->specificCollectionIds)->limit(8)->get(); + } + + #[Computed] + public function selectedProducts(): SupportCollection + { + return Product::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->whereKey($this->specificProductIds)->orderBy('title')->get(); + } + + #[Computed] + public function selectedCollections(): SupportCollection + { + return Collection::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->whereKey($this->specificCollectionIds)->orderBy('title')->get(); + } + + public function render(): View + { + $label = $this->discount?->code ?: ($this->discount ? 'Automatic discount' : 'Create discount'); + + return $this->admin(view('admin.discounts.form'), $label, [['label' => 'Discounts', 'url' => url('/admin/discounts')], ['label' => $label]]); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..0578123d --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,59 @@ +authorizeAction('viewAny', Discount::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function deleteDiscount(int $id): void + { + $discount = Discount::query()->findOrFail($id); + $this->authorizeAction('delete', $discount); + $discount->delete(); + $this->toast('Discount deleted.'); + } + + #[Computed] + public function discounts(): LengthAwarePaginator + { + return Discount::query() + ->when($this->search !== '', fn (Builder $query) => $query->where('code', 'like', '%'.$this->search.'%')) + ->when($this->statusFilter === 'scheduled', fn (Builder $query) => $query->where('starts_at', '>', now())->where('status', 'active')) + ->when($this->statusFilter === 'expired', fn (Builder $query) => $query->where(fn (Builder $nested) => $nested->where('status', 'expired')->orWhere('ends_at', '<', now()))) + ->when($this->statusFilter === 'active', fn (Builder $query) => $query->where('status', 'active')->where('starts_at', '<=', now())->where(fn (Builder $nested) => $nested->whereNull('ends_at')->orWhere('ends_at', '>=', now()))) + ->latest('created_at')->paginate(20); + } + + public function render(): View + { + return $this->admin(view('admin.discounts.index'), 'Discounts', [['label' => 'Discounts']]); + } +} diff --git a/app/Livewire/Admin/Inventory/Index.php b/app/Livewire/Admin/Inventory/Index.php new file mode 100644 index 00000000..8fbc4e73 --- /dev/null +++ b/app/Livewire/Admin/Inventory/Index.php @@ -0,0 +1,70 @@ +authorizeAction('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStockFilter(): void + { + $this->resetPage(); + } + + public function updateQuantity(int $itemId, mixed $quantity): void + { + $item = InventoryItem::query()->with('variant.product')->findOrFail($itemId); + $this->authorizeAction('update', $item->variant->product); + $validated = validator(['quantity' => $quantity], ['quantity' => ['required', 'integer', 'min:0', 'max:999999999']])->validate(); + $item->update(['quantity_on_hand' => $validated['quantity']]); + $this->toast('Inventory updated.'); + } + + public function updatePolicy(int $itemId, string $policy): void + { + $item = InventoryItem::query()->with('variant.product')->findOrFail($itemId); + $this->authorizeAction('update', $item->variant->product); + abort_unless(in_array($policy, ['deny', 'continue'], true), 422); + $item->update(['policy' => $policy]); + $this->toast('Inventory policy updated.'); + } + + #[Computed] + public function inventoryItems(): LengthAwarePaginator + { + return InventoryItem::query()->with(['variant.product', 'variant.optionValues.option']) + ->when($this->search !== '', fn (Builder $query) => $query->whereHas('variant', fn (Builder $variant) => $variant->where('sku', 'like', '%'.$this->search.'%')->orWhereHas('product', fn (Builder $product) => $product->where('title', 'like', '%'.$this->search.'%')))) + ->when($this->stockFilter === 'in_stock', fn (Builder $query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) > 10')) + ->when($this->stockFilter === 'low_stock', fn (Builder $query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) between 1 and 10')) + ->when($this->stockFilter === 'out_of_stock', fn (Builder $query) => $query->whereRaw('(quantity_on_hand - quantity_reserved) <= 0')) + ->orderBy('id')->paginate(25); + } + + public function render(): View + { + return $this->admin(view('admin.inventory.index'), 'Inventory', [['label' => 'Inventory']]); + } +} diff --git a/app/Livewire/Admin/Layout/Sidebar.php b/app/Livewire/Admin/Layout/Sidebar.php new file mode 100644 index 00000000..387e7321 --- /dev/null +++ b/app/Livewire/Admin/Layout/Sidebar.php @@ -0,0 +1,33 @@ +currentRoute = (string) request()->route()?->getName(); + } + + public function toggle(): void + { + $this->collapsed = ! $this->collapsed; + } + + public function render(): View + { + $store = app('current_store'); + $role = Auth::user()?->roleForStore($store) ?? StoreUserRole::Support; + + return view('admin.layout.sidebar', ['role' => $role->value]); + } +} diff --git a/app/Livewire/Admin/Layout/TopBar.php b/app/Livewire/Admin/Layout/TopBar.php new file mode 100644 index 00000000..d7e12f60 --- /dev/null +++ b/app/Livewire/Admin/Layout/TopBar.php @@ -0,0 +1,56 @@ +currentStoreName = (string) app('current_store')->name; + } + + public function switchStore(string $storeId): void + { + $allowed = StoreUser::query()->where('user_id', Auth::id())->where('store_id', $storeId)->exists(); + abort_unless($allowed, 403); + + session(['current_store_id' => (int) $storeId]); + $this->redirect('/admin', navigate: true); + } + + public function logout(): void + { + Auth::guard('web')->logout(); + request()->session()->invalidate(); + request()->session()->regenerateToken(); + $this->redirect('/admin/login', navigate: true); + } + + /** @return Collection */ + public function getStoresProperty(): Collection + { + return Auth::user()?->stores()->orderBy('name')->get() ?? collect(); + } + + public function render(): View + { + $user = Auth::user(); + + return view('admin.layout.top-bar', [ + 'stores' => $this->stores, + 'user' => $user, + 'role' => $user?->roleForStore(app('current_store'))?->value, + ]); + } +} diff --git a/app/Livewire/Admin/Navigation/Index.php b/app/Livewire/Admin/Navigation/Index.php new file mode 100644 index 00000000..5a77c0d0 --- /dev/null +++ b/app/Livewire/Admin/Navigation/Index.php @@ -0,0 +1,213 @@ +> */ + public array $menuItems = []; + + /** @var array|null */ + public ?array $editingItem = null; + + public ?int $editingItemIndex = null; + + public string $itemLabel = ''; + + public string $itemType = 'link'; + + public string $itemUrl = ''; + + public ?int $itemResourceId = null; + + public function mount(): void + { + $this->authorizeNavigation(); + $first = NavigationMenu::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->orderBy('id')->first(); + if ($first) { + $this->loadMenu($first); + } + } + + public function selectMenu(int $menuId): void + { + $this->authorizeNavigation(); + $this->loadMenu($this->menu($menuId)); + } + + public function addItem(): void + { + $this->authorizeNavigation(); + abort_unless($this->editingMenu, 422, 'Select a menu first.'); + $this->editingItem = null; + $this->editingItemIndex = null; + $this->itemLabel = ''; + $this->itemType = 'link'; + $this->itemUrl = ''; + $this->itemResourceId = null; + $this->resetValidation(); + $this->dispatch('modal-show', name: 'item-form'); + } + + public function editItem(int $index): void + { + $this->authorizeNavigation(); + abort_unless(array_key_exists($index, $this->menuItems), 404); + $this->editingItemIndex = $index; + $this->editingItem = $this->menuItems[$index]; + $this->itemLabel = (string) $this->editingItem['label']; + $this->itemType = (string) $this->editingItem['type']; + $this->itemUrl = (string) ($this->editingItem['url'] ?? ''); + $this->itemResourceId = filled($this->editingItem['resource_id'] ?? null) ? (int) $this->editingItem['resource_id'] : null; + $this->resetValidation(); + $this->dispatch('modal-show', name: 'item-form'); + } + + public function saveItem(): void + { + $this->authorizeNavigation(); + abort_unless($this->editingMenu, 422, 'Select a menu first.'); + $data = $this->validate([ + 'itemLabel' => ['required', 'string', 'max:255'], + 'itemType' => ['required', Rule::in(['link', 'page', 'collection', 'product'])], + 'itemUrl' => [ + 'required_if:itemType,link', 'nullable', 'string', 'max:2048', + function (string $attribute, mixed $value, \Closure $fail): void { + if (! SafeUrl::isAllowed($value)) { + $fail('The link must be a relative, HTTP, or HTTPS URL.'); + } + }, + ], + 'itemResourceId' => ['required_unless:itemType,link', 'nullable', 'integer', 'min:1'], + ]); + if ($data['itemType'] !== 'link') { + $this->assertResourceBelongsToStore($data['itemType'], (int) $data['itemResourceId']); + } + $item = [ + 'label' => trim($data['itemLabel']), + 'type' => $data['itemType'], + 'url' => $data['itemType'] === 'link' ? SafeUrl::normalize($data['itemUrl']) : null, + 'resource_id' => $data['itemType'] === 'link' ? null : (int) $data['itemResourceId'], + ]; + if ($this->editingItemIndex === null) { + $this->menuItems[] = $item; + } else { + $this->menuItems[$this->editingItemIndex] = $item; + } + $this->editingItem = null; + $this->editingItemIndex = null; + $this->dispatch('modal-close', name: 'item-form'); + } + + public function removeItem(int $index): void + { + $this->authorizeNavigation(); + abort_unless(array_key_exists($index, $this->menuItems), 404); + unset($this->menuItems[$index]); + $this->menuItems = array_values($this->menuItems); + } + + /** @param list $order */ + public function reorderItems(array $order): void + { + $this->authorizeNavigation(); + abort_unless(count($order) === count($this->menuItems), 422); + $old = $this->menuItems; + $this->menuItems = collect($order)->map(function (int $index) use ($old): array { + abort_unless(array_key_exists($index, $old), 422); + + return $old[$index]; + })->values()->all(); + } + + public function saveMenu(): void + { + $this->authorizeNavigation(); + abort_unless($this->editingMenu, 422, 'Select a menu first.'); + $this->editingMenu = $this->menu($this->editingMenu->id); + foreach ($this->menuItems as $item) { + abort_unless(in_array($item['type'], ['link', 'page', 'collection', 'product'], true), 422); + abort_if($item['type'] === 'link' && ! SafeUrl::isAllowed($item['url'] ?? null), 422, 'A navigation link is unsafe.'); + if ($item['type'] !== 'link') { + $this->assertResourceBelongsToStore($item['type'], (int) $item['resource_id']); + } + } + DB::transaction(function (): void { + $this->editingMenu->items()->delete(); + foreach ($this->menuItems as $position => $item) { + $this->editingMenu->items()->create([...$item, 'position' => $position]); + } + }); + $this->loadMenu($this->editingMenu->fresh()); + $this->toast('Navigation saved'); + } + + #[Computed] + public function menus(): mixed + { + return NavigationMenu::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->withCount('items')->orderBy('title')->get(); + } + + #[Computed] + public function resources(): array + { + $storeId = $this->currentStore()->id; + + return [ + 'page' => Page::withoutGlobalScopes()->where('store_id', $storeId)->orderBy('title')->get(['id', 'title'])->map(fn (Page $item): array => ['id' => $item->id, 'label' => $item->title])->all(), + 'collection' => ProductCollection::withoutGlobalScopes()->where('store_id', $storeId)->orderBy('title')->get(['id', 'title'])->map(fn (ProductCollection $item): array => ['id' => $item->id, 'label' => $item->title])->all(), + 'product' => Product::withoutGlobalScopes()->where('store_id', $storeId)->orderBy('title')->get(['id', 'title'])->map(fn (Product $item): array => ['id' => $item->id, 'label' => $item->title])->all(), + ]; + } + + public function render(): View + { + return $this->admin(view('admin.navigation.index'), 'Navigation', [['label' => 'Navigation']]); + } + + private function authorizeNavigation(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } + + private function menu(int $id): NavigationMenu + { + return NavigationMenu::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->findOrFail($id); + } + + private function loadMenu(NavigationMenu $menu): void + { + $this->editingMenu = $menu->load('items'); + $this->menuItems = $this->editingMenu->items->map(fn ($item): array => [ + 'label' => $item->label, + 'type' => (string) $this->enumValue($item->type), + 'url' => $item->url, + 'resource_id' => $item->resource_id, + ])->values()->all(); + } + + private function assertResourceBelongsToStore(string $type, int $id): void + { + $class = match ($type) { + 'page' => Page::class, + 'collection' => ProductCollection::class, + 'product' => Product::class, + default => abort(422), + }; + abort_unless($class::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->whereKey($id)->exists(), 422, 'The selected resource is unavailable.'); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..49d523d5 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,70 @@ +authorizeAction('viewAny', Order::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function setStatus(string $status): void + { + abort_unless(in_array($status, ['all', 'pending', 'paid', 'fulfilled', 'cancelled', 'refunded'], true), 400); + $this->statusFilter = $status; + $this->resetPage(); + } + + public function sortBy(string $field): void + { + abort_unless(in_array($field, ['placed_at', 'order_number', 'total_amount'], true), 400); + $this->sortDirection = $this->sortField === $field && $this->sortDirection === 'desc' ? 'asc' : 'desc'; + $this->sortField = $field; + } + + #[Computed] + public function orders(): LengthAwarePaginator + { + return Order::query()->with('customer') + ->when($this->search !== '', fn (Builder $query) => $query->where(fn (Builder $nested) => $nested->where('order_number', 'like', '%'.$this->search.'%')->orWhere('email', 'like', '%'.$this->search.'%')->orWhereHas('customer', fn (Builder $customer) => $customer->where('name', 'like', '%'.$this->search.'%')))) + ->when($this->statusFilter !== 'all', function (Builder $query): void { + $query->where(function (Builder $nested): void { + $nested->where('status', $this->statusFilter)->orWhere('financial_status', $this->statusFilter)->orWhere('fulfillment_status', $this->statusFilter); + }); + })->orderBy($this->sortField, $this->sortDirection)->paginate(25); + } + + public function render(): View + { + return $this->admin(view('admin.orders.index'), 'Orders', [['label' => 'Orders']]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..b6ef3200 --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,154 @@ + */ + public array $fulfillmentLines = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + public ?int $refundAmount = null; + + public string $refundReason = ''; + + /** @var array */ + public array $refundLines = []; + + public function mount(Order $order): void + { + abort_unless((int) $order->store_id === (int) $this->currentStore()->id, 404); + $this->authorizeAction('view', $order); + $this->order = $order; + $this->reloadOrder(); + foreach ($this->order->lines as $line) { + $fulfilled = (int) $line->fulfillmentLines->sum('quantity'); + $this->fulfillmentLines[$line->id] = max(0, (int) $line->quantity - $fulfilled); + $this->refundLines[$line->id] = 0; + } + } + + public function openFulfillmentModal(): void + { + foreach ($this->order->lines as $line) { + $this->fulfillmentLines[$line->id] = max(0, (int) $line->quantity - (int) $line->fulfillmentLines->sum('quantity')); + } + } + + public function openRefundModal(): void + { + $refunded = (int) $this->order->refunds + ->filter(fn ($refund): bool => $this->enumValue($refund->status) === 'processed') + ->sum('amount'); + $this->refundAmount = max(0, (int) $this->order->total_amount - $refunded); + } + + public function confirmPayment(): void + { + $this->authorizeAction('update', $this->order); + try { + app(OrderService::class)->confirmBankTransfer($this->order); + $this->reloadOrder(); + $this->toast('Bank transfer payment confirmed.'); + } catch (\Throwable $exception) { + $this->toast($exception->getMessage(), 'error'); + } + } + + public function createFulfillment(): void + { + $this->authorizeAction('fulfill', $this->order); + $this->validate([ + 'fulfillmentLines' => ['required', 'array'], 'fulfillmentLines.*' => ['integer', 'min:0'], + 'trackingCompany' => ['nullable', 'string', 'max:255'], 'trackingNumber' => ['nullable', 'string', 'max:255'], 'trackingUrl' => [ + 'nullable', 'string', 'max:2048', + function (string $attribute, mixed $value, \Closure $fail): void { + if (! SafeUrl::isAllowed($value)) { + $fail('The tracking link must be a relative, HTTP, or HTTPS URL.'); + } + }, + ], + ]); + $lines = collect($this->fulfillmentLines)->mapWithKeys(fn ($quantity, $id) => [(int) $id => (int) $quantity])->filter(fn (int $quantity) => $quantity > 0)->all(); + if ($lines === []) { + throw ValidationException::withMessages(['fulfillmentLines' => 'Select at least one quantity to fulfill.']); + } + try { + app(FulfillmentService::class)->create($this->order, $lines, ['tracking_company' => $this->trackingCompany ?: null, 'tracking_number' => $this->trackingNumber ?: null, 'tracking_url' => $this->trackingUrl ?: null]); + $this->reloadOrder(); + $this->toast('Fulfillment created.'); + $this->modal('create-fulfillment')->close(); + } catch (\Throwable $exception) { + $this->toast($exception->getMessage(), 'error'); + } + } + + public function markAsShipped(int $fulfillmentId): void + { + $this->authorizeAction('fulfill', $this->order); + $fulfillment = $this->order->fulfillments()->findOrFail($fulfillmentId); + app(FulfillmentService::class)->markAsShipped($fulfillment); + $this->reloadOrder(); + $this->toast('Fulfillment marked as shipped.'); + } + + public function markAsDelivered(int $fulfillmentId): void + { + $this->authorizeAction('fulfill', $this->order); + $fulfillment = $this->order->fulfillments()->findOrFail($fulfillmentId); + app(FulfillmentService::class)->markAsDelivered($fulfillment); + $this->reloadOrder(); + if ($this->order->fulfillments->isNotEmpty() && $this->order->fulfillments->every(fn (Fulfillment $item): bool => $this->enumValue($item->status) === 'delivered')) { + $this->order->update(['fulfillment_status' => 'fulfilled', 'status' => 'fulfilled']); + $this->reloadOrder(); + } + $this->toast('Fulfillment marked as delivered.'); + } + + public function createRefund(): void + { + $this->authorizeAction('refund', $this->order); + $this->validate(['refundAmount' => ['nullable', 'integer', 'min:1'], 'refundReason' => ['nullable', 'string', 'max:1000'], 'refundLines.*' => ['integer', 'min:0']]); + $payment = $this->order->payments->first(fn ($payment) => in_array($this->enumValue($payment->status), ['captured', 'refunded'], true)); + if (! $payment) { + throw ValidationException::withMessages(['refundAmount' => 'This order has no captured payment.']); + } + $lines = collect($this->refundLines)->mapWithKeys(fn ($quantity, $id) => [(int) $id => (int) $quantity])->filter()->all(); + $amount = $this->refundAmount ?: (int) $this->order->lines->sum(fn ($line) => (int) ($lines[$line->id] ?? 0) * (int) $line->unit_price_amount); + try { + app(RefundService::class)->create($this->order, $payment, $amount, $this->refundReason ?: null, $lines !== [], $lines ?: null); + $this->reloadOrder(); + $this->toast('Refund processed.'); + $this->modal('create-refund')->close(); + } catch (\Throwable $exception) { + $this->toast($exception->getMessage(), 'error'); + } + } + + private function reloadOrder(): void + { + $this->order = $this->order->refresh()->load(['customer', 'lines.product.media', 'lines.variant', 'lines.fulfillmentLines', 'payments', 'refunds', 'fulfillments.lines.orderLine']); + } + + public function render(): View + { + return $this->admin(view('admin.orders.show'), $this->order->order_number, [['label' => 'Orders', 'url' => url('/admin/orders')], ['label' => $this->order->order_number]]); + } +} diff --git a/app/Livewire/Admin/Pages/Form.php b/app/Livewire/Admin/Pages/Form.php new file mode 100644 index 00000000..38b21751 --- /dev/null +++ b/app/Livewire/Admin/Pages/Form.php @@ -0,0 +1,102 @@ +exists) { + abort_unless((int) $page->store_id === (int) $this->currentStore()->id, 404); + $this->authorizeAction('view', $page); + $this->page = $page; + $this->title = $page->title; + $this->handle = $page->handle; + $this->bodyHtml = (string) $page->body_html; + $this->status = (string) $this->enumValue($page->status); + $this->publishedAt = $page->published_at?->format('Y-m-d\TH:i'); + + return; + } + + $this->authorizeAction('create', Page::class); + } + + public function updatedTitle(string $title): void + { + $this->page + ? $this->authorizeAction('update', $this->page) + : $this->authorizeAction('create', Page::class); + if (! $this->page) { + $this->handle = Str::slug($title); + } + } + + public function save(): void + { + $this->page + ? $this->authorizeAction('update', $this->page) + : $this->authorizeAction('create', Page::class); + $this->handle = Str::slug($this->handle ?: $this->title); + $data = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'alpha_dash:ascii', 'max:255', Rule::unique('pages', 'handle')->where('store_id', $this->currentStore()->id)->ignore($this->page?->id)], + 'bodyHtml' => ['nullable', 'string', 'max:1000000'], + 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], + 'publishedAt' => ['nullable', 'date'], + ]); + $body = preg_replace('#]*>(.*?)#is', '', $data['bodyHtml'] ?? '') ?? ''; + $publishedAt = $data['status'] === 'published' ? ($data['publishedAt'] ?: now()) : null; + + if ($this->page) { + $this->page->update([ + 'title' => trim($data['title']), 'handle' => $data['handle'], 'body_html' => $body, + 'status' => $data['status'], 'published_at' => $publishedAt, + ]); + } else { + $this->page = Page::withoutGlobalScopes()->create([ + 'store_id' => $this->currentStore()->id, 'title' => trim($data['title']), 'handle' => $data['handle'], + 'body_html' => $body, 'status' => $data['status'], 'published_at' => $publishedAt, + ]); + } + $this->publishedAt = $this->page->published_at?->format('Y-m-d\TH:i'); + $this->toast('Page saved'); + } + + public function deletePage(): mixed + { + abort_unless($this->page, 404); + $this->authorizeAction('delete', $this->page); + $this->page->delete(); + $this->toast('Page deleted'); + + return $this->redirect('/admin/pages', navigate: true); + } + + public function render(): View + { + $label = $this->page ? $this->title : 'Add page'; + + return $this->admin(view('admin.pages.form'), $label, [ + ['label' => 'Pages', 'url' => url('/admin/pages')], ['label' => $label], + ]); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..b074e344 --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,42 @@ +authorizeAction('viewAny', Page::class); + } + + public function updatedSearch(): void + { + $this->authorizeAction('viewAny', Page::class); + $this->resetPage(); + } + + #[Computed] + public function pages(): mixed + { + return Page::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->when(trim($this->search) !== '', fn ($query) => $query->where('title', 'like', '%'.trim($this->search).'%')) + ->latest('updated_at') + ->paginate(20); + } + + public function render(): View + { + return $this->admin(view('admin.pages.index'), 'Pages', [['label' => 'Pages']]); + } +} diff --git a/app/Livewire/Admin/Products/Form.php b/app/Livewire/Admin/Products/Form.php new file mode 100644 index 00000000..6f5f5dc5 --- /dev/null +++ b/app/Livewire/Admin/Products/Form.php @@ -0,0 +1,327 @@ + */ + public array $collectionIds = []; + + /** @var list}> */ + public array $options = []; + + /** @var list> */ + public array $variants = []; + + /** @var list> */ + public array $media = []; + + /** @var array */ + public array $newMedia = []; + + public function mount(?Product $product = null): void + { + if ($product?->exists) { + abort_unless((int) $product->store_id === (int) $this->currentStore()->id, 404); + $this->authorizeAction('view', $product); + $this->product = $product->load(['options.values', 'variants.inventoryItem', 'variants.optionValues.option', 'media', 'collections']); + $this->title = $product->title; + $this->descriptionHtml = (string) $product->description_html; + $this->status = (string) $this->enumValue($product->status); + $this->vendor = (string) $product->vendor; + $this->productType = (string) $product->product_type; + $this->tags = implode(', ', (array) $product->tags); + $this->handle = $product->handle; + $this->publishedAt = $product->published_at?->format('Y-m-d\TH:i'); + $this->collectionIds = $product->collections->modelKeys(); + $this->options = $product->options->map(fn ($option): array => ['name' => $option->name, 'values' => $option->values->pluck('value')->all()])->all(); + $this->variants = $product->variants->map(fn ($variant): array => [ + 'title' => $variant->optionValues->sortBy(fn ($value) => $value->option?->position)->pluck('value')->implode(' / ') ?: 'Default', + 'sku' => (string) $variant->sku, 'price' => (int) $variant->price_amount, + 'compareAtPrice' => $variant->compare_at_amount, 'quantity' => (int) ($variant->inventoryItem?->quantity_on_hand ?? 0), + 'requiresShipping' => (bool) $variant->requires_shipping, + ])->all(); + $this->refreshMedia(); + } else { + $this->authorizeAction('create', Product::class); + $this->variants = [['title' => 'Default', 'sku' => '', 'price' => 0, 'compareAtPrice' => null, 'quantity' => 0, 'requiresShipping' => true]]; + } + } + + public function updatedTitle(string $value): void + { + if (! $this->product && $this->handle === '') { + $this->handle = Str::slug($value); + } + } + + public function addOption(): void + { + if (count($this->options) < 3) { + $this->options[] = ['name' => '', 'values' => ['']]; + $this->generateVariants(); + } + } + + public function removeOption(int $index): void + { + unset($this->options[$index]); + $this->options = array_values($this->options); + $this->generateVariants(); + } + + public function addOptionValue(int $optionIndex): void + { + $this->options[$optionIndex]['values'][] = ''; + } + + public function removeOptionValue(int $optionIndex, int $valueIndex): void + { + unset($this->options[$optionIndex]['values'][$valueIndex]); + $this->options[$optionIndex]['values'] = array_values($this->options[$optionIndex]['values']); + $this->generateVariants(); + } + + public function updatedOptions(): void + { + $this->generateVariants(); + } + + public function generateVariants(): void + { + $old = collect($this->variants)->keyBy('title'); + $groups = collect($this->options)->map(fn (array $option): array => array_values(array_filter(array_map('trim', $option['values'] ?? []))))->filter()->values()->all(); + $combinationCount = array_reduce($groups, fn (int $count, array $group): int => $count * count(array_unique($group)), 1); + if ($combinationCount > 100) { + throw ValidationException::withMessages(['options' => 'Product options may generate at most 100 variants.']); + } + $combinations = [[]]; + foreach ($groups as $group) { + $combinations = collect($combinations)->flatMap(fn (array $prefix) => collect($group)->map(fn (string $value) => [...$prefix, $value]))->all(); + } + $this->variants = collect($combinations ?: [[]])->map(function (array $values) use ($old): array { + $title = implode(' / ', $values) ?: 'Default'; + + return $old->get($title, ['title' => $title, 'sku' => '', 'price' => 0, 'compareAtPrice' => null, 'quantity' => 0, 'requiresShipping' => true]); + })->values()->all(); + } + + public function save(): void + { + $id = $this->product?->id; + $validated = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', Rule::in(['draft', 'active', 'archived'])], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'string'], + 'handle' => ['required', 'alpha_dash', 'max:255', Rule::unique('products', 'handle')->where('store_id', $this->currentStore()->id)->ignore($id)], + 'options' => ['array', 'max:3'], + 'options.*.name' => ['required', 'string', 'max:100'], + 'options.*.values' => ['required', 'array', 'min:1'], + 'options.*.values.*' => ['required', 'string', 'max:100'], + 'variants' => ['required', 'array', 'min:1'], + 'variants.*.sku' => ['nullable', 'string', 'max:255'], + 'variants.*.price' => ['required', 'integer', 'min:0'], + 'variants.*.compareAtPrice' => ['nullable', 'integer', 'min:0'], + 'variants.*.quantity' => ['required', 'integer', 'min:0'], + 'variants.*.requiresShipping' => ['boolean'], + 'collectionIds' => ['array'], + 'collectionIds.*' => ['integer', Rule::exists('collections', 'id')->where('store_id', $this->currentStore()->id)], + 'newMedia.*' => ['image', 'max:5120'], + ]); + + if ($this->status === 'active' && collect($validated['variants'])->doesntContain(fn (array $variant): bool => (int) $variant['price'] > 0)) { + throw ValidationException::withMessages(['variants' => 'An active product needs at least one variant with a price greater than zero.']); + } + + $service = app(ProductService::class); + $data = [ + 'title' => $validated['title'], 'description_html' => $validated['descriptionHtml'] ?: null, + 'vendor' => $validated['vendor'] ?: null, 'product_type' => $validated['productType'] ?: null, + 'tags' => array_values(array_filter(array_map('trim', explode(',', $validated['tags'] ?? '')))), + 'handle' => Str::slug($validated['handle']), + 'options' => collect($validated['options'])->map(fn (array $option): array => ['name' => trim($option['name']), 'values' => array_values(array_unique(array_map('trim', $option['values'])))])->all(), + ]; + + if ($this->product) { + $this->authorizeAction('update', $this->product); + $this->product = $service->update($this->product, $data); + } else { + $this->authorizeAction('create', Product::class); + $this->product = $service->create($this->currentStore(), [...$data, 'status' => 'draft', 'variant' => ['price_amount' => 0]]); + } + + $local = collect($this->variants)->keyBy('title'); + $this->product->load(['variants.optionValues.option', 'variants.inventoryItem']); + foreach ($this->product->variants as $position => $variant) { + $title = $variant->optionValues->sortBy(fn ($value) => $value->option?->position)->pluck('value')->implode(' / ') ?: 'Default'; + $row = $local->get($title, $this->variants[$position] ?? null); + if (! $row) { + continue; + } + $variant->update([ + 'sku' => $row['sku'] ?: null, 'price_amount' => (int) $row['price'], + 'compare_at_amount' => filled($row['compareAtPrice']) ? (int) $row['compareAtPrice'] : null, + 'currency' => $this->currentStore()->default_currency, 'requires_shipping' => (bool) $row['requiresShipping'], 'position' => $position, + ]); + $variant->inventoryItem()->updateOrCreate([], ['store_id' => $this->currentStore()->id, 'quantity_on_hand' => (int) $row['quantity'], 'quantity_reserved' => (int) ($variant->inventoryItem?->quantity_reserved ?? 0), 'policy' => $variant->inventoryItem?->policy ?? 'deny']); + } + $this->product->collections()->sync(collect($this->collectionIds)->mapWithKeys(fn ($value, $position) => [(int) $value => ['position' => $position]])->all()); + $this->product->forceFill(['published_at' => $this->publishedAt ?: null])->save(); + try { + $service->transitionStatus($this->product->refresh(), ProductStatus::from($this->status)); + } catch (DomainException $exception) { + throw ValidationException::withMessages(['status' => $exception->getMessage()]); + } + $this->uploadMedia(); + app(SearchService::class)->syncProduct($this->product->refresh()); + $this->toast('Product saved successfully.'); + $this->redirect('/admin/products/'.$this->product->id.'/edit', navigate: true); + } + + public function uploadMedia(): void + { + if (! $this->product || $this->newMedia === []) { + return; + } + + $this->authorizeAction('update', $this->product); + $this->validate([ + 'newMedia' => ['required', 'array', 'max:10'], + 'newMedia.*' => ['required', 'file', 'image', 'mimes:jpg,jpeg,png,gif,webp,avif', 'max:5120'], + ]); + + $position = (int) $this->product->media()->max('position') + 1; + foreach ($this->newMedia as $file) { + $path = $file->store('media/'.$this->product->id.'/originals', 'public'); + $media = $this->product->media()->create([ + 'type' => 'image', + 'storage_key' => $path, + 'alt_text' => $this->product->title, + 'mime_type' => $file->getMimeType(), + 'byte_size' => $file->getSize(), + 'position' => $position++, + 'status' => 'processing', + ]); + ProcessMediaUpload::dispatch($media)->afterCommit(); + } + $this->newMedia = []; + $this->refreshMedia(); + } + + public function removeMedia(int $mediaId): void + { + abort_unless($this->product, 404); + $this->authorizeAction('update', $this->product); + $media = $this->product->media()->findOrFail($mediaId); + Storage::disk('public')->delete($media->storage_key); + $media->delete(); + $this->refreshMedia(); + $this->toast('Media removed.'); + } + + /** @param list $order */ + public function reorderMedia(array $order): void + { + abort_unless($this->product, 404); + $this->authorizeAction('update', $this->product); + foreach ($order as $position => $id) { + $this->product->media()->whereKey($id)->update(['position' => $position]); + } + $this->refreshMedia(); + } + + public function moveMedia(int $mediaId, string $direction): void + { + abort_unless(in_array($direction, ['up', 'down'], true), 400); + $ids = collect($this->media)->pluck('id')->map(fn ($id): int => (int) $id)->values()->all(); + $index = array_search($mediaId, $ids, true); + abort_if($index === false, 404); + $target = $direction === 'up' ? $index - 1 : $index + 1; + if (! isset($ids[$target])) { + return; + } + [$ids[$index], $ids[$target]] = [$ids[$target], $ids[$index]]; + $this->reorderMedia($ids); + } + + public function updateMediaAlt(int $mediaId, string $alt): void + { + abort_unless($this->product, 404); + $this->authorizeAction('update', $this->product); + $this->product->media()->whereKey($mediaId)->update(['alt_text' => Str::limit(trim($alt), 255, '')]); + $this->refreshMedia(); + } + + public function deleteProduct(): void + { + abort_unless($this->product, 404); + $this->authorizeAction('delete', $this->product); + app(ProductService::class)->transitionStatus($this->product, ProductStatus::Archived); + $this->toast('Product archived.'); + $this->redirect('/admin/products', navigate: true); + } + + private function refreshMedia(): void + { + $this->media = $this->product?->media()->orderBy('position')->get()->map(fn (ProductMedia $media): array => ['id' => $media->id, 'url' => Storage::disk('public')->url($media->storage_key), 'alt_text' => $media->alt_text, 'position' => $media->position])->all() ?? []; + } + + #[Computed] + public function availableCollections(): iterable + { + return Collection::query()->orderBy('title')->get(); + } + + #[Computed] + public function isEditing(): bool + { + return $this->product?->exists === true; + } + + public function render(): View + { + $label = $this->product?->title ?: 'Add product'; + + return $this->admin(view('admin.products.form'), $label, [['label' => 'Products', 'url' => url('/admin/products')], ['label' => $label]]); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..3db3c851 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,144 @@ + */ + public array $selectedIds = []; + + public bool $selectAll = false; + + public string $sortField = 'updated_at'; + + public string $sortDirection = 'desc'; + + public function mount(): void + { + $this->authorizeAction('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function updatedTypeFilter(): void + { + $this->resetPage(); + } + + public function sortBy(string $field): void + { + abort_unless(in_array($field, ['title', 'updated_at', 'product_type', 'vendor'], true), 400); + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + $this->resetPage(); + } + + public function toggleSelectAll(): void + { + $this->selectAll = ! $this->selectAll; + $this->selectedIds = $this->selectAll ? $this->products->getCollection()->modelKeys() : []; + } + + public function bulkSetActive(): void + { + $this->transitionSelected(ProductStatus::Active, 'Products activated.'); + } + + public function bulkArchive(): void + { + $this->transitionSelected(ProductStatus::Archived, 'Products archived.'); + } + + public function bulkDelete(): void + { + $products = $this->selectedProducts(); + foreach ($products as $product) { + $this->authorizeAction('delete', $product); + try { + app(ProductService::class)->delete($product); + } catch (\Throwable) { + app(ProductService::class)->transitionStatus($product, ProductStatus::Archived); + } + } + $this->clearSelection(); + $this->toast('Selected products were removed or archived.'); + } + + private function transitionSelected(ProductStatus $status, string $message): void + { + try { + foreach ($this->selectedProducts() as $product) { + $this->authorizeAction('update', $product); + app(ProductService::class)->transitionStatus($product, $status); + } + $this->toast($message); + $this->clearSelection(); + } catch (\Throwable $exception) { + $this->toast($exception->getMessage(), 'error'); + } + } + + private function selectedProducts(): iterable + { + return Product::query()->whereKey(array_map('intval', $this->selectedIds))->get(); + } + + private function clearSelection(): void + { + $this->selectedIds = []; + $this->selectAll = false; + } + + #[Computed] + public function products(): LengthAwarePaginator + { + return Product::query() + ->with(['variants.inventoryItem', 'media' => fn ($query) => $query->orderBy('position')->limit(1)]) + ->withCount('variants') + ->when($this->search !== '', fn (Builder $query) => $query->where(fn (Builder $nested) => $nested->where('title', 'like', '%'.$this->search.'%')->orWhere('vendor', 'like', '%'.$this->search.'%')->orWhere('handle', 'like', '%'.$this->search.'%'))) + ->when($this->statusFilter !== 'all', fn (Builder $query) => $query->where('status', $this->statusFilter)) + ->when($this->typeFilter !== '', fn (Builder $query) => $query->where('product_type', $this->typeFilter)) + ->orderBy($this->sortField, $this->sortDirection)->paginate(20); + } + + #[Computed] + public function productTypes(): array + { + return Product::query()->whereNotNull('product_type')->where('product_type', '!=', '')->distinct()->orderBy('product_type')->pluck('product_type')->all(); + } + + public function render(): View + { + return $this->admin(view('admin.products.index'), 'Products', [['label' => 'Products']]); + } +} diff --git a/app/Livewire/Admin/Search/Settings.php b/app/Livewire/Admin/Search/Settings.php new file mode 100644 index 00000000..561637ec --- /dev/null +++ b/app/Livewire/Admin/Search/Settings.php @@ -0,0 +1,121 @@ + */ + public array $synonymGroups = []; + + public string $stopWords = ''; + + public ?string $lastIndexedAt = null; + + public bool $isReindexing = false; + + public ?int $reindexProgress = null; + + public function mount(): void + { + $this->authorizeSettings(); + $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->first(); + $this->synonymGroups = collect((array) ($settings?->synonyms_json ?? []))->map(function (mixed $group): string { + return is_array($group) ? implode(', ', array_map('strval', $group)) : (string) $group; + })->values()->all(); + if ($this->synonymGroups === []) { + $this->synonymGroups = ['']; + } + $this->stopWords = implode(', ', array_map('strval', (array) ($settings?->stop_words_json ?? []))); + $storeSettings = StoreSettings::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->first(); + $searchStatus = (array) data_get($storeSettings?->settings_json, 'search', []); + $this->lastIndexedAt = $searchStatus['last_reindex_at'] ?? $searchStatus['last_indexed_at'] ?? null; + $this->reindexProgress = (int) ($searchStatus['progress'] ?? 0); + $this->isReindexing = in_array($searchStatus['status'] ?? null, ['queued', 'processing'], true); + } + + public function addSynonymGroup(): void + { + $this->authorizeSettings(); + $this->synonymGroups[] = ''; + } + + public function removeSynonymGroup(int $index): void + { + $this->authorizeSettings(); + abort_unless(array_key_exists($index, $this->synonymGroups), 404); + unset($this->synonymGroups[$index]); + $this->synonymGroups = array_values($this->synonymGroups); + } + + public function save(): void + { + $this->authorizeSettings(); + $data = $this->validate([ + 'synonymGroups' => ['array', 'max:100'], + 'synonymGroups.*' => ['nullable', 'string', 'max:1000'], + 'stopWords' => ['nullable', 'string', 'max:10000'], + ]); + $synonyms = collect($data['synonymGroups'])->map(fn (string $group): array => $this->commaList($group))->filter()->values()->all(); + SearchSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $this->currentStore()->id], [ + 'synonyms_json' => $synonyms, + 'stop_words_json' => $this->commaList((string) $data['stopWords']), + ]); + $this->toast('Search settings saved'); + } + + public function triggerReindex(): void + { + $this->authorizeSettings(); + $record = StoreSettings::withoutGlobalScopes()->firstOrNew(['store_id' => $this->currentStore()->id]); + $values = (array) $record->settings_json; + if (in_array(data_get($values, 'search.status'), ['queued', 'processing'], true)) { + $this->pollReindexStatus(); + $this->toast('A search reindex is already in progress'); + + return; + } + + $this->isReindexing = true; + $this->reindexProgress = 0; + data_set($values, 'search.status', 'queued'); + data_set($values, 'search.progress', 0); + $record->settings_json = $values; + $record->save(); + ReindexProducts::dispatch((int) $this->currentStore()->id)->afterCommit(); + $this->pollReindexStatus(); + $this->toast($this->isReindexing ? 'Search reindex queued' : 'Search index rebuilt'); + } + + public function pollReindexStatus(): void + { + $this->authorizeSettings(); + $record = StoreSettings::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->first(); + $settings = (array) data_get($record?->settings_json, 'search', []); + $this->reindexProgress = (int) ($settings['progress'] ?? 0); + $this->lastIndexedAt = $settings['last_reindex_at'] ?? $settings['last_indexed_at'] ?? $this->lastIndexedAt; + $this->isReindexing = in_array($settings['status'] ?? null, ['queued', 'processing'], true); + } + + public function render(): View + { + return $this->admin(view('admin.search.settings'), 'Search Settings', [['label' => 'Search Settings']]); + } + + private function authorizeSettings(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } + + /** @return list */ + private function commaList(string $value): array + { + return collect(explode(',', $value))->map(fn (string $word): string => trim($word))->filter()->unique()->values()->all(); + } +} diff --git a/app/Livewire/Admin/Settings/Checkout.php b/app/Livewire/Admin/Settings/Checkout.php new file mode 100644 index 00000000..12e08ccd --- /dev/null +++ b/app/Livewire/Admin/Settings/Checkout.php @@ -0,0 +1,60 @@ +authorizeSettings(); + $settings = (array) StoreSettings::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->value('settings_json'); + $this->allowGuestCheckout = (bool) data_get($settings, 'checkout.allow_guest_checkout', true); + $this->requirePhone = (bool) data_get($settings, 'checkout.require_phone', false); + $this->requireCompany = (bool) data_get($settings, 'checkout.require_company', false); + $this->checkoutExpiryHours = (int) data_get($settings, 'checkout.expiry_hours', 24); + } + + public function save(): void + { + $this->authorizeSettings(); + $data = $this->validate([ + 'allowGuestCheckout' => ['boolean'], + 'requirePhone' => ['boolean'], + 'requireCompany' => ['boolean'], + 'checkoutExpiryHours' => ['required', 'integer', 'min:1', 'max:168'], + ]); + $record = StoreSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $this->currentStore()->id], ['settings_json' => []]); + $settings = (array) $record->settings_json; + data_set($settings, 'checkout', [ + 'allow_guest_checkout' => $data['allowGuestCheckout'], + 'require_phone' => $data['requirePhone'], + 'require_company' => $data['requireCompany'], + 'expiry_hours' => $data['checkoutExpiryHours'], + ]); + $record->update(['settings_json' => $settings]); + $this->toast('Checkout settings saved'); + } + + public function render(): View + { + return $this->admin(view('admin.settings.checkout'), 'Checkout Settings', [['label' => 'Settings', 'url' => url('/admin/settings')], ['label' => 'Checkout']]); + } + + private function authorizeSettings(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } +} diff --git a/app/Livewire/Admin/Settings/Index.php b/app/Livewire/Admin/Settings/Index.php new file mode 100644 index 00000000..fd21f4c9 --- /dev/null +++ b/app/Livewire/Admin/Settings/Index.php @@ -0,0 +1,137 @@ +authorizeSettings(); + abort_unless(in_array($this->activeTab, ['general', 'domains'], true), 404); + $store = $this->currentStore(); + $this->storeName = $store->name; + $this->storeHandle = $store->handle; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + } + + public function save(): void + { + $this->authorizeSettings(); + $data = $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'defaultCurrency' => ['required', Rule::in(['EUR', 'USD', 'GBP', 'CHF', 'CAD', 'AUD'])], + 'defaultLocale' => ['required', Rule::in(['en', 'de', 'fr', 'es', 'it', 'nl'])], + 'timezone' => ['required', 'timezone'], + ]); + + $this->currentStore()->update([ + 'name' => trim($data['storeName']), + 'default_currency' => $data['defaultCurrency'], + 'default_locale' => $data['defaultLocale'], + 'timezone' => $data['timezone'], + ]); + $this->toast('Settings saved'); + } + + public function selectTab(string $tab): void + { + $this->authorizeSettings(); + abort_unless(in_array($tab, ['general', 'domains'], true), 404); + $this->activeTab = $tab; + } + + public function addDomain(): void + { + $this->authorizeSettings(); + $this->newHostname = strtolower(trim($this->newHostname)); + $data = $this->validate([ + 'newHostname' => ['required', 'string', 'max:253', 'regex:/^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', Rule::unique('store_domains', 'hostname')], + 'newType' => ['required', Rule::in(['storefront', 'admin', 'api'])], + ]); + + $isPrimary = ! StoreDomain::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->where('type', $data['newType'])->exists(); + StoreDomain::withoutGlobalScopes()->create([ + 'store_id' => $this->currentStore()->id, + 'hostname' => $data['newHostname'], + 'type' => $data['newType'], + 'is_primary' => $isPrimary, + 'tls_mode' => 'managed', + ]); + $this->reset('newHostname'); + $this->newType = 'storefront'; + $this->dispatch('modal-close', name: 'add-domain'); + unset($this->domains); + $this->toast('Domain added'); + } + + public function removeDomain(int $domainId): void + { + $this->authorizeSettings(); + $domain = $this->domain($domainId); + abort_if($domain->is_primary, 422, 'Choose another primary domain before deleting this one.'); + $domain->delete(); + unset($this->domains); + $this->toast('Domain removed'); + } + + public function setPrimary(int $domainId): void + { + $this->authorizeSettings(); + $domain = $this->domain($domainId); + DB::transaction(function () use ($domain): void { + StoreDomain::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->where('type', $this->enumValue($domain->type))->update(['is_primary' => false]); + $domain->update(['is_primary' => true]); + }); + unset($this->domains); + $this->toast('Primary domain updated'); + } + + #[Computed] + public function domains(): mixed + { + return StoreDomain::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->orderByDesc('is_primary')->orderBy('hostname')->get(); + } + + public function render(): View + { + return $this->admin(view('admin.settings.index'), 'Store Settings', [['label' => 'Settings']]); + } + + private function authorizeSettings(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } + + private function domain(int $id): StoreDomain + { + return StoreDomain::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->findOrFail($id); + } +} diff --git a/app/Livewire/Admin/Settings/Notifications.php b/app/Livewire/Admin/Settings/Notifications.php new file mode 100644 index 00000000..b66fedfb --- /dev/null +++ b/app/Livewire/Admin/Settings/Notifications.php @@ -0,0 +1,65 @@ +authorizeSettings(); + $settings = (array) StoreSettings::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->value('settings_json'); + $this->orderConfirmation = (bool) data_get($settings, 'notifications.order_confirmation', true); + $this->shippingConfirmation = (bool) data_get($settings, 'notifications.shipping_confirmation', true); + $this->refundConfirmation = (bool) data_get($settings, 'notifications.refund_confirmation', true); + $this->cancellationConfirmation = (bool) data_get($settings, 'notifications.cancellation_confirmation', true); + $this->notifyStaffOfNewOrders = (bool) data_get($settings, 'notifications.staff_new_order', true); + } + + public function save(): void + { + $this->authorizeSettings(); + $data = $this->validate([ + 'orderConfirmation' => ['boolean'], + 'shippingConfirmation' => ['boolean'], + 'refundConfirmation' => ['boolean'], + 'cancellationConfirmation' => ['boolean'], + 'notifyStaffOfNewOrders' => ['boolean'], + ]); + $record = StoreSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $this->currentStore()->id], ['settings_json' => []]); + $settings = (array) $record->settings_json; + data_set($settings, 'notifications', [ + 'order_confirmation' => $data['orderConfirmation'], + 'shipping_confirmation' => $data['shippingConfirmation'], + 'refund_confirmation' => $data['refundConfirmation'], + 'cancellation_confirmation' => $data['cancellationConfirmation'], + 'staff_new_order' => $data['notifyStaffOfNewOrders'], + ]); + $record->update(['settings_json' => $settings]); + $this->toast('Notification settings saved'); + } + + public function render(): View + { + return $this->admin(view('admin.settings.notifications'), 'Notification Settings', [['label' => 'Settings', 'url' => url('/admin/settings')], ['label' => 'Notifications']]); + } + + private function authorizeSettings(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..e1d335cd --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,174 @@ + */ + public array $zoneCountries = []; + + public ?ShippingRate $editingRate = null; + + public ?int $rateZoneId = null; + + public string $rateName = ''; + + public string $rateType = 'flat'; + + /** @var array */ + public array $rateConfig = []; + + public string $rateAmount = '0.00'; + + public bool $rateActive = true; + + /** @var array */ + public array $testAddress = ['country' => 'DE', 'province' => '', 'city' => '', 'postal_code' => '']; + + /** @var array|null */ + public ?array $testResult = null; + + public function mount(): void + { + $this->authorizeSettings(); + } + + public function openZoneModal(?int $zoneId = null): void + { + $this->authorizeSettings(); + $this->editingZone = $zoneId ? $this->zone($zoneId) : null; + $this->zoneName = (string) ($this->editingZone?->name ?? ''); + $this->zoneCountries = array_values((array) ($this->editingZone?->countries_json ?? [])); + $this->resetValidation(); + $this->dispatch('modal-show', name: 'zone-form'); + } + + public function saveZone(): void + { + $this->authorizeSettings(); + $data = $this->validate(['zoneName' => ['required', 'string', 'max:255'], 'zoneCountries' => ['required', 'array', 'min:1'], 'zoneCountries.*' => ['string', 'size:2']]); + ShippingZone::withoutGlobalScopes()->updateOrCreate( + ['id' => $this->editingZone?->id, 'store_id' => $this->currentStore()->id], + ['name' => trim($data['zoneName']), 'countries_json' => array_values(array_unique(array_map('strtoupper', $data['zoneCountries']))), 'regions_json' => (array) ($this->editingZone?->regions_json ?? [])], + ); + $this->editingZone = null; + unset($this->zones); + $this->dispatch('modal-close', name: 'zone-form'); + $this->toast('Shipping zone saved'); + } + + public function deleteZone(int $zoneId): void + { + $this->authorizeSettings(); + $this->zone($zoneId)->delete(); + unset($this->zones); + $this->toast('Shipping zone deleted'); + } + + public function openRateModal(int $zoneId, ?int $rateId = null): void + { + $this->authorizeSettings(); + $this->zone($zoneId); + $this->editingRate = $rateId ? $this->rate($rateId, $zoneId) : null; + $this->rateZoneId = $zoneId; + $this->rateName = (string) ($this->editingRate?->name ?? ''); + $this->rateType = (string) $this->enumValue($this->editingRate?->type ?? 'flat'); + $this->rateConfig = (array) ($this->editingRate?->config_json ?? []); + $amount = (int) data_get($this->rateConfig, 'amount', data_get($this->rateConfig, 'ranges.0.amount', data_get($this->rateConfig, 'fallback_amount', 0))); + $this->rateAmount = number_format($amount / 100, 2, '.', ''); + $this->rateActive = (bool) ($this->editingRate?->is_active ?? true); + $this->resetValidation(); + $this->dispatch('modal-show', name: 'rate-form'); + } + + public function saveRate(): void + { + $this->authorizeSettings(); + abort_unless($this->rateZoneId, 422); + $this->zone($this->rateZoneId); + $data = $this->validate([ + 'rateName' => ['required', 'string', 'max:255'], + 'rateType' => ['required', Rule::in(['flat', 'weight', 'price', 'carrier'])], + 'rateAmount' => ['required_unless:rateType,carrier', 'nullable', 'numeric', 'min:0', 'max:1000000'], + 'rateActive' => ['boolean'], + 'rateConfig.min_g' => ['nullable', 'integer', 'min:0'], 'rateConfig.max_g' => ['nullable', 'integer', 'gte:rateConfig.min_g'], + 'rateConfig.min_amount' => ['nullable', 'numeric', 'min:0'], 'rateConfig.max_amount' => ['nullable', 'numeric', 'gte:rateConfig.min_amount'], + ]); + $amount = (int) round((float) ($data['rateAmount'] ?? 0) * 100); + $config = match ($data['rateType']) { + 'weight' => ['ranges' => [['min_g' => (int) data_get($data, 'rateConfig.min_g', 0), 'max_g' => filled(data_get($data, 'rateConfig.max_g')) ? (int) data_get($data, 'rateConfig.max_g') : null, 'amount' => $amount]]], + 'price' => ['ranges' => [['min_amount' => (int) round((float) data_get($data, 'rateConfig.min_amount', 0) * 100), 'max_amount' => filled(data_get($data, 'rateConfig.max_amount')) ? (int) round((float) data_get($data, 'rateConfig.max_amount') * 100) : null, 'amount' => $amount]]], + 'carrier' => ['carrier' => 'mock', 'service' => 'standard', 'fallback_amount' => 999], + default => ['amount' => $amount], + }; + ShippingRate::query()->updateOrCreate(['id' => $this->editingRate?->id, 'zone_id' => $this->rateZoneId], ['name' => trim($data['rateName']), 'type' => $data['rateType'], 'config_json' => $config, 'is_active' => (bool) $data['rateActive']]); + $this->editingRate = null; + unset($this->zones); + $this->dispatch('modal-close', name: 'rate-form'); + $this->toast('Shipping rate saved'); + } + + public function deleteRate(int $rateId): void + { + $this->authorizeSettings(); + $rate = ShippingRate::query()->whereHas('zone', fn ($query) => $query->withoutGlobalScopes()->where('store_id', $this->currentStore()->id))->findOrFail($rateId); + $rate->delete(); + unset($this->zones); + $this->toast('Shipping rate deleted'); + } + + public function testShippingAddress(): void + { + $this->authorizeSettings(); + $data = $this->validate(['testAddress.country' => ['required', 'string', 'size:2'], 'testAddress.province' => ['nullable', 'string', 'max:100'], 'testAddress.city' => ['nullable', 'string', 'max:100'], 'testAddress.postal_code' => ['nullable', 'string', 'max:20']]); + $calculator = app(ShippingCalculator::class); + $address = ['country_code' => strtoupper($data['testAddress']['country']), 'province_code' => strtoupper($data['testAddress']['province'] ?? ''), 'city' => $data['testAddress']['city'] ?? '', 'postal_code' => $data['testAddress']['postal_code'] ?? '']; + $zone = $calculator->matchingZone($this->currentStore(), $address); + $this->testResult = $zone ? ['zone' => $zone->name, 'rates' => $zone->rates()->where('is_active', true)->get()->map(fn (ShippingRate $rate): array => ['name' => $rate->name, 'amount' => (int) data_get($rate->config_json, 'amount', data_get($rate->config_json, 'ranges.0.amount', data_get($rate->config_json, 'fallback_amount', 0)))])->all()] : ['zone' => null, 'rates' => []]; + } + + #[Computed] + public function zones(): mixed + { + return ShippingZone::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->with('rates')->orderBy('name')->get(); + } + + #[Computed] + public function countries(): array + { + return ['DE' => 'Germany', 'AT' => 'Austria', 'BE' => 'Belgium', 'CH' => 'Switzerland', 'DK' => 'Denmark', 'ES' => 'Spain', 'FR' => 'France', 'GB' => 'United Kingdom', 'IT' => 'Italy', 'NL' => 'Netherlands', 'PL' => 'Poland', 'PT' => 'Portugal', 'US' => 'United States', 'CA' => 'Canada', 'AU' => 'Australia']; + } + + public function render(): View + { + return $this->admin(view('admin.settings.shipping'), 'Shipping', [['label' => 'Settings', 'url' => url('/admin/settings')], ['label' => 'Shipping']]); + } + + private function authorizeSettings(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } + + private function zone(int $id): ShippingZone + { + return ShippingZone::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->findOrFail($id); + } + + private function rate(int $id, int $zoneId): ShippingRate + { + return ShippingRate::query()->where('zone_id', $zoneId)->findOrFail($id); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..8bd5c049 --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,105 @@ + */ + public array $manualRates = []; + + public function mount(): void + { + $this->authorizeSettings(); + $settings = TaxSettings::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->first(); + if ($settings) { + $config = (array) $settings->config_json; + $this->mode = (string) $this->enumValue($settings->mode); + $this->provider = (string) $this->enumValue($settings->provider); + $this->pricesIncludeTax = (bool) $settings->prices_include_tax; + $this->manualRates = array_values((array) data_get($config, 'manual_rates', [])); + $this->providerApiKey = (string) data_get($config, 'api_key', ''); + } + if ($this->manualRates === []) { + $this->manualRates = [['zone_name' => '', 'rate_percentage' => '']]; + } + } + + public function addManualRate(): void + { + $this->authorizeSettings(); + $this->manualRates[] = ['zone_name' => '', 'rate_percentage' => '']; + } + + public function removeManualRate(int $index): void + { + $this->authorizeSettings(); + abort_unless(array_key_exists($index, $this->manualRates), 404); + unset($this->manualRates[$index]); + $this->manualRates = array_values($this->manualRates); + } + + public function save(): void + { + $this->authorizeSettings(); + $data = $this->validate([ + 'mode' => ['required', Rule::in(['manual', 'provider'])], + 'pricesIncludeTax' => ['boolean'], + 'provider' => ['required', Rule::in(['none', 'stripe_tax'])], + 'providerApiKey' => ['nullable', 'string', 'max:500'], + 'manualRates' => ['array', 'max:100'], + 'manualRates.*.zone_name' => ['nullable', 'string', 'max:100'], + 'manualRates.*.rate_percentage' => ['nullable', 'numeric', 'min:0', 'max:100'], + ]); + + foreach ($data['manualRates'] as $index => $rate) { + if (filled($rate['zone_name']) xor filled($rate['rate_percentage'])) { + $field = filled($rate['zone_name']) ? "manualRates.{$index}.rate_percentage" : "manualRates.{$index}.zone_name"; + $this->addError($field, 'Complete both fields for this manual rate.'); + + return; + } + } + + $rates = collect($data['manualRates'])->filter(fn (array $rate): bool => filled($rate['zone_name']) || filled($rate['rate_percentage']))->map(fn (array $rate): array => [ + 'zone_name' => trim((string) $rate['zone_name']), + 'rate_percentage' => round((float) $rate['rate_percentage'], 4), + ])->values()->all(); + + $existing = TaxSettings::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->first(); + $config = (array) ($existing?->config_json ?? []); + $config['manual_rates'] = $rates; + $config['api_key'] = $data['mode'] === 'provider' ? $data['providerApiKey'] : null; + + TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $this->currentStore()->id], [ + 'mode' => $data['mode'], + 'provider' => $data['mode'] === 'provider' ? $data['provider'] : 'none', + 'prices_include_tax' => (bool) $data['pricesIncludeTax'], + 'config_json' => $config, + ]); + $this->toast('Tax settings saved'); + } + + public function render(): View + { + return $this->admin(view('admin.settings.taxes'), 'Tax Settings', [['label' => 'Settings', 'url' => url('/admin/settings')], ['label' => 'Taxes']]); + } + + private function authorizeSettings(): void + { + $this->requireRoles(['owner', 'admin']); + $this->authorizeAction('update', $this->currentStore()); + } +} diff --git a/app/Livewire/Admin/Themes/Editor.php b/app/Livewire/Admin/Themes/Editor.php new file mode 100644 index 00000000..1a64f3bf --- /dev/null +++ b/app/Livewire/Admin/Themes/Editor.php @@ -0,0 +1,200 @@ +>}> */ + public array $sections = []; + + public ?string $selectedSection = null; + + /** @var array */ + public array $sectionSettings = []; + + /** @var array */ + public array $allSettings = []; + + public string $previewUrl = '/'; + + public function mount(Theme $theme): void + { + $this->authorizeThemes(); + abort_unless((int) $theme->store_id === (int) $this->currentStore()->id, 404); + $this->authorizeAction('view', $theme); + $this->theme = $theme->load('settings'); + session()->forget($this->previewSessionKey()); + $this->sections = $this->schema(); + $this->allSettings = array_replace_recursive($this->defaults(), (array) ($theme->settings?->settings_json ?? [])); + $this->previewUrl = url('/').'?theme_preview='.$theme->id; + $this->selectSection((string) array_key_first($this->sections)); + } + + public function selectSection(string $sectionKey): void + { + $this->authorizeThemes(); + $this->authorizeAction('view', $this->theme); + abort_unless(array_key_exists($sectionKey, $this->sections), 404); + $this->selectedSection = $sectionKey; + $this->sectionSettings = []; + foreach ($this->sections[$sectionKey]['fields'] as $key => $definition) { + $this->sectionSettings[$key] = data_get($this->allSettings, $key, $definition['default'] ?? null); + } + } + + public function updateSetting(string $key, mixed $value): void + { + $this->authorizeThemes(); + $this->authorizeAction('update', $this->theme); + abort_unless($this->selectedSection && array_key_exists($key, $this->sections[$this->selectedSection]['fields']), 422); + if ((str_ends_with($key, '.url') || str_ends_with($key, '_url')) && ! SafeUrl::isAllowed($value)) { + abort(422, 'Links must be relative, HTTP, or HTTPS URLs.'); + } + if (str_ends_with($key, '.url') || str_ends_with($key, '_url')) { + $value = SafeUrl::normalize($value); + } + data_set($this->allSettings, $key, $value); + $this->sectionSettings[$key] = $value; + $this->refreshPreview(); + } + + public function save(): void + { + $this->authorizeThemes(); + $this->authorizeAction('update', $this->theme); + $this->mergeSelectedSettings(); + ThemeSettings::query()->updateOrCreate(['theme_id' => $this->theme->id], ['settings_json' => $this->allSettings]); + Cache::forget('theme-settings:'.$this->currentStore()->id); + session()->forget($this->previewSessionKey()); + $this->theme->load('settings'); + $this->toast('Theme settings saved'); + } + + public function publish(): void + { + $this->authorizeThemes(); + $this->authorizeAction('update', $this->theme); + $this->mergeSelectedSettings(); + + DB::transaction(function (): void { + ThemeSettings::query()->updateOrCreate(['theme_id' => $this->theme->id], ['settings_json' => $this->allSettings]); + Theme::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->update(['status' => 'draft', 'published_at' => null]); + $this->theme->update(['status' => 'published', 'published_at' => now()]); + }); + + Cache::forget('theme-settings:'.$this->currentStore()->id); + session()->forget($this->previewSessionKey()); + $this->theme->refresh()->load('settings'); + $this->toast('Theme published'); + } + + public function refreshPreview(): void + { + $this->authorizeThemes(); + $this->authorizeAction('view', $this->theme); + $this->allSettings = SafeUrl::sanitizeThemeSettings($this->allSettings); + session()->put($this->previewSessionKey(), [ + 'user_id' => $this->adminUser()->id, + 'settings' => $this->allSettings, + ]); + $this->dispatch('theme-preview-refresh', url: $this->previewUrl, settings: $this->allSettings); + } + + public function leaveEditor(): mixed + { + $this->authorizeThemes(); + $this->authorizeAction('view', $this->theme); + session()->forget($this->previewSessionKey()); + + return $this->redirect('/admin/themes', navigate: true); + } + + public function render(): View + { + return $this->admin(view('admin.themes.editor'), 'Customize '.$this->theme->name, [ + ['label' => 'Themes', 'url' => url('/admin/themes')], + ['label' => $this->theme->name], + ]); + } + + private function authorizeThemes(): void + { + $this->requireRoles(['owner', 'admin']); + } + + private function previewSessionKey(): string + { + return 'theme_preview.'.$this->theme->id; + } + + private function mergeSelectedSettings(): void + { + if (! $this->selectedSection) { + return; + } + foreach ($this->sectionSettings as $key => $value) { + abort_unless(array_key_exists($key, $this->sections[$this->selectedSection]['fields']), 422); + data_set($this->allSettings, $key, $value); + } + $this->allSettings = SafeUrl::sanitizeThemeSettings($this->allSettings); + } + + /** @return array>}> */ + private function schema(): array + { + return [ + 'colors' => ['label' => 'Colors', 'fields' => [ + 'colors.primary' => ['label' => 'Primary color', 'type' => 'color', 'default' => '#2563eb'], + 'colors.secondary' => ['label' => 'Secondary color', 'type' => 'color', 'default' => '#0f172a'], + 'colors.accent' => ['label' => 'Accent color', 'type' => 'color', 'default' => '#f59e0b'], + 'dark_mode' => ['label' => 'Color scheme', 'type' => 'select', 'default' => 'system', 'options' => [ + 'system' => 'Follow system', 'toggle' => 'Customer toggle', 'light' => 'Always light', 'dark' => 'Always dark', + ]], + ]], + 'announcement' => ['label' => 'Announcement bar', 'fields' => [ + 'announcement.enabled' => ['label' => 'Show announcement', 'type' => 'checkbox', 'default' => false], + 'announcement.text' => ['label' => 'Message', 'type' => 'text', 'default' => 'Free shipping on orders over €50'], + 'announcement.url' => ['label' => 'Link', 'type' => 'text', 'default' => ''], + 'announcement.background' => ['label' => 'Background', 'type' => 'color', 'default' => '#0f172a'], + ]], + 'header' => ['label' => 'Header', 'fields' => [ + 'header.sticky' => ['label' => 'Sticky header', 'type' => 'checkbox', 'default' => true], + 'header.logo_url' => ['label' => 'Logo URL', 'type' => 'text', 'default' => ''], + ]], + 'hero' => ['label' => 'Homepage hero', 'fields' => [ + 'home.hero.enabled' => ['label' => 'Show hero', 'type' => 'checkbox', 'default' => true], + 'home.hero.heading' => ['label' => 'Heading', 'type' => 'text', 'default' => 'Discover something special'], + 'home.hero.subheading' => ['label' => 'Subheading', 'type' => 'textarea', 'default' => 'Thoughtfully selected products, delivered to your door.'], + 'home.hero.cta_label' => ['label' => 'Button label', 'type' => 'text', 'default' => 'Shop now'], + 'home.hero.cta_url' => ['label' => 'Button link', 'type' => 'text', 'default' => '/collections'], + 'home.hero.image_url' => ['label' => 'Image URL', 'type' => 'text', 'default' => ''], + ]], + 'footer' => ['label' => 'Footer', 'fields' => [ + 'footer.description' => ['label' => 'Description', 'type' => 'textarea', 'default' => ''], + ]], + ]; + } + + /** @return array */ + private function defaults(): array + { + $defaults = []; + foreach ($this->schema() as $section) { + foreach ($section['fields'] as $key => $definition) { + data_set($defaults, $key, $definition['default'] ?? null); + } + } + + return $defaults; + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..7950a869 --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,109 @@ +authorizeThemes(); + $this->authorizeAction('viewAny', Theme::class); + } + + public function publishTheme(int $themeId): void + { + $this->authorizeThemes(); + $theme = $this->theme($themeId); + $this->authorizeAction('update', $theme); + + DB::transaction(function () use ($theme): void { + Theme::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->update([ + 'status' => 'draft', + 'published_at' => null, + ]); + $theme->update(['status' => 'published', 'published_at' => now()]); + }); + + Cache::forget('theme-settings:'.$this->currentStore()->id); + unset($this->themes); + $this->toast('Theme published'); + } + + public function duplicateTheme(int $themeId): void + { + $this->authorizeThemes(); + $source = $this->theme($themeId)->load(['settings', 'files']); + $this->authorizeAction('view', $source); + $this->authorizeAction('create', Theme::class); + + DB::transaction(function () use ($source): void { + $copy = Theme::withoutGlobalScopes()->create([ + 'store_id' => $this->currentStore()->id, + 'name' => $source->name.' Copy', + 'version' => $source->version, + 'status' => 'draft', + ]); + $copy->settings()->create(['settings_json' => (array) ($source->settings?->settings_json ?? [])]); + foreach ($source->files as $file) { + $storageKey = "themes/{$this->currentStore()->id}/{$copy->id}/{$file->path}"; + if (Storage::disk('local')->exists($file->storage_key)) { + Storage::disk('local')->copy($file->storage_key, $storageKey); + } + $copy->files()->create([ + 'path' => $file->path, + 'storage_key' => $storageKey, + 'sha256' => $file->sha256, + 'byte_size' => $file->byte_size, + ]); + } + }); + + unset($this->themes); + $this->toast('Theme duplicated'); + } + + public function deleteTheme(int $themeId): void + { + $this->authorizeThemes(); + $theme = $this->theme($themeId); + $this->authorizeAction('delete', $theme); + abort_if($this->enumValue($theme->status) === 'published', 422, 'The published theme cannot be deleted.'); + $theme->delete(); + unset($this->themes); + $this->toast('Theme deleted'); + } + + #[Computed] + public function themes(): mixed + { + return Theme::withoutGlobalScopes() + ->where('store_id', $this->currentStore()->id) + ->with('settings') + ->orderByRaw("case when status = 'published' then 0 else 1 end") + ->orderBy('name') + ->get(); + } + + public function render(): View + { + return $this->admin(view('admin.themes.index'), 'Themes', [['label' => 'Themes']]); + } + + private function authorizeThemes(): void + { + $this->requireRoles(['owner', 'admin']); + } + + private function theme(int $id): Theme + { + return Theme::withoutGlobalScopes()->where('store_id', $this->currentStore()->id)->findOrFail($id); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php index 43b5252b..23c581e1 100644 --- a/app/Livewire/Storefront/Account/Auth/Login.php +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -33,8 +33,9 @@ public function login(): mixed ]); $key = 'customer-login:'.Str::lower($this->email).'|'.request()->ip(); - if (RateLimiter::tooManyAttempts($key, 5)) { - $seconds = RateLimiter::availableIn($key); + $ipKey = 'login-ip:'.request()->ip(); + if (RateLimiter::tooManyAttempts($key, 5) || RateLimiter::tooManyAttempts($ipKey, 5)) { + $seconds = max(RateLimiter::availableIn($key), RateLimiter::availableIn($ipKey)); $this->addError('email', "Too many attempts. Try again in {$seconds} seconds."); return null; @@ -42,12 +43,14 @@ public function login(): mixed if (! Auth::guard('customer')->attempt(['email' => $this->email, 'password' => $this->password])) { RateLimiter::hit($key, 60); + RateLimiter::hit($ipKey, 60); $this->addError('credentials', 'Invalid credentials'); return null; } RateLimiter::clear($key); + RateLimiter::clear($ipKey); request()->session()->regenerate(); $this->mergeGuestCart(); diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php index 8a29f5d1..e7d7f332 100644 --- a/app/Livewire/Storefront/Account/Auth/Register.php +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -4,9 +4,8 @@ use App\Livewire\Storefront\StorefrontComponent; use App\Models\Cart; -use App\Models\Customer; +use App\Services\CustomerService; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rule; use Illuminate\View\View; @@ -40,15 +39,12 @@ public function register(): mixed 'marketingOptIn' => ['boolean'], ], ['password.same' => 'The passwords do not match.']); - $customer = Customer::withoutGlobalScopes()->firstOrNew([ - 'store_id' => $store->getKey(), - 'email' => mb_strtolower($this->email), - ]); - $customer->fill([ + $customer = app(CustomerService::class)->register($store, [ 'name' => $this->name, - 'password_hash' => Hash::make($this->password), + 'email' => $this->email, + 'password' => $this->password, 'marketing_opt_in' => $this->marketingOptIn, - ])->save(); + ]); Auth::guard('customer')->login($customer); request()->session()->regenerate(); diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php index 4e0c4c54..daabb48d 100644 --- a/app/Livewire/Storefront/Checkout/Confirmation.php +++ b/app/Livewire/Storefront/Checkout/Confirmation.php @@ -5,6 +5,7 @@ use App\Livewire\Storefront\StorefrontComponent; use App\Models\Checkout; use App\Models\Order; +use App\Services\OrderStatusLink; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; @@ -14,6 +15,8 @@ class Confirmation extends StorefrontComponent public Order $order; + public string $orderStatusUrl = ''; + public function mount(int|string|Checkout $checkoutId): void { $this->checkout = ($checkoutId instanceof Checkout @@ -31,12 +34,15 @@ public function mount(int|string|Checkout $checkoutId): void 403, ); + $orderId = data_get($this->checkout->totals_json, 'order_id'); + abort_unless(is_numeric($orderId) && (int) $orderId > 0, 404); + $this->order = Order::query() ->where('store_id', $this->currentStore()->getKey()) - ->when(session('last_order_id'), fn ($query, $id) => $query->whereKey($id)) - ->when(! session('last_order_id'), fn ($query) => $query->where('email', $this->checkout->email)->latest('placed_at')) + ->whereKey((int) $orderId) ->with(['lines.product.media', 'payments', 'fulfillments']) ->firstOrFail(); + $this->orderStatusUrl = app(OrderStatusLink::class)->url($this->order); } public function render(): View diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php index f119c98d..b7abc7af 100644 --- a/app/Livewire/Storefront/Checkout/Show.php +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -76,6 +76,7 @@ public function mount(int|string|Checkout $checkoutId): void abort_if($this->status() === 'completed', 409, 'This checkout is already complete.'); abort_if($this->status() === 'expired', 410, 'This checkout has expired.'); abort_if($this->checkout->cart->lines->isEmpty(), 422, 'Your cart is empty.'); + $this->requiresShipping = app(ShippingCalculator::class)->requiresShipping($this->checkout->cart); $this->email = (string) ($this->checkout->email ?: Auth::guard('customer')->user()?->email); $this->discountCode = (string) ($this->checkout->discount_code ?: session('cart_discount_code', '')); @@ -101,6 +102,17 @@ public function saveContact(): void $this->validate(['email' => ['required', 'email:rfc', 'max:255']]); $this->email = mb_strtolower($this->email); $this->checkout->forceFill(['email' => $this->email])->save(); + if (! $this->requiresShipping) { + $service = app(CheckoutService::class); + $service->setAddress($this->checkout, ['email' => $this->email]); + $this->checkout->refresh(); + $service->setShippingMethod($this->checkout, null); + $this->checkout->refresh(); + $this->step = 4; + $this->dispatch('checkout-step-changed', step: 4); + + return; + } $this->step = 2; $this->dispatch('checkout-step-changed', step: 2); } diff --git a/app/Livewire/Storefront/StorefrontComponent.php b/app/Livewire/Storefront/StorefrontComponent.php index 94fa6d5e..4d0172a8 100644 --- a/app/Livewire/Storefront/StorefrontComponent.php +++ b/app/Livewire/Storefront/StorefrontComponent.php @@ -2,9 +2,13 @@ namespace App\Livewire\Storefront; +use App\Models\Collection; use App\Models\NavigationMenu; +use App\Models\Page; +use App\Models\Product; use App\Models\Store; use App\Services\ThemeSettingsService; +use App\Support\SafeUrl; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; @@ -80,7 +84,7 @@ protected function themeSettings(): array ]; } - return array_replace_recursive([ + return SafeUrl::sanitizeThemeSettings(array_replace_recursive([ 'announcement' => ['enabled' => false, 'text' => '', 'url' => null, 'background' => '#18181b'], 'header' => ['sticky' => true, 'logo_url' => null], 'colors' => ['primary' => '#1d4ed8', 'secondary' => '#334155', 'accent' => '#f59e0b'], @@ -97,7 +101,7 @@ protected function themeSettings(): array ], ], 'footer' => ['description' => null, 'social' => []], - ], $raw); + ], $raw)); } /** @return array> */ @@ -113,10 +117,10 @@ private function navigationItems(mixed $menu): array return [ 'label' => $item->label, 'url' => match ($type) { - 'page' => $this->resourceUrl(\App\Models\Page::class, $item->resource_id, '/pages/'), - 'collection' => $this->resourceUrl(\App\Models\Collection::class, $item->resource_id, '/collections/'), - 'product' => $this->resourceUrl(\App\Models\Product::class, $item->resource_id, '/products/'), - default => $item->url ?: '#', + 'page' => $this->resourceUrl(Page::class, $item->resource_id, '/pages/'), + 'collection' => $this->resourceUrl(Collection::class, $item->resource_id, '/collections/'), + 'product' => $this->resourceUrl(Product::class, $item->resource_id, '/products/'), + default => SafeUrl::normalize($item->url) ?: '#', }, 'children' => Arr::wrap(data_get($item, 'children', [])), ]; diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php index cd905d30..32568f59 100644 --- a/app/Models/Fulfillment.php +++ b/app/Models/Fulfillment.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Enums\FulfillmentShipmentStatus; +use App\Support\SafeUrl; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -35,4 +37,12 @@ public function lines(): HasMany { return $this->hasMany(FulfillmentLine::class); } + + protected function trackingUrl(): Attribute + { + return Attribute::make( + get: fn (mixed $value): ?string => SafeUrl::normalize($value), + set: fn (mixed $value): ?string => SafeUrl::normalize($value), + ); + } } diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php index b66e6b52..763135b1 100644 --- a/app/Models/NavigationItem.php +++ b/app/Models/NavigationItem.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Enums\NavigationItemType; +use App\Support\SafeUrl; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -43,4 +45,12 @@ public function product(): BelongsTo { return $this->belongsTo(Product::class, 'resource_id'); } + + protected function url(): Attribute + { + return Attribute::make( + get: fn (mixed $value): ?string => SafeUrl::normalize($value), + set: fn (mixed $value): ?string => SafeUrl::normalize($value), + ); + } } diff --git a/app/Models/Page.php b/app/Models/Page.php index 0aa36fa9..bfc7d6fd 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\SanitizeHtml; use App\Enums\PageStatus; use App\Models\Concerns\BelongsToStore; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -20,4 +21,9 @@ protected function casts(): array 'published_at' => 'datetime', ]; } + + public function setBodyHtmlAttribute(mixed $value): void + { + $this->attributes['body_html'] = app(SanitizeHtml::class)->execute($value === null ? null : (string) $value); + } } diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 8c9cb684..2ff788fe 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -19,6 +19,8 @@ class Payment extends Model 'order_id', 'provider', 'method', 'provider_payment_id', 'status', 'amount', 'currency', 'raw_json_encrypted', ]; + protected $hidden = ['raw_json_encrypted']; + protected function casts(): array { return [ diff --git a/app/Models/Product.php b/app/Models/Product.php index 189efc1b..12930067 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\SanitizeHtml; use App\Enums\ProductStatus; use App\Models\Concerns\BelongsToStore; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -26,6 +27,11 @@ protected function casts(): array ]; } + public function setDescriptionHtmlAttribute(mixed $value): void + { + $this->attributes['description_html'] = app(SanitizeHtml::class)->execute($value === null ? null : (string) $value); + } + public function options(): HasMany { return $this->hasMany(ProductOption::class)->orderBy('position'); diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php index 4d54f104..c04201de 100644 --- a/app/Models/ThemeSettings.php +++ b/app/Models/ThemeSettings.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\SafeUrl; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -18,9 +20,19 @@ class ThemeSettings extends Model protected $fillable = ['theme_id', 'settings_json']; - protected function casts(): array + protected function settingsJson(): Attribute { - return ['settings_json' => 'array']; + return Attribute::make( + get: function (mixed $value): array { + $settings = is_array($value) ? $value : json_decode((string) $value, true); + + return SafeUrl::sanitizeThemeSettings(is_array($settings) ? $settings : []); + }, + set: fn (mixed $value): string => json_encode( + SafeUrl::sanitizeThemeSettings(is_array($value) ? $value : []), + JSON_THROW_ON_ERROR, + ), + ); } public function theme(): BelongsTo diff --git a/app/Models/User.php b/app/Models/User.php index 51dedfa2..6da50f16 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,8 @@ use App\Enums\StoreUserRole; use App\Enums\UserStatus; +use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -13,9 +15,9 @@ use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmailContract { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** diff --git a/app/Notifications/OrderLifecycleNotification.php b/app/Notifications/OrderLifecycleNotification.php index e2432358..0dceebf0 100644 --- a/app/Notifications/OrderLifecycleNotification.php +++ b/app/Notifications/OrderLifecycleNotification.php @@ -5,6 +5,7 @@ use App\Models\Fulfillment; use App\Models\Order; use App\Models\Refund; +use App\Services\OrderStatusLink; use Illuminate\Bus\Queueable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -47,7 +48,7 @@ public function toMail(object $notifiable): MailMessage default => $message->line("Order {$this->order->order_number} was updated."), }; - return $message->action('Visit the shop', url('/account/orders')) + return $message->action('View order status', app(OrderStatusLink::class)->url($this->order)) ->line('Thank you for shopping with us.'); } diff --git a/app/Observers/StoreDomainObserver.php b/app/Observers/StoreDomainObserver.php new file mode 100644 index 00000000..ec28ef32 --- /dev/null +++ b/app/Observers/StoreDomainObserver.php @@ -0,0 +1,23 @@ +hostname)); + $original = $domain->getRawOriginal('hostname'); + if (is_string($original) && $original !== $domain->hostname) { + Cache::forget('store_domain:'.mb_strtolower($original)); + } + } + + public function deleted(StoreDomain $domain): void + { + Cache::forget('store_domain:'.mb_strtolower((string) $domain->hostname)); + } +} diff --git a/app/Observers/ThemeCacheObserver.php b/app/Observers/ThemeCacheObserver.php new file mode 100644 index 00000000..b67bde6c --- /dev/null +++ b/app/Observers/ThemeCacheObserver.php @@ -0,0 +1,30 @@ +forget($model); + } + + public function deleted(Theme|ThemeSettings $model): void + { + $this->forget($model); + } + + private function forget(Theme|ThemeSettings $model): void + { + $storeId = $model instanceof Theme + ? $model->store_id + : Theme::withoutGlobalScopes()->whereKey($model->theme_id)->value('store_id'); + if ($storeId !== null) { + Cache::forget("theme-settings:{$storeId}"); + } + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php index 07f4d2d3..3fb52bf1 100644 --- a/app/Policies/StorePolicy.php +++ b/app/Policies/StorePolicy.php @@ -35,6 +35,11 @@ public function viewSettings(User $user, Store $store): bool return $this->hasRole($user, $store, ['owner', 'admin']); } + public function viewAnalytics(User $user, Store $store): bool + { + return $this->hasRole($user, $store, ['owner', 'admin', 'staff']); + } + public function updateSettings(User $user, Store $store): bool { return $this->viewSettings($user, $store); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 231e9b26..f9dbfba6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Auth\CustomerUserProvider; +use App\Contracts\DnsResolver; use App\Contracts\PaymentProvider; use App\Contracts\TaxProvider; use App\Enums\StoreUserRole; @@ -19,13 +20,20 @@ use App\Models\Refund; use App\Models\ShippingZone; use App\Models\Store; +use App\Models\StoreDomain; use App\Models\TaxSettings; use App\Models\Theme; +use App\Models\ThemeSettings; use App\Models\User; use App\Observers\AuditableObserver; use App\Observers\ProductMediaObserver; use App\Observers\ProductObserver; +use App\Observers\StoreDomainObserver; +use App\Observers\ThemeCacheObserver; use App\Services\AuditLogger; +use App\Services\CodeQuality\Contracts\AiReviewer; +use App\Services\CodeQuality\Runners\CodexAiRunner; +use App\Services\NativeDnsResolver; use App\Services\Payments\MockPaymentProvider; use App\Services\Tax\ManualTaxProvider; use Carbon\CarbonImmutable; @@ -50,6 +58,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->bind(AiReviewer::class, CodexAiRunner::class); + $this->app->bind(DnsResolver::class, NativeDnsResolver::class); $this->app->singleton(PaymentProvider::class, MockPaymentProvider::class); $this->app->singleton(TaxProvider::class, ManualTaxProvider::class); } @@ -65,6 +75,9 @@ public function boot(): void $this->configureGates(); Product::observe(ProductObserver::class); ProductMedia::observe(ProductMediaObserver::class); + StoreDomain::observe(StoreDomainObserver::class); + Theme::observe(ThemeCacheObserver::class); + ThemeSettings::observe(ThemeCacheObserver::class); foreach ([ Product::class, Collection::class, @@ -83,9 +96,10 @@ public function boot(): void Event::subscribe(ShopEventSubscriber::class); $this->configureAuditEvents(); ResetPassword::createUrlUsing(function ($notifiable, string $token): string { - $path = $notifiable instanceof \App\Models\Customer ? '/reset-password/' : '/admin/reset-password/'; + $path = $notifiable instanceof Customer ? '/reset-password/' : '/admin/reset-password/'; + $root = rtrim((string) config('app.url'), '/'); - return url($path.$token).'?email='.urlencode($notifiable->getEmailForPasswordReset()); + return $root.$path.rawurlencode($token).'?email='.rawurlencode($notifiable->getEmailForPasswordReset()); }); if (config('database.default') === 'sqlite') { @@ -129,7 +143,10 @@ private function configureAuthentication(): void private function configureRateLimits(): void { - RateLimiter::for('login', fn ($request) => Limit::perMinute(5)->by($request->ip().'|'.mb_strtolower((string) $request->input('email')))); + RateLimiter::for('login', fn ($request): array => [ + Limit::perMinute(5)->by('login-ip:'.$request->ip()), + Limit::perMinute(5)->by('login-identity:'.$request->ip().'|'.mb_strtolower((string) $request->input('email'))), + ]); RateLimiter::for('api.admin', fn ($request) => Limit::perMinute(60)->by((string) ($request->user()?->getAuthIdentifier() ?? $request->ip()))); RateLimiter::for('api.storefront', fn ($request) => Limit::perMinute(120)->by($request->ip())); RateLimiter::for('checkout', fn ($request) => Limit::perMinute(10)->by($request->session()->getId())); @@ -204,6 +221,6 @@ private function currentStoreId(): ?int $store = app('current_store'); - return (int) ($store instanceof \App\Models\Store ? $store->getKey() : $store); + return (int) ($store instanceof Store ? $store->getKey() : $store); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 44e57aa0..64d3e014 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -4,8 +4,12 @@ use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\ResetUserPassword; +use App\Enums\UserStatus; +use App\Models\User; +use BackedEnum; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; @@ -38,6 +42,16 @@ private function configureActions(): void { Fortify::resetUserPasswordsUsing(ResetUserPassword::class); Fortify::createUsersUsing(CreateNewUser::class); + Fortify::authenticateUsing(function (Request $request): ?User { + $user = User::query()->where('email', mb_strtolower((string) $request->input('email')))->first(); + $status = $user?->status instanceof BackedEnum ? $user->status->value : $user?->status; + + return $user !== null + && $status === UserStatus::Active->value + && Hash::check((string) $request->input('password'), $user->getAuthPassword()) + ? $user + : null; + }); } /** @@ -66,7 +80,10 @@ private function configureRateLimiting(): void RateLimiter::for('login', function (Request $request) { $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); - return Limit::perMinute(5)->by($throttleKey); + return [ + Limit::perMinute(5)->by('login-ip:'.$request->ip()), + Limit::perMinute(5)->by('login-identity:'.$throttleKey), + ]; }); } } diff --git a/app/Services/CartService.php b/app/Services/CartService.php index 4738363c..3909d7ed 100644 --- a/app/Services/CartService.php +++ b/app/Services/CartService.php @@ -4,6 +4,7 @@ use App\Exceptions\CartVersionMismatchException; use App\Exceptions\DomainException; +use App\Exceptions\InsufficientInventoryException; use App\Models\Cart; use App\Models\CartLine; use App\Models\Customer; @@ -16,12 +17,12 @@ final class CartService { public function __construct(private readonly InventoryService $inventory) {} - public function create(Store $store, ?Customer $customer = null): Cart + public function create(Store $store, ?Customer $customer = null, ?string $currency = null): Cart { return Cart::withoutGlobalScopes()->create([ 'store_id' => $store->id, 'customer_id' => $customer?->id, - 'currency' => $store->default_currency, + 'currency' => $currency ?? $store->default_currency, 'cart_version' => 1, 'status' => 'active', ]); @@ -62,6 +63,7 @@ public function getOrCreateForSession(Store $store, ?Customer $customer = null): public function addLine(Cart $cart, int|ProductVariant $variant, int $quantity = 1, ?int $expectedVersion = null): CartLine { + $this->assertMutable($cart); $this->assertVersion($cart, $expectedVersion); $this->positive($quantity); $variant = $variant instanceof ProductVariant @@ -80,18 +82,22 @@ public function addLine(Cart $cart, int|ProductVariant $variant, int $quantity = throw new DomainException('This product is not available.'); } - return DB::transaction(function () use ($cart, $variant, $quantity): CartLine { - $line = CartLine::query()->where('cart_id', $cart->id)->where('variant_id', $variant->id)->first(); + return DB::transaction(function () use ($cart, $variant, $quantity, $expectedVersion): CartLine { + $lockedCart = Cart::withoutGlobalScopes()->lockForUpdate()->findOrFail($cart->id); + $this->assertMutable($lockedCart); + $this->assertVersion($lockedCart, $expectedVersion); + $line = CartLine::query()->where('cart_id', $lockedCart->id)->where('variant_id', $variant->id)->first(); $newQuantity = $quantity + ($line?->quantity ?? 0); if ($variant->inventoryItem !== null && ! $this->inventory->checkAvailability($variant->inventoryItem, $newQuantity)) { - throw new \App\Exceptions\InsufficientInventoryException('The requested quantity is not available.'); + throw new InsufficientInventoryException('The requested quantity is not available.'); } $amounts = $this->amounts((int) $variant->price_amount, $newQuantity); - $line ??= new CartLine(['cart_id' => $cart->id, 'variant_id' => $variant->id]); + $line ??= new CartLine(['cart_id' => $lockedCart->id, 'variant_id' => $variant->id]); $line->fill(['quantity' => $newQuantity, ...$amounts])->save(); - $this->bumpVersion($cart); + $this->bumpVersion($lockedCart); + $cart->setRawAttributes($lockedCart->getAttributes(), true); return $line->refresh(); }); @@ -99,6 +105,7 @@ public function addLine(Cart $cart, int|ProductVariant $variant, int $quantity = public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): CartLine { + $this->assertMutable($cart); $this->assertVersion($cart, $expectedVersion); if ($quantity === 0) { $this->removeLine($cart, $lineId, $expectedVersion); @@ -107,15 +114,19 @@ public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int } $this->positive($quantity); - return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine { - $line = CartLine::query()->where('cart_id', $cart->id)->with('variant.inventoryItem')->findOrFail($lineId); + return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): CartLine { + $lockedCart = Cart::withoutGlobalScopes()->lockForUpdate()->findOrFail($cart->id); + $this->assertMutable($lockedCart); + $this->assertVersion($lockedCart, $expectedVersion); + $line = CartLine::query()->where('cart_id', $lockedCart->id)->with('variant.inventoryItem')->findOrFail($lineId); if ($line->variant->inventoryItem !== null && ! $this->inventory->checkAvailability($line->variant->inventoryItem, $quantity)) { - throw new \App\Exceptions\InsufficientInventoryException('The requested quantity is not available.'); + throw new InsufficientInventoryException('The requested quantity is not available.'); } $line->fill(['quantity' => $quantity, ...$this->amounts((int) $line->variant->price_amount, $quantity)])->save(); - $this->bumpVersion($cart); + $this->bumpVersion($lockedCart); + $cart->setRawAttributes($lockedCart->getAttributes(), true); return $line->refresh(); }); @@ -123,10 +134,15 @@ public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void { + $this->assertMutable($cart); $this->assertVersion($cart, $expectedVersion); - DB::transaction(function () use ($cart, $lineId): void { - CartLine::query()->where('cart_id', $cart->id)->findOrFail($lineId)->delete(); - $this->bumpVersion($cart); + DB::transaction(function () use ($cart, $lineId, $expectedVersion): void { + $lockedCart = Cart::withoutGlobalScopes()->lockForUpdate()->findOrFail($cart->id); + $this->assertMutable($lockedCart); + $this->assertVersion($lockedCart, $expectedVersion); + CartLine::query()->where('cart_id', $lockedCart->id)->findOrFail($lineId)->delete(); + $this->bumpVersion($lockedCart); + $cart->setRawAttributes($lockedCart->getAttributes(), true); }); } @@ -137,13 +153,30 @@ public function mergeOnLogin(Cart $guest, Cart $customer): Cart } return DB::transaction(function () use ($guest, $customer): Cart { - $guest->load('lines'); + $guest->load('lines.variant.inventoryItem'); foreach ($guest->lines as $line) { $target = CartLine::query()->where('cart_id', $customer->id)->where('variant_id', $line->variant_id)->first(); if ($target !== null) { - $target->quantity += $line->quantity; + $target->quantity = max((int) $target->quantity, (int) $line->quantity); + if ($line->variant?->inventoryItem !== null && ! $this->inventory->checkAvailability($line->variant->inventoryItem, (int) $target->quantity)) { + $target->quantity = max(0, (int) $line->variant->inventoryItem->quantity_on_hand - (int) $line->variant->inventoryItem->quantity_reserved); + } + if ($target->quantity === 0) { + $target->delete(); + + continue; + } $target->fill($this->amounts((int) $target->unit_price_amount, (int) $target->quantity))->save(); } else { + if ($line->variant?->inventoryItem !== null && ! $this->inventory->checkAvailability($line->variant->inventoryItem, (int) $line->quantity)) { + $line->quantity = max(0, (int) $line->variant->inventoryItem->quantity_on_hand - (int) $line->variant->inventoryItem->quantity_reserved); + } + if ($line->quantity === 0) { + $line->delete(); + + continue; + } + $line->fill($this->amounts((int) $line->unit_price_amount, (int) $line->quantity)); $line->cart_id = $customer->id; $line->save(); } @@ -164,6 +197,18 @@ public function assertVersion(Cart $cart, ?int $expectedVersion): void } } + private function assertMutable(Cart $cart): void + { + $status = $this->enumValue($cart->status); + $hasReservedCheckout = $cart->checkouts() + ->whereIn('status', ['payment_selected', 'completed']) + ->exists(); + + if ($status !== 'active' || $hasReservedCheckout) { + throw new DomainException('This cart can no longer be changed.'); + } + } + /** @return array{unit_price_amount: int, line_subtotal_amount: int, line_discount_amount: int, line_total_amount: int} */ private function amounts(int $unitPrice, int $quantity): array { diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php index 409988b1..c6a087e1 100644 --- a/app/Services/CheckoutService.php +++ b/app/Services/CheckoutService.php @@ -13,6 +13,7 @@ use App\Models\Cart; use App\Models\Checkout; use App\Models\Order; +use App\ValueObjects\PaymentResult; use BackedEnum; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -27,43 +28,83 @@ public function __construct( private readonly OrderService $orders, ) {} - public function create(Cart $cart): Checkout + public function create(Cart $cart, ?string $email = null): Checkout { - if ($cart->lines()->count() === 0) { - throw new InvalidCheckoutTransitionException('An empty cart cannot be checked out.'); - } + return DB::transaction(function () use ($cart, $email): Checkout { + $cart = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($cart->id); + if ($this->stateValue($cart->status) !== 'active' || $cart->lines->isEmpty()) { + throw new InvalidCheckoutTransitionException('Only a non-empty active cart can be checked out.'); + } - return Checkout::withoutGlobalScopes()->create([ - 'store_id' => $cart->store_id, - 'cart_id' => $cart->id, - 'customer_id' => $cart->customer_id, - 'status' => 'started', - 'expires_at' => now()->addDay(), - ]); + $existing = Checkout::withoutGlobalScopes() + ->where('cart_id', $cart->id) + ->whereNotIn('status', ['completed', 'expired']) + ->latest('id') + ->first(); + if ($existing !== null && $existing->expires_at?->isFuture()) { + if ($email !== null && $this->state($existing) === 'started') { + $existing->update(['email' => mb_strtolower($email), 'expires_at' => now()->addDay()]); + } + $this->pricing->calculate($existing); + + return $existing->refresh(); + } + if ($existing !== null) { + $this->expireCheckout($existing); + } + + $checkout = Checkout::withoutGlobalScopes()->create([ + 'store_id' => $cart->store_id, + 'cart_id' => $cart->id, + 'customer_id' => $cart->customer_id, + 'status' => 'started', + 'email' => $email === null ? null : mb_strtolower($email), + 'expires_at' => now()->addDay(), + ]); + $this->pricing->calculate($checkout); + + return $checkout->refresh(); + }); } /** @param array $data */ public function setAddress(Checkout $checkout, array $data): Checkout { $this->assertState($checkout, ['started', 'addressed', 'shipping_selected']); + $checkout->loadMissing('cart.lines.variant'); + $requiresShipping = $this->shipping->requiresShipping($checkout->cart); $address = (array) ($data['shipping_address'] ?? $data['address'] ?? $data); $email = (string) ($data['email'] ?? $checkout->email ?? ''); - Validator::make(['email' => $email, ...$address], [ + $rules = [ 'email' => ['required', 'email'], - 'first_name' => ['required', 'string', 'max:100'], - 'last_name' => ['required', 'string', 'max:100'], - 'address1' => ['required', 'string', 'max:255'], - 'city' => ['required', 'string', 'max:100'], - 'country_code' => ['required_without:country', 'nullable', 'string', 'size:2'], - 'country' => ['required_without:country_code', 'nullable', 'string', 'size:2'], - 'postal_code' => ['required_without:zip', 'nullable', 'string', 'max:20'], - 'zip' => ['required_without:postal_code', 'nullable', 'string', 'max:20'], - ])->validate(); + ]; + if ($requiresShipping) { + $rules += [ + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'address1' => ['required', 'string', 'max:500'], + 'city' => ['required', 'string', 'max:255'], + 'country_code' => ['required', 'string', 'size:2'], + 'postal_code' => ['required', 'string', 'max:20'], + ]; + } + Validator::make(['email' => $email, ...$address], $rules)->validate(); + + if ($requiresShipping) { + $checkout->loadMissing('store'); + if ($this->shipping->getAvailableRates($checkout->store, $address, $checkout->cart)->isEmpty()) { + throw new ShippingUnavailableException('No shipping method is available for this address.'); + } + } + + $billing = (bool) ($data['use_shipping_as_billing'] ?? true) + ? $address + : (array) ($data['billing_address'] ?? []); $checkout->fill([ 'email' => mb_strtolower($email), - 'shipping_address_json' => $address, - 'billing_address_json' => (array) ($data['billing_address'] ?? $address), + 'shipping_address_json' => $requiresShipping ? $address : null, + 'billing_address_json' => $billing === [] ? null : $billing, 'shipping_method_id' => null, 'status' => 'addressed', ])->save(); @@ -127,27 +168,41 @@ public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $pa /** @param array $paymentDetails */ public function completeCheckout(Checkout $checkout, array $paymentDetails = []): Order { - if ($this->state($checkout) === 'completed') { - $id = (int) data_get($checkout->totals_json, 'order_id'); + $outcome = DB::transaction(function () use ($checkout, $paymentDetails): Order|\App\ValueObjects\PaymentResult { + $checkout = Checkout::withoutGlobalScopes()->lockForUpdate()->with('cart')->findOrFail($checkout->id); + if ($this->state($checkout) === 'completed') { + $id = (int) data_get($checkout->totals_json, 'order_id'); - return Order::withoutGlobalScopes()->findOrFail($id); - } - $this->assertState($checkout, ['payment_selected']); - $method = $checkout->payment_method instanceof PaymentMethod - ? $checkout->payment_method - : PaymentMethod::from((string) $checkout->payment_method); - $result = $this->payments->charge($checkout, $method, $paymentDetails); + return Order::withoutGlobalScopes()->findOrFail($id); + } + if ($checkout->expires_at?->isPast()) { + $this->expireCheckout($checkout); + throw new InvalidCheckoutTransitionException('This checkout has expired.'); + } + $this->assertState($checkout, ['payment_selected']); + $method = $checkout->payment_method instanceof PaymentMethod + ? $checkout->payment_method + : PaymentMethod::from((string) $checkout->payment_method); + $result = $this->payments->charge($checkout, $method, $paymentDetails); - if (! $result->success) { - $this->releaseReservations($checkout); - $checkout->update(['status' => 'shipping_selected', 'payment_method' => null]); - throw new PaymentFailedException($result->errorCode ?? 'payment_failed', $result->errorMessage); - } + if (! $result->success) { + $this->releaseReservations($checkout); + $checkout->update(['status' => 'shipping_selected', 'payment_method' => null]); + + return $result; + } - $order = $this->orders->createFromCheckout($checkout, $result); - event(new CheckoutCompleted($checkout, $order)); + $order = $this->orders->createFromCheckout($checkout, $result); + event(new CheckoutCompleted($checkout, $order)); - return $order; + return $order; + }); + + if ($outcome instanceof PaymentResult) { + throw new PaymentFailedException($outcome->errorCode ?? 'payment_failed', $outcome->errorMessage); + } + + return $outcome; } public function expireCheckout(Checkout $checkout): void @@ -185,6 +240,11 @@ private function assertState(Checkout $checkout, array $states): void private function state(Checkout $checkout): string { - return $checkout->status instanceof BackedEnum ? (string) $checkout->status->value : (string) $checkout->status; + return $this->stateValue($checkout->status); + } + + private function stateValue(mixed $status): string + { + return $status instanceof BackedEnum ? (string) $status->value : (string) $status; } } diff --git a/app/Services/CodeQuality/AiReviewRequest.php b/app/Services/CodeQuality/AiReviewRequest.php new file mode 100644 index 00000000..d898adf1 --- /dev/null +++ b/app/Services/CodeQuality/AiReviewRequest.php @@ -0,0 +1,24 @@ + $files + * @param list $deterministicFindings + */ + public function __construct( + public readonly string $runId, + public readonly string $checkId, + public readonly string $checkName, + public readonly string $basePath, + public readonly array $files, + public readonly string $checklistExcerpt, + public readonly array $deterministicFindings, + public readonly string $model, + public readonly int $timeoutSeconds, + ) {} +} diff --git a/app/Services/CodeQuality/AiReviewResult.php b/app/Services/CodeQuality/AiReviewResult.php new file mode 100644 index 00000000..ee40a63b --- /dev/null +++ b/app/Services/CodeQuality/AiReviewResult.php @@ -0,0 +1,16 @@ + $findings + */ + public function __construct( + public readonly array $findings, + public readonly ?string $rawOutput = null, + ) {} +} diff --git a/app/Services/CodeQuality/CheckContext.php b/app/Services/CodeQuality/CheckContext.php new file mode 100644 index 00000000..664ba236 --- /dev/null +++ b/app/Services/CodeQuality/CheckContext.php @@ -0,0 +1,36 @@ + $selectedChecks + * @param list $deterministicFindings + */ + public function __construct( + public readonly string $runId, + public readonly array $selectedChecks, + public readonly bool $aiEnabled, + public readonly ?string $aiModel, + public readonly int $checkTimeoutSeconds, + public readonly array $deterministicFindings = [], + ) {} + + /** + * @param list $deterministicFindings + */ + public function withDeterministicFindings(array $deterministicFindings): self + { + return new self( + runId: $this->runId, + selectedChecks: $this->selectedChecks, + aiEnabled: $this->aiEnabled, + aiModel: $this->aiModel, + checkTimeoutSeconds: $this->checkTimeoutSeconds, + deterministicFindings: $deterministicFindings, + ); + } +} diff --git a/app/Services/CodeQuality/CheckRegistry.php b/app/Services/CodeQuality/CheckRegistry.php new file mode 100644 index 00000000..fd825f6e --- /dev/null +++ b/app/Services/CodeQuality/CheckRegistry.php @@ -0,0 +1,775 @@ + + */ + public function expand(array $requestedChecks, bool $aiEnabled, bool $noAi): array + { + $requestedChecks = $this->normalizeRequestedChecks($requestedChecks); + $requestedChecks = $requestedChecks === [] ? (array) config('code_quality.defaults.checks', ['deterministic']) : $requestedChecks; + $expanded = []; + + foreach ($requestedChecks as $requestedCheck) { + if ($requestedCheck === 'all') { + $expanded = [ + ...$expanded, + ...self::DETERMINISTIC_IDS, + ...($aiEnabled ? self::AI_IDS : []), + ]; + + continue; + } + + if (array_key_exists($requestedCheck, $this->groups())) { + $expanded = [...$expanded, ...$this->groups()[$requestedCheck]]; + + continue; + } + + if (! in_array($requestedCheck, [...self::DETERMINISTIC_IDS, ...self::AI_IDS], true)) { + throw CodeQualityException::invalidInput("Unknown code-quality check or group: {$requestedCheck}"); + } + + $expanded[] = $requestedCheck; + } + + if ($noAi) { + $expanded = array_values(array_filter( + $expanded, + fn (string $checkId): bool => ! Str::startsWith($checkId, 'ai.'), + )); + } + + return array_values(array_unique($expanded)); + } + + /** + * @param list $ids + * @return list + */ + public function checksFor(array $ids): array + { + $checks = $this->all(); + + return array_values(array_map( + fn (string $id): CodeQualityCheck => $checks[$id], + $ids, + )); + } + + /** + * @return array + */ + public function all(): array + { + return [ + 'deterministic.tenancy' => new PatternCodeQualityCheck( + id: 'deterministic.tenancy', + groups: ['deterministic', 'security', 'laravel'], + areas: ['php_backend'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanTenantModels($files, $check), + ), + 'deterministic.secrets' => new PatternCodeQualityCheck( + id: 'deterministic.secrets', + groups: ['deterministic', 'security', 'laravel', 'frontend', 'worker'], + areas: ['php_backend', 'php_tests', 'frontend', 'worker'], + patterns: [[ + 'regex' => '/(?:Log::(?:debug|info|notice|warning|error|critical|alert)|Inertia::render|console\.(?:log|debug|error))\([^;\n]*(password|token|secret|api_?key|credential|authorization|cookie)/i', + 'severity' => 'error', + 'confidence' => 'medium', + 'category' => 'security', + 'title' => 'Secret-like value is passed to an unsafe output sink', + 'description' => 'Risky sinks must not receive credential-like values unless they are redacted first.', + 'recommendation' => 'Pass only redacted fields or remove the secret-like value from the output payload.', + 'redact_evidence' => true, + 'evidence' => 'A secret-like identifier is passed to a logging, rendering, or console sink.', + ]], + ), + 'deterministic.llm-boundaries' => new PatternCodeQualityCheck( + id: 'deterministic.llm-boundaries', + groups: ['deterministic', 'stability', 'worker'], + areas: ['php_backend', 'worker', 'frontend'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanHardcodedModelStrings($files, $check), + ), + 'deterministic.ai-free-boundaries' => new PatternCodeQualityCheck( + id: 'deterministic.ai-free-boundaries', + groups: ['deterministic', 'stability', 'security'], + areas: ['php_backend', 'php_tests', 'worker', 'frontend'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanAiFreeBoundaryReferences($files, $check), + ), + 'deterministic.request-validation' => new PatternCodeQualityCheck( + id: 'deterministic.request-validation', + groups: ['deterministic', 'security', 'laravel'], + areas: ['php_backend'], + patterns: [[ + 'regex' => '/\$request\s*->\s*all\s*\(/', + 'severity' => 'error', + 'confidence' => 'high', + 'category' => 'validation', + 'title' => 'Request payload is mass-read with all()', + 'description' => 'State-changing code should consume validated input, not the full request payload.', + 'recommendation' => 'Use a Form Request and $request->validated(), or explicitly validate before reading input.', + ]], + ), + 'deterministic.authorization' => new PatternCodeQualityCheck( + id: 'deterministic.authorization', + groups: ['deterministic', 'security', 'laravel'], + areas: ['php_backend'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanControllerAuthorization($files, $check), + ), + 'deterministic.config-env' => new PatternCodeQualityCheck( + id: 'deterministic.config-env', + groups: ['deterministic', 'laravel', 'stability'], + areas: ['php_backend', 'php_tests'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanEnvOutsideConfig($files, $check), + ), + 'deterministic.http-client' => new PatternCodeQualityCheck( + id: 'deterministic.http-client', + groups: ['deterministic', 'stability', 'laravel'], + areas: ['php_backend'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanHttpTimeouts($files, $check), + ), + 'deterministic.queue-stability' => new PatternCodeQualityCheck( + id: 'deterministic.queue-stability', + groups: ['deterministic', 'stability', 'laravel'], + areas: ['php_backend'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanQueueJobs($files, $check), + ), + 'deterministic.migrations' => new PatternCodeQualityCheck( + id: 'deterministic.migrations', + groups: ['deterministic', 'laravel', 'stability'], + areas: ['php_backend'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanMigrations($files, $check), + ), + 'deterministic.frontend-routes' => new PatternCodeQualityCheck( + id: 'deterministic.frontend-routes', + groups: ['deterministic', 'frontend'], + areas: ['frontend', 'php_tests'], + patterns: [[ + 'regex' => '/(?:router\.(?:get|post|put|patch|delete|visit)|fetch)\(\s*[\'"]\/(?:app|api|internal)\b|href=[\'"]\/app\//', + 'severity' => 'warning', + 'confidence' => 'medium', + 'category' => 'frontend-routing', + 'title' => 'Frontend route appears hardcoded', + 'description' => 'Frontend calls into Laravel should prefer typed or named route helpers when the application provides them.', + 'recommendation' => 'Use the project route helper, generated action helper, or named route helper instead of a hardcoded application URL.', + ]], + ), + 'deterministic.dependencies' => new PatternCodeQualityCheck( + id: 'deterministic.dependencies', + groups: ['deterministic', 'security', 'stability'], + areas: ['config'], + scanner: fn (CodeQualityScope $scope, array $files, CheckContext $context, PatternCodeQualityCheck $check): array => $this->scanDependencyFiles($files, $check), + ), + 'ai.architecture' => $this->aiCheck('ai.architecture', 'architecture review', ['ai', 'laravel', 'worker'], ['php_backend', 'frontend', 'worker', 'check_spec'], 'Architecture boundaries, misplaced domain decisions, and unjustified abstractions.'), + 'ai.clean-code' => $this->aiCheck('ai.clean-code', 'clean code review', ['ai', 'laravel', 'frontend', 'worker'], ['php_backend', 'frontend', 'worker', 'php_tests'], 'Names, abstraction levels, duplication, and reviewability.'), + 'ai.security-review' => $this->aiCheck('ai.security-review', 'security review', ['ai', 'security'], ['php_backend', 'frontend', 'worker'], 'Access control, secret exposure, SSRF, and trust-boundary issues.'), + 'ai.stability' => $this->aiCheck('ai.stability', 'stability review', ['ai', 'stability'], ['php_backend', 'worker'], 'Idempotency, retries, timeouts, partial failure, overload, and alert noise.'), + 'ai.test-quality' => $this->aiCheck('ai.test-quality', 'test quality review', ['ai', 'tests'], ['php_tests', 'worker'], 'Negative paths, meaningful assertions, hidden external systems, and eval coverage.'), + 'ai.frontend-ux' => $this->aiCheck('ai.frontend-ux', 'frontend UX review', ['ai', 'frontend'], ['frontend', 'blade'], 'Loading, empty, error, disabled states, accessibility, routing helpers, and application copy consistency.'), + ]; + } + + /** + * @return array> + */ + private function groups(): array + { + return [ + 'deterministic' => self::DETERMINISTIC_IDS, + 'ai' => self::AI_IDS, + 'security' => [ + 'deterministic.tenancy', + 'deterministic.secrets', + 'deterministic.authorization', + 'deterministic.dependencies', + 'ai.security-review', + ], + 'stability' => [ + 'deterministic.llm-boundaries', + 'deterministic.http-client', + 'deterministic.queue-stability', + 'deterministic.migrations', + 'ai.stability', + ], + 'laravel' => [ + 'deterministic.tenancy', + 'deterministic.request-validation', + 'deterministic.authorization', + 'deterministic.config-env', + 'deterministic.http-client', + 'deterministic.queue-stability', + 'deterministic.migrations', + 'ai.architecture', + 'ai.clean-code', + ], + 'frontend' => [ + 'deterministic.frontend-routes', + 'deterministic.secrets', + 'ai.frontend-ux', + ], + 'worker' => [ + 'deterministic.llm-boundaries', + 'deterministic.ai-free-boundaries', + 'ai.stability', + ], + 'tests' => [ + 'ai.test-quality', + ], + ]; + } + + /** + * @param list $requestedChecks + * @return list + */ + private function normalizeRequestedChecks(array $requestedChecks): array + { + return array_values(array_filter(array_map( + 'trim', + collect($requestedChecks) + ->flatMap(fn (string $check): array => explode(',', $check)) + ->all(), + ))); + } + + /** + * @param list $groups + * @param list $areas + */ + private function aiCheck(string $id, string $name, array $groups, array $areas, string $checklistExcerpt): AiCodeQualityCheck + { + return new AiCodeQualityCheck($id, $name, $groups, $areas, $checklistExcerpt, $this->aiReviewer); + } + + /** + * @param list $files + * @return list + */ + private function scanEnvOutsideConfig(array $files, PatternCodeQualityCheck $check): array + { + $findings = []; + + foreach ($files as $file) { + if (Str::startsWith($file, 'config/')) { + continue; + } + + $content = $this->readFile($file); + + if ($content === null) { + continue; + } + + $tokens = token_get_all($content); + + foreach ($tokens as $index => $token) { + if (! is_array($token) || $token[0] !== T_STRING || mb_strtolower($token[1]) !== 'env') { + continue; + } + + if ($this->nextMeaningfulTokenText($tokens, $index) !== '(') { + continue; + } + + $line = (int) $token[2]; + $findings[] = $check->makeFinding( + severity: 'error', + confidence: 'high', + category: 'configuration', + title: 'env() is used outside configuration', + description: 'Direct env() reads can return null after config caching.', + file: $file, + line: $line, + evidence: 'env() call', + recommendation: 'Move the env() read into a config file and use config() from application code.', + dedupeKey: $check->id().':'.$file.':'.$line.':env-outside-config', + ); + } + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanHardcodedModelStrings(array $files, PatternCodeQualityCheck $check): array + { + $findings = []; + + foreach ($files as $file) { + if (Str::startsWith($file, ['config/', 'resources/prompts/', 'specs/', 'docs/'])) { + continue; + } + + $content = $this->readFile($file); + + if ($content === null) { + continue; + } + + foreach (token_get_all($content) as $token) { + if (! is_array($token) || $token[0] !== T_CONSTANT_ENCAPSED_STRING) { + continue; + } + + $value = stripcslashes(trim($token[1], '\'"')); + + if (preg_match('/^(?:gpt-|claude-|gemini-|anthropic\/|google\/|openai\/)[\w.\/-]+$/i', $value) !== 1) { + continue; + } + + $line = (int) $token[2]; + $findings[] = $check->makeFinding( + severity: 'error', + confidence: 'high', + category: 'llm-boundary', + title: 'Model name is hardcoded outside configuration', + description: 'LLM model selection should stay in configuration or prompt metadata so operations can change models without code edits.', + file: $file, + line: $line, + evidence: 'Model-like string literal: '.$value, + recommendation: 'Move the model value into configuration and read it through config().', + dedupeKey: $check->id().':'.$file.':'.$line.':hardcoded-model', + ); + } + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanHttpTimeouts(array $files, PatternCodeQualityCheck $check): array + { + $findings = []; + + foreach ($files as $file) { + $content = $this->readFile($file); + + if ($content === null || ! str_contains($content, 'Http::')) { + continue; + } + + $tokens = token_get_all($content); + + foreach ($tokens as $index => $token) { + if (! is_array($token) || $token[0] !== T_STRING || $token[1] !== 'Http') { + continue; + } + + if ($this->nextMeaningfulTokenText($tokens, $index) !== '::') { + continue; + } + + $statement = $this->statementFromTokens($tokens, $index); + + if (str_contains($statement, 'fake(') || str_contains($statement, 'preventStrayRequests(')) { + continue; + } + + if (str_contains($statement, 'timeout(') && str_contains($statement, 'connectTimeout(')) { + continue; + } + + $line = (int) $token[2]; + $findings[] = $check->makeFinding( + severity: 'warning', + confidence: 'high', + category: 'stability', + title: 'HTTP client call is missing explicit timeouts', + description: 'External HTTP calls should set both connectTimeout and timeout so workers do not hang.', + file: $file, + line: $line, + evidence: Str::limit(trim(preg_replace('/\s+/', ' ', $statement) ?? ''), 220), + recommendation: 'Add connectTimeout(), timeout(), and explicit response error handling to the HTTP chain.', + dedupeKey: $check->id().':'.$file.':'.$line.':http-timeout', + ); + } + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanAiFreeBoundaryReferences(array $files, PatternCodeQualityCheck $check): array + { + $keywords = array_values(array_filter(array_map( + fn (string $keyword): string => Str::lower(trim($keyword)), + (array) config('code_quality.domain.ai_free_path_keywords', []), + ))); + + if ($keywords === []) { + return []; + } + + $aiFreeFiles = array_values(array_filter( + $files, + fn (string $file): bool => Str::contains(Str::lower($file), $keywords), + )); + + $findings = []; + + foreach ($aiFreeFiles as $file) { + $findings = [...$findings, ...$this->lineFindings( + $file, + '/(?:use\s+.*(?:Llm|OpenRouter|OpenAI|Ai)|import\s+.*(?:llm|openai|openrouter|prompts)|from\s+[\'"].*(?:llm|openai|openrouter|prompts)|\b(?:LlmClient|OpenRouter|OpenAI)::)/i', + fn (int $line, string $evidence): Finding => $check->makeFinding( + severity: 'critical', + confidence: 'high', + category: 'ai-boundary', + title: 'AI-free code boundary references AI infrastructure', + description: 'Configured AI-free paths must remain deterministic and must not import LLM or prompt infrastructure.', + file: $file, + line: $line, + evidence: $evidence, + recommendation: 'Move AI-dependent behavior out of the configured AI-free boundary or remove the path keyword from config if it is not intended.', + dedupeKey: $check->id().':'.$file.':'.$line.':ai-free-boundary', + ), + )]; + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanQueueJobs(array $files, PatternCodeQualityCheck $check): array + { + $findings = []; + + foreach ($files as $file) { + if (! Str::startsWith($file, 'app/Jobs/')) { + continue; + } + + $content = $this->readFile($file); + + if ($content === null || ! str_contains($content, 'ShouldQueue')) { + continue; + } + + $propertyType = '(?:[?\\\\A-Za-z_][\\\\A-Za-z0-9_|?]*\\s+)?'; + $hasRetryPolicy = preg_match('/(?:public|protected)\s+'.$propertyType.'\$tries|function\s+backoff|(?:public|protected)\s+'.$propertyType.'\$backoff|function\s+retryUntil/', $content) === 1; + $hasTimeout = preg_match('/(?:public|protected)\s+'.$propertyType.'\$timeout/', $content) === 1; + + if ($hasRetryPolicy && $hasTimeout) { + continue; + } + + $findings[] = $check->makeFinding( + severity: 'warning', + confidence: 'medium', + category: 'queue', + title: 'Queued job has incomplete retry or timeout policy', + description: 'Jobs should state retry/backoff and timeout behavior when they may do slow or external work.', + file: $file, + line: 1, + evidence: 'The job implements ShouldQueue without both retry/backoff and timeout declarations.', + recommendation: 'Add timeout and retry/backoff behavior, or document why the job is intentionally trivial.', + dedupeKey: $check->id().':'.$file.':queue-policy', + ); + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanMigrations(array $files, PatternCodeQualityCheck $check): array + { + $findings = []; + + foreach (array_filter($files, fn (string $file): bool => Str::startsWith($file, 'database/migrations/')) as $file) { + $content = $this->readFile($file); + + if ($content === null) { + continue; + } + + if (preg_match('/DB::(?:statement|select|unprepared|update|delete|insert)\s*\(/', $content, $match, PREG_OFFSET_CAPTURE) === 1) { + $line = substr_count(substr($content, 0, (int) $match[0][1]), "\n") + 1; + $findings[] = $check->makeFinding( + severity: 'warning', + confidence: 'high', + category: 'migration', + title: 'Migration uses raw SQL', + description: 'Raw SQL in migrations needs explicit justification and database portability review.', + file: $file, + line: $line, + evidence: trim($match[0][0]), + recommendation: 'Prefer Schema builder methods or add a clear portability justification.', + dedupeKey: $check->id().':'.$file.':'.$line.':raw-sql', + ); + } + + if (! str_contains($content, 'function down(')) { + $findings[] = $check->makeFinding( + severity: 'warning', + confidence: 'medium', + category: 'migration', + title: 'Migration has no down() method', + description: 'Migrations should be reversible unless intentionally forward-only.', + file: $file, + line: 1, + evidence: 'No down() method was found.', + recommendation: 'Add a down() method or document the migration as intentionally irreversible.', + dedupeKey: $check->id().':'.$file.':missing-down', + ); + } + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanTenantModels(array $files, PatternCodeQualityCheck $check): array + { + if (! (bool) config('code_quality.domain.multi_tenant', false)) { + return []; + } + + $allowlist = array_values(array_map('strval', [ + ...(array) config('code_quality.allowlists.global_models', []), + ...(array) config('code_quality.allowlists.tenant_via_parent_models', []), + ])); + $tenantKeys = array_values(array_filter(array_map('strval', (array) config('code_quality.domain.tenant_keys', [])))); + $findings = []; + + if ($tenantKeys === []) { + return []; + } + + foreach (array_filter($files, fn (string $file): bool => Str::startsWith($file, 'app/Models/')) as $file) { + $content = $this->readFile($file); + + if ($content === null || ! str_contains($content, 'extends Model')) { + continue; + } + + $model = pathinfo($file, PATHINFO_FILENAME); + + if (in_array($model, $allowlist, true) || Str::contains($content, $tenantKeys)) { + continue; + } + + $findings[] = $check->makeFinding( + severity: 'warning', + confidence: 'medium', + category: 'tenancy', + title: 'Model does not declare an obvious tenant boundary', + description: 'Tenant-owned models should include a configured tenant key or be allowlisted as global.', + file: $file, + line: 1, + evidence: "Model {$model} extends Model without a configured tenant key reference.", + recommendation: 'Add tenant scoping for tenant-owned data or allowlist the model as global with justification.', + dedupeKey: $check->id().':'.$file.':tenant-boundary', + ); + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanControllerAuthorization(array $files, PatternCodeQualityCheck $check): array + { + $findings = []; + $allowlist = array_values(array_map('strval', (array) config('code_quality.allowlists.authorization_methods', []))); + + foreach (array_filter($files, fn (string $file): bool => Str::contains($file, 'Controller.php')) as $file) { + $content = $this->readFile($file); + + if ($content === null) { + continue; + } + + preg_match_all('/function\s+(index|show|store|update|destroy|delete)\s*\([^)]*\)\s*:[^{]+{(?P.*?)(?=\n (?:public|protected|private)\s+function|\n})/s', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + foreach ($matches as $match) { + $method = $match[1][0]; + $body = $match['body'][0]; + + if (in_array($file.':'.$method, $allowlist, true) + || preg_match('/(?:authorize|Gate::|->can\(|middleware\([\'"]can:)/', $body) === 1) { + continue; + } + + $line = substr_count(substr($content, 0, (int) $match[0][1]), "\n") + 1; + $findings[] = $check->makeFinding( + severity: 'warning', + confidence: 'low', + category: 'authorization', + title: 'Controller method has no obvious authorization check', + description: 'Tenant data reads and state-changing methods should authorize through policies, gates, or route middleware.', + file: $file, + line: $line, + evidence: "Method {$method} has no obvious authorization call in its body.", + recommendation: 'Confirm route middleware or add an explicit policy/gate authorization check.', + dedupeKey: $check->id().':'.$file.':'.$method.':authorization', + ); + } + } + + return $findings; + } + + /** + * @param list $files + * @return list + */ + private function scanDependencyFiles(array $files, PatternCodeQualityCheck $check): array + { + $dependencyFiles = array_values(array_filter( + $files, + fn (string $file): bool => in_array($file, ['composer.json', 'composer.lock', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'], true), + )); + + if ($dependencyFiles === []) { + return []; + } + + return array_map( + fn (string $file): Finding => $check->makeFinding( + severity: 'warning', + confidence: 'high', + category: 'dependencies', + title: 'Dependency manifest or lockfile is in scope', + description: 'Dependency changes require audit/license checks before they should merge.', + file: $file, + line: 1, + evidence: 'Dependency file is included in the code-quality scope.', + recommendation: 'Run the configured package audit and license checks, then include the result in the handoff.', + dedupeKey: $check->id().':'.$file.':dependency-review', + ), + $dependencyFiles, + ); + } + + /** + * @return list + */ + private function lineFindings(string $file, string $regex, callable $factory): array + { + $lines = file(base_path($file), FILE_IGNORE_NEW_LINES); + + if ($lines === false) { + return []; + } + + $findings = []; + + foreach ($lines as $index => $line) { + if (preg_match($regex, $line) === 1) { + $findings[] = $factory($index + 1, trim($line)); + } + } + + return $findings; + } + + private function readFile(string $file): ?string + { + $content = @file_get_contents(base_path($file)); + + return $content === false ? null : $content; + } + + /** + * @param list $tokens + */ + private function nextMeaningfulTokenText(array $tokens, int $index): ?string + { + for ($cursor = $index + 1; $cursor < count($tokens); $cursor++) { + $token = $tokens[$cursor]; + + if (is_array($token) && in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + return $this->tokenText($token); + } + + return null; + } + + /** + * @param list $tokens + */ + private function statementFromTokens(array $tokens, int $index): string + { + $statement = ''; + + for ($cursor = $index; $cursor < count($tokens); $cursor++) { + $text = $this->tokenText($tokens[$cursor]); + $statement .= $text; + + if ($text === ';') { + break; + } + } + + return $statement; + } + + /** + * @param int|string|array{int, string, int} $token + */ + private function tokenText(int|string|array $token): string + { + return is_array($token) ? $token[1] : (string) $token; + } +} diff --git a/app/Services/CodeQuality/CheckResult.php b/app/Services/CodeQuality/CheckResult.php new file mode 100644 index 00000000..36c26110 --- /dev/null +++ b/app/Services/CodeQuality/CheckResult.php @@ -0,0 +1,61 @@ + $findings + */ + public function __construct( + public readonly string $checkId, + public readonly string $status, + public readonly int $durationMs, + public readonly array $findings = [], + public readonly ?string $message = null, + ) {} + + /** + * @param list $findings + */ + public static function completed(string $checkId, int $durationMs, array $findings): self + { + return new self( + checkId: $checkId, + status: $findings === [] ? 'passed' : 'failed', + durationMs: $durationMs, + findings: $findings, + ); + } + + public static function skipped(string $checkId, string $message, int $durationMs = 0): self + { + return new self($checkId, 'skipped', $durationMs, [], $message); + } + + public static function errored(string $checkId, string $message, int $durationMs = 0): self + { + return new self($checkId, 'errored', $durationMs, [], $message); + } + + public static function timedOut(string $checkId, string $message, int $durationMs = 0): self + { + return new self($checkId, 'timed_out', $durationMs, [], $message); + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'check_id' => $this->checkId, + 'status' => $this->status, + 'duration_ms' => $this->durationMs, + 'findings_count' => count($this->findings), + 'message' => $this->message, + ], fn (mixed $value): bool => $value !== null); + } +} diff --git a/app/Services/CodeQuality/Checks/AiCodeQualityCheck.php b/app/Services/CodeQuality/Checks/AiCodeQualityCheck.php new file mode 100644 index 00000000..ca8e8e15 --- /dev/null +++ b/app/Services/CodeQuality/Checks/AiCodeQualityCheck.php @@ -0,0 +1,85 @@ + $groups + * @param list $areas + */ + public function __construct( + private readonly string $id, + private readonly string $name, + private readonly array $groups, + private readonly array $areas, + private readonly string $checklistExcerpt, + private readonly AiReviewer $reviewer, + ) {} + + public function id(): string + { + return $this->id; + } + + public function groups(): array + { + return $this->groups; + } + + public function supports(CodeQualityScope $scope): bool + { + return $this->matchingFiles($scope) !== []; + } + + public function run(CodeQualityScope $scope, CheckContext $context): CheckResult + { + $started = hrtime(true); + $files = $this->matchingFiles($scope); + + if ($files === []) { + return CheckResult::skipped($this->id, 'No scoped files match this AI review.'); + } + + if (! $context->aiEnabled) { + return CheckResult::skipped($this->id, 'AI checks are disabled. Pass --ai to run this review.'); + } + + $result = $this->reviewer->review(new AiReviewRequest( + runId: $context->runId, + checkId: $this->id, + checkName: $this->name, + basePath: $scope->basePath, + files: $files, + checklistExcerpt: $this->checklistExcerpt, + deterministicFindings: $context->deterministicFindings, + model: $context->aiModel ?? (string) config('code_quality.ai.model'), + timeoutSeconds: $context->checkTimeoutSeconds, + )); + + return CheckResult::completed( + $this->id, + (int) ((hrtime(true) - $started) / 1_000_000), + $result->findings, + ); + } + + /** + * @return list + */ + private function matchingFiles(CodeQualityScope $scope): array + { + if ($this->areas === []) { + return $scope->files; + } + + return $scope->filesForAreas($this->areas); + } +} diff --git a/app/Services/CodeQuality/Checks/PatternCodeQualityCheck.php b/app/Services/CodeQuality/Checks/PatternCodeQualityCheck.php new file mode 100644 index 00000000..d6a41908 --- /dev/null +++ b/app/Services/CodeQuality/Checks/PatternCodeQualityCheck.php @@ -0,0 +1,155 @@ + $groups + * @param list $areas + * @param list> $patterns + * @param null|callable(CodeQualityScope, list, CheckContext, self): list $scanner + */ + public function __construct( + private readonly string $id, + private readonly array $groups, + private readonly array $areas, + private readonly array $patterns = [], + private readonly mixed $scanner = null, + private readonly ?string $skipMessage = null, + ) {} + + public function id(): string + { + return $this->id; + } + + public function groups(): array + { + return $this->groups; + } + + public function supports(CodeQualityScope $scope): bool + { + return $this->matchingFiles($scope) !== []; + } + + public function run(CodeQualityScope $scope, CheckContext $context): CheckResult + { + $started = hrtime(true); + $files = $this->matchingFiles($scope); + + if ($files === []) { + return CheckResult::skipped($this->id, $this->skipMessage ?? 'No scoped files match this check.'); + } + + $findings = is_callable($this->scanner) + ? ($this->scanner)($scope, $files, $context, $this) + : $this->scanPatterns($files); + + return CheckResult::completed( + $this->id, + (int) ((hrtime(true) - $started) / 1_000_000), + $findings, + ); + } + + public function makeFinding( + string $severity, + string $confidence, + string $category, + string $title, + string $description, + ?string $file = null, + ?int $line = null, + ?string $evidence = null, + ?string $recommendation = null, + ?string $dedupeKey = null, + ): Finding { + return new Finding( + id: 'cqf_'.Str::ulid(), + checkId: $this->id, + source: 'deterministic', + severity: $severity, + confidence: $confidence, + category: $category, + title: $title, + description: $description, + file: $file, + line: $line, + evidence: $evidence, + recommendation: $recommendation, + docs: [], + dedupeKey: $dedupeKey, + ); + } + + /** + * @return list + */ + private function matchingFiles(CodeQualityScope $scope): array + { + if ($this->areas === []) { + return $scope->files; + } + + return $scope->filesForAreas($this->areas); + } + + /** + * @param list $files + * @return list + */ + private function scanPatterns(array $files): array + { + $findings = []; + + foreach ($files as $file) { + $absolutePath = base_path($file); + + if (! is_readable($absolutePath)) { + continue; + } + + $lines = file($absolutePath, FILE_IGNORE_NEW_LINES); + + if ($lines === false) { + continue; + } + + foreach ($lines as $index => $line) { + foreach ($this->patterns as $pattern) { + if (! preg_match((string) $pattern['regex'], $line, $matches)) { + continue; + } + + $evidence = (bool) ($pattern['redact_evidence'] ?? false) + ? (string) ($pattern['evidence'] ?? 'Line matches a sensitive-risk pattern.') + : trim($line); + + $findings[] = $this->makeFinding( + severity: (string) $pattern['severity'], + confidence: (string) ($pattern['confidence'] ?? 'high'), + category: (string) $pattern['category'], + title: (string) $pattern['title'], + description: (string) $pattern['description'], + file: $file, + line: $index + 1, + evidence: $evidence, + recommendation: (string) ($pattern['recommendation'] ?? ''), + dedupeKey: $this->id.':'.$file.':'.($index + 1).':'.Str::slug((string) $pattern['title']), + ); + } + } + } + + return $findings; + } +} diff --git a/app/Services/CodeQuality/CodeQualityException.php b/app/Services/CodeQuality/CodeQualityException.php new file mode 100644 index 00000000..c0eab47c --- /dev/null +++ b/app/Services/CodeQuality/CodeQualityException.php @@ -0,0 +1,38 @@ +exitCode; + } +} diff --git a/app/Services/CodeQuality/CodeQualityPipeline.php b/app/Services/CodeQuality/CodeQualityPipeline.php new file mode 100644 index 00000000..8f2968b2 --- /dev/null +++ b/app/Services/CodeQuality/CodeQualityPipeline.php @@ -0,0 +1,172 @@ +scopes->resolve($options); + $selectedChecks = $this->registry->expand($options->checks, $options->aiEnabled(), $options->noAi); + $context = new CheckContext( + runId: $runId, + selectedChecks: $selectedChecks, + aiEnabled: $options->aiEnabled(), + aiModel: $options->aiModel ?? (string) config('code_quality.ai.model'), + checkTimeoutSeconds: $options->checkTimeoutSeconds, + ); + + $results = []; + $findings = []; + $deterministicFindings = []; + + foreach ($this->registry->checksFor($selectedChecks) as $check) { + if (! $check->supports($scope)) { + $results[] = CheckResult::skipped($check->id(), 'No scoped files match this check.'); + + continue; + } + + try { + $result = $check->run($scope, $context); + } catch (CodeQualityException $exception) { + if ($exception->exitCode() === 4) { + $results[] = CheckResult::timedOut($check->id(), $exception->getMessage()); + + continue; + } + + if ($exception->exitCode() === 5) { + throw $exception; + } + + $results[] = CheckResult::errored($check->id(), $exception->getMessage()); + + continue; + } catch (Throwable $exception) { + report($exception); + $results[] = CheckResult::errored($check->id(), $exception->getMessage()); + + continue; + } + + $results[] = $result; + $findings = [...$findings, ...$result->findings]; + + foreach ($result->findings as $finding) { + if ($finding->source === 'deterministic') { + $deterministicFindings[] = $finding; + } + } + + $context = $context->withDeterministicFindings($deterministicFindings); + } + + $findings = $this->markBlocking( + findings: $this->dedupeFindings($findings), + failOn: $options->failOn, + ); + + return new Report( + runId: $runId, + startedAt: $startedAt->toJSON(), + finishedAt: now('UTC')->toJSON(), + status: $this->status($results, $findings), + profile: $options->profile, + scope: $scope, + configuration: [ + 'checks' => $selectedChecks, + 'ai_enabled' => $options->aiEnabled(), + 'ai_model' => $options->aiEnabled() ? ($options->aiModel ?? config('code_quality.ai.model')) : null, + 'ai_concurrency' => $options->aiConcurrency ?? config('code_quality.ai.concurrency'), + 'fail_on' => $options->failOn, + ], + checkResults: $results, + findings: $findings, + ); + } + + /** + * @param list $findings + * @return list + */ + private function dedupeFindings(array $findings): array + { + $deduped = []; + + foreach ($findings as $finding) { + $key = $finding->dedupeIdentity(); + + if (! array_key_exists($key, $deduped)) { + $deduped[$key] = $finding; + + continue; + } + + $existing = $deduped[$key]; + + if ($existing->source === 'ai' && $finding->source === 'deterministic') { + $deduped[$key] = $finding->withRelatedNote($existing->description); + + continue; + } + + if ($existing->source === 'deterministic' && $finding->source === 'ai') { + $deduped[$key] = $existing->withRelatedNote($finding->description); + } + } + + return array_values($deduped); + } + + /** + * @param list $findings + * @return list + */ + private function markBlocking(array $findings, string $failOn): array + { + return array_map( + fn (Finding $finding): Finding => $finding->withBlocking(Severity::blocks( + source: $finding->source, + severity: $finding->severity, + confidence: $finding->confidence, + threshold: $failOn, + )), + $findings, + ); + } + + /** + * @param list $results + * @param list $findings + */ + private function status(array $results, array $findings): string + { + if (collect($results)->contains(fn (CheckResult $result): bool => $result->status === 'timed_out')) { + return 'timed_out'; + } + + if (collect($results)->contains(fn (CheckResult $result): bool => $result->status === 'errored')) { + return 'errored'; + } + + if (collect($findings)->contains(fn (Finding $finding): bool => $finding->blocking)) { + return 'failed'; + } + + return 'passed'; + } +} diff --git a/app/Services/CodeQuality/CodeQualityRunOptions.php b/app/Services/CodeQuality/CodeQualityRunOptions.php new file mode 100644 index 00000000..d3f95d70 --- /dev/null +++ b/app/Services/CodeQuality/CodeQualityRunOptions.php @@ -0,0 +1,40 @@ + $paths + * @param list $checks + * @param list $excludes + */ + public function __construct( + public readonly array $paths, + public readonly array $checks, + public readonly array $excludes, + public readonly bool $changed, + public readonly bool $staged, + public readonly ?string $base, + public readonly string $format, + public readonly ?string $output, + public readonly string $failOn, + public readonly bool $ai, + public readonly bool $noAi, + public readonly ?string $aiModel, + public readonly ?int $aiConcurrency, + public readonly int $timeoutSeconds, + public readonly int $checkTimeoutSeconds, + public readonly string $profile, + ) {} + + public function aiEnabled(): bool + { + return ! $this->noAi && ($this->ai || (bool) config('code_quality.ai.enabled', false)); + } + + public function hasExplicitAllChecks(): bool + { + return in_array('all', $this->checks, true); + } +} diff --git a/app/Services/CodeQuality/CodeQualityScope.php b/app/Services/CodeQuality/CodeQualityScope.php new file mode 100644 index 00000000..d8cd68d0 --- /dev/null +++ b/app/Services/CodeQuality/CodeQualityScope.php @@ -0,0 +1,80 @@ + $inputPaths + * @param list $files + */ + public function __construct( + public readonly string $basePath, + public readonly array $inputPaths, + public readonly array $files, + public readonly ?string $base, + public readonly bool $changedOnly, + public readonly bool $stagedOnly, + ) {} + + /** + * @param list $areas + * @return list + */ + public function filesForAreas(array $areas): array + { + return array_values(array_filter( + $this->files, + fn (string $file): bool => in_array($this->areaFor($file), $areas, true), + )); + } + + public function areaFor(string $file): string + { + return match (true) { + Str::is('app/**', $file), Str::is('bootstrap/**', $file), Str::is('config/**', $file), Str::is('routes/**', $file), Str::is('database/**', $file) => 'php_backend', + Str::is('tests/**', $file) => 'php_tests', + Str::is('resources/js/**', $file) => 'frontend', + Str::is('resources/views/**', $file) => 'blade', + Str::is('worker/src/**', $file), Str::is('worker/tests/**', $file) => 'worker', + Str::is('packages/check-spec/**', $file) => 'check_spec', + Str::is('resources/prompts/**', $file) => 'prompts', + Str::is('specs/**', $file), Str::is('docs/**', $file) => 'specs_docs', + $this->isConfigFile($file) => 'config', + default => 'other', + }; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'paths' => $this->inputPaths, + 'files' => $this->files, + 'base' => $this->base, + 'changed_only' => $this->changedOnly, + 'staged_only' => $this->stagedOnly, + ]; + } + + private function isConfigFile(string $file): bool + { + return Str::is([ + 'composer.json', + 'composer.lock', + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'vite.config.*', + 'tsconfig.*', + 'phpstan.*', + 'pint.*', + 'eslint.config.*', + ], $file); + } +} diff --git a/app/Services/CodeQuality/Contracts/AiReviewer.php b/app/Services/CodeQuality/Contracts/AiReviewer.php new file mode 100644 index 00000000..b92ed980 --- /dev/null +++ b/app/Services/CodeQuality/Contracts/AiReviewer.php @@ -0,0 +1,11 @@ + + */ + public function groups(): array; + + public function supports(CodeQualityScope $scope): bool; + + public function run(CodeQualityScope $scope, CheckContext $context): CheckResult; +} diff --git a/app/Services/CodeQuality/Findings/Finding.php b/app/Services/CodeQuality/Findings/Finding.php new file mode 100644 index 00000000..5e0bbf75 --- /dev/null +++ b/app/Services/CodeQuality/Findings/Finding.php @@ -0,0 +1,158 @@ + $docs + * @param list $relatedNotes + */ + public function __construct( + public readonly string $id, + public readonly string $checkId, + public readonly string $source, + public readonly string $severity, + public readonly string $confidence, + public readonly string $category, + public readonly string $title, + public readonly string $description, + public readonly ?string $file = null, + public readonly ?int $line = null, + public readonly ?int $endLine = null, + public readonly ?string $symbol = null, + public readonly ?string $evidence = null, + public readonly ?string $recommendation = null, + public readonly array $docs = [], + public readonly ?string $dedupeKey = null, + public readonly bool $blocking = false, + public readonly array $relatedNotes = [], + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data, string $fallbackCheckId, string $fallbackSource): self + { + return new self( + id: (string) ($data['id'] ?? 'cqf_'.Str::ulid()), + checkId: (string) ($data['check_id'] ?? $fallbackCheckId), + source: (string) ($data['source'] ?? $fallbackSource), + severity: (string) ($data['severity'] ?? 'warning'), + confidence: (string) ($data['confidence'] ?? 'medium'), + category: (string) ($data['category'] ?? 'general'), + title: (string) ($data['title'] ?? 'Code quality finding'), + description: (string) ($data['description'] ?? ''), + file: Arr::has($data, 'file') && $data['file'] !== null ? (string) $data['file'] : null, + line: Arr::has($data, 'line') && $data['line'] !== null ? (int) $data['line'] : null, + endLine: Arr::has($data, 'end_line') && $data['end_line'] !== null ? (int) $data['end_line'] : null, + symbol: Arr::has($data, 'symbol') && $data['symbol'] !== null ? (string) $data['symbol'] : null, + evidence: Arr::has($data, 'evidence') && $data['evidence'] !== null ? (string) $data['evidence'] : null, + recommendation: Arr::has($data, 'recommendation') && $data['recommendation'] !== null ? (string) $data['recommendation'] : null, + docs: array_values(array_map('strval', Arr::wrap($data['docs'] ?? []))), + dedupeKey: Arr::has($data, 'dedupe_key') && $data['dedupe_key'] !== null ? (string) $data['dedupe_key'] : null, + blocking: (bool) ($data['blocking'] ?? false), + relatedNotes: array_values(array_map('strval', Arr::wrap($data['related_notes'] ?? []))), + ); + } + + public function withBlocking(bool $blocking): self + { + return new self( + id: $this->id, + checkId: $this->checkId, + source: $this->source, + severity: $this->severity, + confidence: $this->confidence, + category: $this->category, + title: $this->title, + description: $this->description, + file: $this->file, + line: $this->line, + endLine: $this->endLine, + symbol: $this->symbol, + evidence: $this->evidence, + recommendation: $this->recommendation, + docs: $this->docs, + dedupeKey: $this->dedupeKey, + blocking: $blocking, + relatedNotes: $this->relatedNotes, + ); + } + + public function withRelatedNote(string $note): self + { + return new self( + id: $this->id, + checkId: $this->checkId, + source: $this->source, + severity: $this->severity, + confidence: $this->confidence, + category: $this->category, + title: $this->title, + description: $this->description, + file: $this->file, + line: $this->line, + endLine: $this->endLine, + symbol: $this->symbol, + evidence: $this->evidence, + recommendation: $this->recommendation, + docs: $this->docs, + dedupeKey: $this->dedupeKey, + blocking: $this->blocking, + relatedNotes: [...$this->relatedNotes, $note], + ); + } + + public function dedupeIdentity(): string + { + if ($this->dedupeKey !== null && $this->dedupeKey !== '') { + return $this->dedupeKey; + } + + if ($this->file !== null && $this->line !== null) { + return implode(':', [ + $this->checkId, + $this->file, + (string) $this->line, + Str::slug($this->title), + ]); + } + + return implode(':', [ + $this->checkId, + Str::slug($this->title), + (string) $this->symbol, + ]); + } + + /** + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'id' => $this->id, + 'check_id' => $this->checkId, + 'source' => $this->source, + 'severity' => $this->severity, + 'confidence' => $this->confidence, + 'category' => $this->category, + 'title' => $this->title, + 'description' => $this->description, + 'file' => $this->file, + 'line' => $this->line, + 'end_line' => $this->endLine, + 'symbol' => $this->symbol, + 'evidence' => $this->evidence, + 'recommendation' => $this->recommendation, + 'docs' => $this->docs, + 'dedupe_key' => $this->dedupeKey ?? $this->dedupeIdentity(), + 'blocking' => $this->blocking, + 'related_notes' => $this->relatedNotes, + ], fn (mixed $value): bool => $value !== null && $value !== []); + } +} diff --git a/app/Services/CodeQuality/Findings/Report.php b/app/Services/CodeQuality/Findings/Report.php new file mode 100644 index 00000000..eaf8039f --- /dev/null +++ b/app/Services/CodeQuality/Findings/Report.php @@ -0,0 +1,95 @@ + $configuration + * @param list $checkResults + * @param list $findings + */ + public function __construct( + public readonly string $runId, + public readonly string $startedAt, + public readonly string $finishedAt, + public readonly string $status, + public readonly string $profile, + public readonly CodeQualityScope $scope, + public readonly array $configuration, + public readonly array $checkResults, + public readonly array $findings, + ) {} + + public function exitCode(): int + { + return match ($this->status) { + 'passed' => 0, + 'failed' => 1, + 'timed_out' => 4, + default => 3, + }; + } + + /** + * @return array + */ + public function summary(): array + { + $summary = [ + 'critical' => 0, + 'error' => 0, + 'warning' => 0, + 'info' => 0, + 'skipped_checks' => 0, + 'failed_checks' => 0, + ]; + + foreach ($this->findings as $finding) { + if (array_key_exists($finding->severity, $summary)) { + $summary[$finding->severity]++; + } + } + + foreach ($this->checkResults as $result) { + if ($result->status === 'skipped') { + $summary['skipped_checks']++; + } + + if (in_array($result->status, ['errored', 'timed_out'], true)) { + $summary['failed_checks']++; + } + } + + return $summary; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'schema_version' => 1, + 'run_id' => $this->runId, + 'started_at' => $this->startedAt, + 'finished_at' => $this->finishedAt, + 'status' => $this->status, + 'profile' => $this->profile, + 'scope' => $this->scope->toArray(), + 'configuration' => $this->configuration, + 'summary' => $this->summary(), + 'check_results' => array_map( + fn (CheckResult $result): array => $result->toArray(), + $this->checkResults, + ), + 'findings' => array_map( + fn (Finding $finding): array => $finding->toArray(), + $this->findings, + ), + ]; + } +} diff --git a/app/Services/CodeQuality/Output/JsonReportWriter.php b/app/Services/CodeQuality/Output/JsonReportWriter.php new file mode 100644 index 00000000..2812a266 --- /dev/null +++ b/app/Services/CodeQuality/Output/JsonReportWriter.php @@ -0,0 +1,13 @@ +toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL; + } +} diff --git a/app/Services/CodeQuality/Output/JsonlReportWriter.php b/app/Services/CodeQuality/Output/JsonlReportWriter.php new file mode 100644 index 00000000..d1d8a9e6 --- /dev/null +++ b/app/Services/CodeQuality/Output/JsonlReportWriter.php @@ -0,0 +1,34 @@ + 'summary', + 'report' => array_diff_key($report->toArray(), ['findings' => true, 'check_results' => true]), + ], JSON_UNESCAPED_SLASHES), + ]; + + foreach ($report->checkResults as $result) { + $lines[] = json_encode([ + 'type' => 'check_result', + 'check_result' => $result->toArray(), + ], JSON_UNESCAPED_SLASHES); + } + + foreach ($report->findings as $finding) { + $lines[] = json_encode([ + 'type' => 'finding', + 'finding' => $finding->toArray(), + ], JSON_UNESCAPED_SLASHES); + } + + return implode(PHP_EOL, $lines).PHP_EOL; + } +} diff --git a/app/Services/CodeQuality/Output/PrettyReportWriter.php b/app/Services/CodeQuality/Output/PrettyReportWriter.php new file mode 100644 index 00000000..b12f63c8 --- /dev/null +++ b/app/Services/CodeQuality/Output/PrettyReportWriter.php @@ -0,0 +1,52 @@ +summary(); + $lines = [ + "Code quality {$report->status}: ".count($report->scope->files).' file(s), '.count($report->checkResults).' check(s).', + "Findings: {$summary['critical']} critical, {$summary['error']} error, {$summary['warning']} warning, {$summary['info']} info.", + "Skipped checks: {$summary['skipped_checks']}; checker failures: {$summary['failed_checks']}.", + ]; + + foreach ($report->checkResults as $result) { + if (! in_array($result->status, ['skipped', 'errored', 'timed_out'], true)) { + continue; + } + + $lines[] = ''; + $lines[] = strtoupper($result->status).' '.$result->checkId.($result->message === null ? '' : ': '.$result->message); + } + + foreach ($report->findings as $finding) { + $lines[] = ''; + $lines[] = $this->findingTitle($finding); + $lines[] = $finding->description; + + if ($finding->file !== null) { + $location = $finding->file.($finding->line === null ? '' : ':'.$finding->line); + $lines[] = 'Location: '.$location; + } + + if ($finding->recommendation !== null && $finding->recommendation !== '') { + $lines[] = 'Recommendation: '.$finding->recommendation; + } + } + + return implode(PHP_EOL, $lines).PHP_EOL; + } + + private function findingTitle(Finding $finding): string + { + $blocking = $finding->blocking ? ' blocking' : ''; + + return strtoupper($finding->severity)."{$blocking} {$finding->checkId}: {$finding->title}"; + } +} diff --git a/app/Services/CodeQuality/Runners/CodexAiRunner.php b/app/Services/CodeQuality/Runners/CodexAiRunner.php new file mode 100644 index 00000000..8d35651e --- /dev/null +++ b/app/Services/CodeQuality/Runners/CodexAiRunner.php @@ -0,0 +1,247 @@ +ensureSchema(); + $runDirectory = storage_path("app/code-quality/runs/{$request->runId}"); + $this->files->ensureDirectoryExists($runDirectory); + $outputPath = "{$runDirectory}/".str_replace('.', '-', $request->checkId).'.json'; + + $result = $this->processes->run( + command: $this->command($request, $schemaPath, $outputPath), + workingDirectory: $request->basePath, + timeoutSeconds: $request->timeoutSeconds, + environment: $this->safeEnvironment(), + ); + + if (! $result->successful()) { + throw CodeQualityException::aiUnavailable(trim($result->errorOutput) !== '' + ? 'Codex AI review failed: '.Str::limit(trim($result->errorOutput), 500) + : 'Codex AI review failed with exit code '.$result->exitCode); + } + + $rawOutput = $this->files->exists($outputPath) + ? $this->files->get($outputPath) + : $result->output; + + $decoded = json_decode($rawOutput, true); + + if (! is_array($decoded)) { + throw CodeQualityException::checkerFailed("Codex AI review did not return parseable JSON for {$request->checkId}."); + } + + $items = is_array($decoded['findings'] ?? null) ? $decoded['findings'] : $decoded; + $findings = []; + + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + $findings[] = Finding::fromArray([ + ...$item, + 'check_id' => $request->checkId, + 'source' => 'ai', + ], $request->checkId, 'ai'); + } + + return new AiReviewResult($findings, $rawOutput); + } + + /** + * @return list + */ + private function command(AiReviewRequest $request, string $schemaPath, string $outputPath): array + { + $binary = (string) config('code_quality.ai.binary', 'codex'); + + return [ + $binary, + 'exec', + '--ephemeral', + '--sandbox', + 'read-only', + '-C', + $request->basePath, + '--model', + $request->model, + '-c', + 'approval_policy="never"', + '-c', + 'model_reasoning_effort="'.addcslashes((string) config('code_quality.ai.reasoning_effort', 'low'), '"').'"', + '-c', + 'model_verbosity="'.addcslashes((string) config('code_quality.ai.verbosity', 'low'), '"').'"', + '--output-schema', + $schemaPath, + '--output-last-message', + $outputPath, + '--color', + 'never', + $this->prompt($request), + ]; + } + + private function prompt(AiReviewRequest $request): string + { + $deterministicFindings = array_map( + fn (Finding $finding): array => $finding->toArray(), + $request->deterministicFindings, + ); + + return implode("\n\n", [ + "You are reviewing Laravel application code for {$request->checkName}.", + 'Return only JSON matching the provided schema.', + 'Do not edit files. Do not ask to run commands.', + 'Do not repeat deterministic findings unless you add a separate risk.', + 'No compliments. Findings only.', + 'Review scope:', + implode("\n", $request->files), + 'Relevant checklist:', + $request->checklistExcerpt, + 'Deterministic findings already found:', + json_encode($deterministicFindings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + 'Files:', + $this->fileExcerpts($request), + 'Find only concrete issues with evidence. If there are no findings, return {"findings":[]}.', + ]); + } + + private function fileExcerpts(AiReviewRequest $request): string + { + $chunks = []; + $remainingBytes = (int) config('code_quality.ai.max_prompt_file_bytes', 120_000); + + foreach ($request->files as $file) { + if ($remainingBytes <= 0) { + break; + } + + $path = $request->basePath.'/'.$file; + + if (! $this->files->isFile($path) || ! $this->files->isReadable($path)) { + continue; + } + + $content = $this->files->get($path); + $excerpt = Str::limit($content, min(30_000, $remainingBytes), "\n...[truncated]"); + $remainingBytes -= strlen($excerpt); + $chunks[] = "### {$file}\n```text\n{$excerpt}\n```"; + } + + return implode("\n\n", $chunks); + } + + private function ensureSchema(): string + { + $path = storage_path('app/code-quality/schemas/ai-finding-report.schema.json'); + $this->files->ensureDirectoryExists(dirname($path)); + + $this->files->put($path, json_encode($this->schema(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $path; + } + + /** + * @return array + */ + private function schema(): array + { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'findings' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'id' => ['type' => 'string'], + 'check_id' => ['type' => 'string'], + 'source' => ['type' => 'string', 'enum' => ['ai']], + 'severity' => ['type' => 'string', 'enum' => ['info', 'warning', 'error', 'critical']], + 'confidence' => ['type' => 'string', 'enum' => ['low', 'medium', 'high']], + 'category' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'description' => ['type' => 'string'], + 'file' => ['type' => ['string', 'null']], + 'line' => ['type' => ['integer', 'null']], + 'end_line' => ['type' => ['integer', 'null']], + 'symbol' => ['type' => ['string', 'null']], + 'evidence' => ['type' => ['string', 'null']], + 'recommendation' => ['type' => 'string'], + 'docs' => ['type' => 'array', 'items' => ['type' => 'string']], + 'dedupe_key' => ['type' => ['string', 'null']], + ], + 'required' => [ + 'id', + 'check_id', + 'source', + 'severity', + 'confidence', + 'category', + 'title', + 'description', + 'file', + 'line', + 'end_line', + 'symbol', + 'evidence', + 'recommendation', + 'docs', + 'dedupe_key', + ], + ], + ], + ], + 'required' => ['findings'], + ]; + } + + /** + * @return array + */ + private function safeEnvironment(): array + { + $allowed = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'TEMP', 'TMP', 'TERM', 'CODEX_HOME', 'XDG_CONFIG_HOME']; + $environment = []; + + foreach (array_keys([...$_SERVER, ...$_ENV]) as $key) { + if (in_array($key, $allowed, true) && ! $this->isSecretLike($key)) { + $value = getenv($key); + + if ($value !== false) { + $environment[$key] = $value; + } + + continue; + } + + $environment[$key] = false; + } + + return $environment; + } + + private function isSecretLike(string $key): bool + { + return preg_match('/(?:SECRET|TOKEN|PASSWORD|PASS|KEY|COOKIE|AUTH|CREDENTIAL|DATABASE_URL|OPENROUTER|STRIPE|AWS)/i', $key) === 1; + } +} diff --git a/app/Services/CodeQuality/Runners/ProcessResult.php b/app/Services/CodeQuality/Runners/ProcessResult.php new file mode 100644 index 00000000..07874778 --- /dev/null +++ b/app/Services/CodeQuality/Runners/ProcessResult.php @@ -0,0 +1,22 @@ + $command + */ + public function __construct( + public readonly array $command, + public readonly int $exitCode, + public readonly string $output, + public readonly string $errorOutput, + public readonly int $durationMs, + ) {} + + public function successful(): bool + { + return $this->exitCode === 0; + } +} diff --git a/app/Services/CodeQuality/Runners/ProcessRunner.php b/app/Services/CodeQuality/Runners/ProcessRunner.php new file mode 100644 index 00000000..9a7916e3 --- /dev/null +++ b/app/Services/CodeQuality/Runners/ProcessRunner.php @@ -0,0 +1,34 @@ + $command + * @param array $environment + */ + public function run(array $command, string $workingDirectory, int $timeoutSeconds, array $environment = []): ProcessResult + { + $started = hrtime(true); + $process = new Process($command, $workingDirectory, $environment, null, $timeoutSeconds); + + try { + $process->run(); + } catch (ProcessTimedOutException $exception) { + throw CodeQualityException::timedOut('Process timed out: '.implode(' ', $command)); + } + + return new ProcessResult( + command: $command, + exitCode: $process->getExitCode() ?? 1, + output: $process->getOutput(), + errorOutput: $process->getErrorOutput(), + durationMs: (int) ((hrtime(true) - $started) / 1_000_000), + ); + } +} diff --git a/app/Services/CodeQuality/ScopeResolver.php b/app/Services/CodeQuality/ScopeResolver.php new file mode 100644 index 00000000..518418a3 --- /dev/null +++ b/app/Services/CodeQuality/ScopeResolver.php @@ -0,0 +1,205 @@ +paths; + $files = $inputPaths === [] + ? $this->resolveImplicitFiles($options, $basePath) + : $this->resolveExplicitFiles($inputPaths, $options, $basePath); + + $files = $this->excludeFiles($files, $options); + + if ($files === [] && ! $options->hasExplicitAllChecks()) { + throw CodeQualityException::invalidInput('No files matched the requested code-quality scope.'); + } + + return new CodeQualityScope( + basePath: $basePath, + inputPaths: $inputPaths, + files: $files, + base: $options->base, + changedOnly: $options->changed, + stagedOnly: $options->staged, + ); + } + + /** + * @param list $inputPaths + * @return list + */ + private function resolveExplicitFiles(array $inputPaths, CodeQualityRunOptions $options, string $basePath): array + { + $resolved = []; + + foreach ($inputPaths as $path) { + $absolutePath = $this->absolutePath($path, $basePath); + + if (! $this->files->exists($absolutePath)) { + throw CodeQualityException::invalidInput("Path does not exist: {$path}"); + } + + if ($this->files->isFile($absolutePath)) { + $resolved[] = $this->relativePath($absolutePath, $basePath); + + continue; + } + + foreach ($this->files->allFiles($absolutePath) as $file) { + $resolved[] = $this->relativePath($file->getPathname(), $basePath); + } + } + + return $this->uniqueSorted($resolved); + } + + /** + * @return list + */ + private function resolveImplicitFiles(CodeQualityRunOptions $options, string $basePath): array + { + if ($options->staged) { + return $this->gitFiles(['git', 'diff', '--cached', '--name-only', '--diff-filter=ACMR'], $basePath); + } + + if ($options->changed || $this->isInteractive()) { + $command = ['git', 'diff', '--name-only', '--diff-filter=ACMR']; + + if ($options->base !== null && $options->base !== '') { + $command[] = $options->base; + $command[] = '--'; + } + + $files = [ + ...$this->gitFiles($command, $basePath), + ...$this->gitFiles(['git', 'ls-files', '--others', '--exclude-standard'], $basePath), + ]; + + if ($files !== []) { + return $this->uniqueSorted($files); + } + } + + if ($options->hasExplicitAllChecks()) { + return $this->gitFiles(['git', 'ls-files', '--cached', '--others', '--exclude-standard'], $basePath); + } + + throw CodeQualityException::invalidInput('Pass paths, --changed, --staged, or --checks=all when no changed files are available.'); + } + + /** + * @param list $files + * @return list + */ + private function excludeFiles(array $files, CodeQualityRunOptions $options): array + { + $patterns = [ + ...config('code_quality.paths.exclude', []), + ...$options->excludes, + ]; + + return array_values(array_filter( + $files, + fn (string $file): bool => ! $this->isExcluded($file, $patterns) && $this->files->isFile(base_path($file)), + )); + } + + /** + * @param list $command + * @return list + */ + private function gitFiles(array $command, string $basePath): array + { + $result = $this->processes->run($command, $basePath, 30); + + if (! $result->successful()) { + return []; + } + + return $this->uniqueSorted(array_filter(array_map( + fn (string $file): string => str_replace('\\', '/', trim($file)), + preg_split('/\R/', $result->output) ?: [], + ))); + } + + /** + * @param list $patterns + */ + private function isExcluded(string $file, array $patterns): bool + { + foreach ($patterns as $pattern) { + $pattern = trim((string) $pattern); + + if ($pattern === '') { + continue; + } + + if (Str::is($pattern, $file) || Str::startsWith($file, rtrim($pattern, '/').'/')) { + return true; + } + } + + return false; + } + + private function absolutePath(string $path, string $basePath): string + { + $path = $path === '' ? '.' : $path; + $candidate = str_starts_with($path, '/') ? $path : $basePath.'/'.$path; + $realPath = realpath($candidate); + + if ($realPath === false) { + return $candidate; + } + + if (! Str::startsWith($realPath, $basePath)) { + throw CodeQualityException::invalidInput("Path is outside the repository: {$path}"); + } + + return $realPath; + } + + private function relativePath(string $path, string $basePath): string + { + $path = $path instanceof SplFileInfo ? $path->getPathname() : $path; + $path = str_replace('\\', '/', $path); + $basePath = str_replace('\\', '/', $basePath); + + return ltrim(Str::after($path, $basePath), '/'); + } + + /** + * @param array $files + * @return list + */ + private function uniqueSorted(array $files): array + { + $files = array_values(array_unique(array_filter(array_map( + fn (string|false|null $file): string => str_replace('\\', '/', trim((string) $file)), + $files, + )))); + + sort($files); + + return $files; + } + + private function isInteractive(): bool + { + return function_exists('posix_isatty') && posix_isatty(STDIN); + } +} diff --git a/app/Services/CodeQuality/Support/Severity.php b/app/Services/CodeQuality/Support/Severity.php new file mode 100644 index 00000000..cde49610 --- /dev/null +++ b/app/Services/CodeQuality/Support/Severity.php @@ -0,0 +1,52 @@ + + */ + private const ORDER = [ + 'info' => 0, + 'warning' => 1, + 'error' => 2, + 'critical' => 3, + ]; + + public static function normalize(string $severity): string + { + $severity = mb_strtolower($severity); + + return array_key_exists($severity, self::ORDER) ? $severity : 'warning'; + } + + public static function isAtLeast(string $severity, string $threshold): bool + { + $severity = self::normalize($severity); + $threshold = self::normalize($threshold); + + return self::ORDER[$severity] >= self::ORDER[$threshold]; + } + + public static function blocks(string $source, string $severity, string $confidence, string $threshold): bool + { + if (! self::isAtLeast($severity, $threshold)) { + return false; + } + + if ($source !== 'ai') { + return true; + } + + return in_array($severity, ['critical', 'error'], true) && $confidence === 'high'; + } + + /** + * @return list + */ + public static function all(): array + { + return array_keys(self::ORDER); + } +} diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php index 54849c99..470abb7f 100644 --- a/app/Services/CustomerService.php +++ b/app/Services/CustomerService.php @@ -4,31 +4,54 @@ use App\Models\Customer; use App\Models\Store; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; final class CustomerService { - public function __construct(private readonly AuditLogger $audit) {} + public function __construct( + private readonly AuditLogger $audit, + private readonly WebhookService $webhooks, + ) {} /** @param array $data */ public function register(Store $store, array $data): Customer { $validated = Validator::make($data, [ 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'email', 'max:255', Rule::unique('customers')->where('store_id', $store->id)], + 'email' => ['required', 'email', 'max:255', Rule::unique('customers')->where( + fn ($query) => $query->where('store_id', $store->id)->whereNotNull('password_hash'), + )], 'password' => ['required', 'string', 'min:8'], 'marketing_opt_in' => ['sometimes', 'boolean'], ])->validate(); - $customer = Customer::withoutGlobalScopes()->create([ - 'store_id' => $store->id, - 'name' => $validated['name'], - 'email' => mb_strtolower($validated['email']), - 'password_hash' => Hash::make($validated['password']), - 'marketing_opt_in' => (bool) ($validated['marketing_opt_in'] ?? false), - ]); + $email = mb_strtolower($validated['email']); + $customer = DB::transaction(function () use ($store, $validated, $email): Customer { + $guest = Customer::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('email', $email) + ->whereNull('password_hash') + ->lockForUpdate() + ->first(); + + if ($guest !== null) { + $guest->update([ + 'email' => 'guest-'.$guest->id.'-'.Str::lower(Str::random(12)).'@unclaimed.invalid', + ]); + } + + return Customer::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'name' => $validated['name'], + 'email' => $email, + 'password_hash' => Hash::make($validated['password']), + 'marketing_opt_in' => (bool) ($validated['marketing_opt_in'] ?? false), + ]); + }); $this->audit->log( 'customer.registered', @@ -37,6 +60,11 @@ public function register(Store $store, array $data): Customer resourceId: (int) $customer->id, extra: ['customer_id' => (int) $customer->id], ); + $this->webhooks->dispatch($store, 'customer.created', [ + 'customer_id' => $customer->id, + 'email' => $customer->email, + 'name' => $customer->name, + ]); return $customer; } diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php index 4ef42193..7ddde53c 100644 --- a/app/Services/DiscountService.php +++ b/app/Services/DiscountService.php @@ -5,6 +5,7 @@ use App\Exceptions\InvalidDiscountException; use App\Models\Cart; use App\Models\Discount; +use App\Models\OrderLine; use App\Models\Store; use App\ValueObjects\DiscountResult; use App\ValueObjects\DiscountValidationResult; @@ -23,6 +24,15 @@ public function validate(string $code, Store $store, Cart $cart): Discount if ($discount === null) { throw new InvalidDiscountException('not_found'); } + + return $this->validateDiscount($discount, $store, $cart); + } + + public function validateDiscount(Discount $discount, Store $store, Cart $cart, ?int $customerId = null): Discount + { + if ((int) $discount->store_id !== (int) $store->id) { + throw new InvalidDiscountException('not_found'); + } if ($this->value($discount->status) !== 'active') { throw new InvalidDiscountException('expired'); } @@ -46,10 +56,37 @@ public function validate(string $code, Store $store, Cart $cart): Discount if ($this->qualifyingLines($discount, $cart->lines)->isEmpty() && $cart->lines->isNotEmpty()) { throw new InvalidDiscountException('not_applicable'); } + $rules = (array) ($discount->rules_json ?? []); + $oncePerCustomer = (bool) ($rules['one_per_customer'] ?? $rules['once_per_customer'] ?? false); + $customerId ??= $cart->customer_id === null ? null : (int) $cart->customer_id; + if ($oncePerCustomer && $customerId !== null && $this->customerHasRedeemed($discount, $customerId)) { + throw new InvalidDiscountException('customer_usage_limit_reached'); + } return $discount; } + /** @return Collection */ + public function automaticDiscounts(Store $store, Cart $cart): Collection + { + return Discount::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('type', 'automatic') + ->where('status', 'active') + ->orderBy('id') + ->get() + ->filter(function (Discount $discount) use ($store, $cart): bool { + try { + $this->validateDiscount($discount, $store, $cart); + + return true; + } catch (InvalidDiscountException) { + return false; + } + }) + ->values(); + } + public function validateResult(string $code, Store $store, Cart $cart): DiscountValidationResult { try { @@ -124,4 +161,15 @@ private function value(mixed $value): string { return $value instanceof BackedEnum ? (string) $value->value : (string) $value; } + + public function customerHasRedeemed(Discount $discount, int $customerId): bool + { + return OrderLine::query() + ->whereHas('order', fn ($orders) => $orders->withoutGlobalScopes() + ->where('store_id', $discount->store_id) + ->where('customer_id', $customerId)) + ->get(['discount_allocations_json']) + ->contains(fn (OrderLine $line): bool => collect((array) $line->discount_allocations_json) + ->contains(fn (mixed $allocation): bool => (int) data_get($allocation, 'discount_id') === (int) $discount->id)); + } } diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php index 69ae80a7..2adda378 100644 --- a/app/Services/FulfillmentService.php +++ b/app/Services/FulfillmentService.php @@ -2,10 +2,10 @@ namespace App\Services; -use App\Events\OrderFulfilled; use App\Events\FulfillmentCreated; use App\Events\FulfillmentDelivered; use App\Events\FulfillmentShipped; +use App\Events\OrderFulfilled; use App\Exceptions\FulfillmentGuardException; use App\Models\Fulfillment; use App\Models\Order; @@ -49,7 +49,7 @@ public function create(Order $order, array $lines, ?array $tracking = null): Ful } /** @param array|null $tracking */ - public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void + public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null, bool $notifyCustomer = true): void { if ($this->value($fulfillment->status) !== 'pending') { throw new FulfillmentGuardException('Only pending fulfillments can be shipped.'); @@ -61,7 +61,7 @@ public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null) 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, 'shipped_at' => now(), ], fn (mixed $value): bool => $value !== null))->save(); - event(new FulfillmentShipped($fulfillment)); + event(new FulfillmentShipped($fulfillment, $notifyCustomer)); } public function markAsDelivered(Fulfillment $fulfillment): void diff --git a/app/Services/NativeDnsResolver.php b/app/Services/NativeDnsResolver.php new file mode 100644 index 00000000..797f4bc1 --- /dev/null +++ b/app/Services/NativeDnsResolver.php @@ -0,0 +1,31 @@ + is_string($address) && filter_var($address, FILTER_VALIDATE_IP) !== false)); + } + } + + return array_values(array_unique($addresses)); + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index d446f9b5..cb8b68b6 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -6,8 +6,10 @@ use App\Events\OrderCreated; use App\Events\OrderPaid; use App\Exceptions\DomainException; +use App\Exceptions\InvalidDiscountException; use App\Models\Checkout; use App\Models\Customer; +use App\Models\Discount; use App\Models\Order; use App\Models\Store; use App\ValueObjects\PaymentResult; @@ -19,6 +21,7 @@ final class OrderService public function __construct( private readonly InventoryService $inventory, private readonly FulfillmentService $fulfillments, + private readonly DiscountService $discounts, ) {} public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentResult = null): Order @@ -39,6 +42,24 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe $pending = $method === 'bank_transfer'; $paymentResult ??= new PaymentResult(true, 'mock_manual', $pending ? 'pending' : 'captured'); $customer = $this->resolveCustomer($checkout); + $appliedDiscounts = (array) ($snapshot['applied_discounts'] ?? []); + if ($appliedDiscounts === [] && $checkout->discount_code !== null) { + $legacyDiscount = Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereRaw('LOWER(code) = ?', [mb_strtolower($checkout->discount_code)]) + ->first(); + if ($legacyDiscount !== null) { + $appliedDiscounts = [[ + 'discount_id' => $legacyDiscount->id, + 'code' => $legacyDiscount->code, + 'amount' => (int) ($snapshot['discount'] ?? 0), + 'allocations' => $checkout->cart->lines->mapWithKeys(fn ($line): array => [ + (int) $line->id => (int) $line->line_discount_amount, + ])->all(), + ]]; + } + } + $lockedDiscounts = $this->lockAndValidateDiscounts($checkout, $appliedDiscounts, $customer?->id); $order = Order::withoutGlobalScopes()->create([ 'store_id' => $checkout->store_id, 'customer_id' => $customer?->id, @@ -74,10 +95,15 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'total_amount' => $line->line_total_amount, - 'tax_lines_json' => (array) ($snapshot['tax_lines'] ?? []), - 'discount_allocations_json' => $line->line_discount_amount > 0 - ? [['code' => $checkout->discount_code, 'amount' => $line->line_discount_amount]] - : [], + 'tax_lines_json' => (array) data_get($snapshot, 'line_tax_allocations.'.$line->id, $snapshot['tax_lines'] ?? []), + 'discount_allocations_json' => collect($appliedDiscounts) + ->map(fn (array $discount): array => [ + 'discount_id' => (int) ($discount['discount_id'] ?? 0), + 'amount' => (int) data_get($discount, 'allocations.'.$line->id, 0), + ]) + ->filter(fn (array $allocation): bool => $allocation['discount_id'] > 0 && $allocation['amount'] > 0) + ->values() + ->all(), ]); if (! $pending && $variant->inventoryItem !== null) { @@ -95,11 +121,8 @@ public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentRe 'raw_json_encrypted' => $paymentResult->raw, ]); - if ($checkout->discount_code !== null) { - \App\Models\Discount::withoutGlobalScopes() - ->where('store_id', $checkout->store_id) - ->whereRaw('LOWER(code) = ?', [mb_strtolower($checkout->discount_code)]) - ->increment('usage_count'); + foreach ($lockedDiscounts as $discount) { + $discount->increment('usage_count'); } $checkout->cart->update(['status' => 'converted']); @@ -184,6 +207,43 @@ private function resolveCustomer(Checkout $checkout): ?Customer ); } + /** + * @param list> $appliedDiscounts + * @return list + */ + private function lockAndValidateDiscounts(Checkout $checkout, array $appliedDiscounts, ?int $customerId): array + { + $discountIds = collect($appliedDiscounts) + ->pluck('discount_id') + ->map(fn (mixed $id): int => (int) $id) + ->filter() + ->unique() + ->values(); + if ($discountIds->isEmpty()) { + return []; + } + + $locked = Discount::withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->whereKey($discountIds) + ->orderBy('id') + ->lockForUpdate() + ->get(); + if ($locked->count() !== $discountIds->count()) { + throw new DomainException('An applied discount is no longer available.'); + } + + foreach ($locked as $discount) { + try { + $this->discounts->validateDiscount($discount, $checkout->store, $checkout->cart, $customerId); + } catch (InvalidDiscountException $exception) { + throw new DomainException('An applied discount is no longer valid: '.$exception->reason); + } + } + + return $locked->all(); + } + private function value(mixed $value): string { return $value instanceof BackedEnum ? (string) $value->value : (string) $value; diff --git a/app/Services/OrderStatusLink.php b/app/Services/OrderStatusLink.php new file mode 100644 index 00000000..923ace23 --- /dev/null +++ b/app/Services/OrderStatusLink.php @@ -0,0 +1,41 @@ +payload($order), (string) config('app.key')); + } + + public function verify(Order $order, mixed $token): bool + { + return is_string($token) && hash_equals($this->token($order), $token); + } + + public function url(Order $order): string + { + $hostname = StoreDomain::withoutGlobalScopes() + ->where('store_id', $order->store_id) + ->where('type', 'storefront') + ->orderByDesc('is_primary') + ->value('hostname'); + $configuredRoot = rtrim((string) config('app.url'), '/'); + $scheme = parse_url($configuredRoot, PHP_URL_SCHEME) ?: 'https'; + $root = is_string($hostname) && $hostname !== '' ? $scheme.'://'.$hostname : $configuredRoot; + + return $root.'/api/storefront/v1/orders/'.rawurlencode($order->order_number).'?'.http_build_query([ + 'token' => $this->token($order), + ], '', '&', PHP_QUERY_RFC3986); + } + + private function payload(Order $order): string + { + // Keep the documented v1 token stable while centralizing all producers and verification. + return $order->order_number.'|'.$order->email; + } +} diff --git a/app/Services/OutboundDispatcher.php b/app/Services/OutboundDispatcher.php new file mode 100644 index 00000000..de5c7fed --- /dev/null +++ b/app/Services/OutboundDispatcher.php @@ -0,0 +1,30 @@ +runningUnitTests() && DB::transactionLevel() <= 1) { + $safeCallback(); + + return; + } + + DB::afterCommit($safeCallback); + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php index 46e9f1fb..42839ac4 100644 --- a/app/Services/PricingEngine.php +++ b/app/Services/PricingEngine.php @@ -3,10 +3,8 @@ namespace App\Services; use App\Models\Checkout; -use App\Models\Discount; use App\Models\ShippingRate; use App\Models\TaxSettings; -use App\ValueObjects\DiscountResult; use App\ValueObjects\PricingResult; final class PricingEngine @@ -22,37 +20,99 @@ public function calculate(Checkout $checkout): PricingResult $checkout->loadMissing(['cart.lines.variant.product.collections', 'store']); $cart = $checkout->cart; $subtotal = (int) $cart->lines->sum(fn ($line): int => (int) $line->unit_price_amount * (int) $line->quantity); - $discountResult = new DiscountResult(0); + $remainingAmounts = $cart->lines->mapWithKeys(fn ($line): array => [ + (int) $line->id => (int) $line->line_subtotal_amount, + ])->all(); + $lineDiscounts = array_fill_keys(array_keys($remainingAmounts), 0); + $appliedDiscounts = []; + $freeShipping = false; + $discounts = collect(); if ($checkout->discount_code !== null && $checkout->discount_code !== '') { - $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart); - $discountResult = $this->discounts->calculate($discount, $subtotal, $cart->lines); - $this->applyAllocations($cart->lines, $discount, $discountResult); - } else { - foreach ($cart->lines as $line) { - $line->update(['line_discount_amount' => 0, 'line_total_amount' => $line->line_subtotal_amount]); + $discounts->push($this->discounts->validate($checkout->discount_code, $checkout->store, $cart)); + } + foreach ($this->discounts->automaticDiscounts($checkout->store, $cart) as $automatic) { + if (! $discounts->contains('id', $automatic->id)) { + $discounts->push($automatic); + } + } + + foreach ($discounts as $discount) { + $syntheticLines = $cart->lines->map(fn ($line): array => [ + 'id' => (int) $line->id, + 'product_id' => (int) $line->variant->product_id, + 'product' => $line->variant->product, + 'line_subtotal_amount' => (int) ($remainingAmounts[$line->id] ?? 0), + ]); + $discountResult = $this->discounts->calculate($discount, array_sum($remainingAmounts), $syntheticLines); + if ($discountResult->amount < 1 && ! $discountResult->freeShipping) { + continue; + } + $allocations = []; + foreach ($discountResult->allocations as $lineId => $allocation) { + $allocation = min((int) ($remainingAmounts[$lineId] ?? 0), (int) $allocation); + if ($allocation < 1) { + continue; + } + $remainingAmounts[$lineId] -= $allocation; + $lineDiscounts[$lineId] += $allocation; + $allocations[(int) $lineId] = $allocation; } + $freeShipping = $freeShipping || $discountResult->freeShipping; + $appliedDiscounts[] = [ + 'discount_id' => (int) $discount->id, + 'type' => $discount->type instanceof \BackedEnum ? $discount->type->value : (string) $discount->type, + 'code' => $discount->code, + 'amount' => array_sum($allocations), + 'free_shipping' => $discountResult->freeShipping, + 'allocations' => $allocations, + ]; } + $this->applyAllocations($cart->lines, $lineDiscounts); + $discountAmount = array_sum($lineDiscounts); $shipping = 0; if ($checkout->shipping_method_id !== null && $this->shipping->requiresShipping($cart)) { - $rate = ShippingRate::query()->find($checkout->shipping_method_id); + $rate = ShippingRate::withoutGlobalScopes() + ->whereHas('zone', fn ($zones) => $zones->withoutGlobalScopes()->where('store_id', $checkout->store_id)) + ->find($checkout->shipping_method_id); $shipping = $rate === null ? 0 : (int) ($this->shipping->calculate($rate, $cart) ?? 0); } - if ($discountResult->freeShipping) { + if ($freeShipping) { $shipping = 0; } - $lineAmounts = $cart->lines->map(fn ($line): int => max(0, (int) $line->line_subtotal_amount - (int) ($discountResult->allocations[$line->id] ?? 0)))->all(); + $lineAmounts = $cart->lines->map(fn ($line): int => (int) ($remainingAmounts[$line->id] ?? 0))->all(); $settings = TaxSettings::withoutGlobalScopes()->where('store_id', $checkout->store_id)->first(); - $taxResult = $this->taxes->calculateLines($lineAmounts, $shipping, $settings, (array) ($checkout->shipping_address_json ?? [])); + $taxAddress = (array) ($checkout->shipping_address_json ?? $checkout->billing_address_json ?? []); + $taxResult = $this->taxes->calculateLines($lineAmounts, $shipping, $settings, $taxAddress); $inclusive = (bool) ($settings?->prices_include_tax ?? false); - $discountedSubtotal = max(0, $subtotal - $discountResult->amount); + $discountedSubtotal = max(0, $subtotal - $discountAmount); $total = $discountedSubtotal + $shipping + ($inclusive ? 0 : $taxResult->totalAmount); + $taxDefinition = $taxResult->taxLines[0] ?? null; + $jurisdiction = strtoupper((string) ($taxAddress['country_code'] ?? $taxAddress['country'] ?? '')); + $lineTaxAllocations = []; + $taxSnapshotLines = []; + foreach ($cart->lines->values() as $index => $line) { + $taxAmount = (int) ($taxResult->lineAmounts[$index] ?? 0); + $lineTaxAllocations[(int) $line->id] = $taxDefinition === null || $taxAmount < 1 ? [] : [[ + 'name' => $taxDefinition->name, + 'rate' => $taxDefinition->rate, + 'amount' => $taxAmount, + ]]; + $taxSnapshotLines[] = [ + 'cart_line_id' => (int) $line->id, + 'variant_id' => (int) $line->variant_id, + 'tax_amount' => $taxAmount, + 'rate' => (int) ($taxDefinition?->rate ?? 0), + 'jurisdiction' => $jurisdiction, + ]; + } + $result = new PricingResult( subtotal: $subtotal, - discount: $discountResult->amount, + discount: $discountAmount, shipping: $shipping, taxLines: $taxResult->taxLines, taxTotal: $taxResult->totalAmount, @@ -60,7 +120,19 @@ public function calculate(Checkout $checkout): PricingResult currency: (string) $cart->currency, ); - $checkout->totals_json = $result->toArray(); + $checkout->tax_provider_snapshot_json = [ + 'provider' => $taxResult->provider, + 'calculated_at' => now()->toIso8601String(), + 'lines' => $taxSnapshotLines, + 'shipping_tax_amount' => $taxResult->shippingAmount, + 'shipping_tax_rate' => (int) ($taxDefinition?->rate ?? 0), + 'response' => $taxResult->providerResponse, + ]; + $checkout->totals_json = [ + ...$result->toArray(), + 'applied_discounts' => $appliedDiscounts, + 'line_tax_allocations' => $lineTaxAllocations, + ]; $checkout->save(); return $result; @@ -74,10 +146,11 @@ public function calculateSubtotal(iterable $lines): int : (int) ($line->unit_price_amount ?? $line->price_amount ?? 0) * (int) ($line->quantity ?? 0)); } - private function applyAllocations(iterable $lines, Discount $discount, DiscountResult $result): void + /** @param array $allocations */ + private function applyAllocations(iterable $lines, array $allocations): void { foreach ($lines as $line) { - $allocation = (int) ($result->allocations[$line->id] ?? 0); + $allocation = (int) ($allocations[$line->id] ?? 0); $line->update([ 'line_discount_amount' => $allocation, 'line_total_amount' => max(0, (int) $line->line_subtotal_amount - $allocation), diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 7ee55331..e0cd7ffd 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -3,8 +3,10 @@ namespace App\Services; use App\Enums\ProductStatus; +use App\Events\ProductDeleted; use App\Events\ProductStatusChanged; use App\Exceptions\InvalidProductTransitionException; +use App\Models\Collection as ProductCollection; use App\Models\InventoryItem; use App\Models\OrderLine; use App\Models\Product; @@ -36,26 +38,23 @@ public function create(Store $store, array $data): Product $this->syncOptions($product, (array) ($data['options'] ?? [])); if ($product->options()->exists()) { $this->variants->rebuildMatrix($product); + if (! empty($data['variants'])) { + $this->syncVariants($product, (array) $data['variants']); + } } else { - $variantData = (array) ($data['variant'] ?? ($data['variants'][0] ?? [])); - $variant = $product->variants()->create([ - 'sku' => $variantData['sku'] ?? null, - 'barcode' => $variantData['barcode'] ?? null, - 'price_amount' => (int) ($variantData['price_amount'] ?? 0), - 'compare_at_amount' => $variantData['compare_at_amount'] ?? null, - 'currency' => $variantData['currency'] ?? $store->default_currency, - 'weight_g' => $variantData['weight_g'] ?? null, - 'requires_shipping' => $variantData['requires_shipping'] ?? true, - 'is_default' => true, - 'position' => 0, - 'status' => 'active', - ]); - InventoryItem::withoutGlobalScopes()->updateOrCreate( - ['variant_id' => $variant->id], - ['store_id' => $store->id, 'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0), 'quantity_reserved' => 0, 'policy' => $variantData['policy'] ?? 'deny'], - ); + $payloads = (array) ($data['variants'] ?? []); + if ($payloads === []) { + $payloads = [(array) ($data['variant'] ?? [])]; + } + foreach (array_values($payloads) as $position => $variantData) { + $this->createVariant($product, (array) $variantData, $position, $position === 0); + } } + $this->normalizeDefaultVariant($product); + + $this->syncCollections($product, $data); + return $product->refresh()->load(['variants.inventoryItem', 'options.values']); }); } @@ -76,9 +75,21 @@ public function update(Product $product, array $data): Product $this->syncOptions($product, (array) $data['options']); $this->variants->rebuildMatrix($product); } + if (array_key_exists('variants', $data)) { + $this->syncVariants($product, (array) $data['variants']); + } elseif (array_key_exists('variant', $data)) { + $variant = $product->variants()->orderByDesc('is_default')->orderBy('position')->first(); + if ($variant === null) { + $this->createVariant($product, (array) $data['variant'], 0, true); + } else { + $this->updateVariant($product, $variant, (array) $data['variant'], 0); + } + } + $this->syncCollections($product, $data); if (array_key_exists('status', $data)) { $this->transitionStatus($product, $data['status'] instanceof ProductStatus ? $data['status'] : ProductStatus::from((string) $data['status'])); } + $this->normalizeDefaultVariant($product); return $product->refresh()->load(['variants.inventoryItem', 'options.values']); }); @@ -112,6 +123,9 @@ public function transitionStatus(Product $product, ProductStatus $newStatus): vo } $product->save(); event(new ProductStatusChanged($product, $current, $newStatus->value)); + if ($newStatus === ProductStatus::Archived) { + event(new ProductDeleted($product)); + } } public function delete(Product $product): void @@ -128,6 +142,17 @@ private function syncOptions(Product $product, array $options): void if (count($options) > 3) { throw new \InvalidArgumentException('A product may have at most three options.'); } + $combinationCount = 1; + foreach ($options as $option) { + $values = collect((array) ($option['values'] ?? [])) + ->map(fn (mixed $value): string => trim((string) (is_array($value) ? ($value['value'] ?? '') : $value))) + ->filter() + ->unique(fn (string $value): string => mb_strtolower($value)); + $combinationCount *= max(1, $values->count()); + if ($combinationCount > 100) { + throw new \InvalidArgumentException('Product options may generate at most 100 variants.'); + } + } $existingOptions = $product->options()->with('values')->get(); $keptOptionIds = []; @@ -142,6 +167,9 @@ private function syncOptions(Product $product, array $options): void $option = isset($optionData['id']) ? $existingOptions->firstWhere('id', (int) $optionData['id']) : $existingOptions->first(fn ($candidate) => mb_strtolower($candidate->name) === mb_strtolower($name)); + if (isset($optionData['id']) && $option === null) { + throw new \InvalidArgumentException('A product option does not belong to this product.'); + } $option ??= $product->options()->make(); $option->fill(['name' => $name, 'position' => $position])->save(); $keptOptionIds[] = $option->id; @@ -157,6 +185,9 @@ private function syncOptions(Product $product, array $options): void $optionValue = is_array($valueData) && isset($valueData['id']) ? $existingValues->firstWhere('id', (int) $valueData['id']) : $existingValues->first(fn ($candidate) => mb_strtolower($candidate->value) === mb_strtolower($value)); + if (is_array($valueData) && isset($valueData['id']) && $optionValue === null) { + throw new \InvalidArgumentException('A product option value does not belong to this product option.'); + } $optionValue ??= $option->values()->make(); $optionValue->fill(['value' => $value, 'position' => $valuePosition])->save(); $keptValueIds[] = $optionValue->id; @@ -168,6 +199,182 @@ private function syncOptions(Product $product, array $options): void $product->options()->whereNotIn('id', $keptOptionIds)->delete(); } + /** @param list> $payloads */ + private function syncVariants(Product $product, array $payloads): void + { + $product->load(['options.values', 'variants.optionValues', 'variants.inventoryItem']); + $used = []; + $preferredDefaultId = null; + + foreach (array_values($payloads) as $position => $payload) { + $payload = (array) $payload; + $valueIds = $this->optionValueIds($product, (array) ($payload['option_values'] ?? [])); + $variant = isset($payload['id']) + ? $product->variants->firstWhere('id', (int) $payload['id']) + : null; + if (isset($payload['id']) && $variant === null) { + throw new \InvalidArgumentException('A product variant does not belong to this product.'); + } + if ($variant === null && $valueIds !== []) { + $key = $this->variantKey($valueIds); + $variant = $product->variants->first(fn (ProductVariant $candidate): bool => ! in_array($candidate->id, $used, true) + && $this->variantKey($candidate->optionValues->modelKeys()) === $key); + } + if ($variant === null && $product->options->isEmpty()) { + $variant = $product->variants->first(fn (ProductVariant $candidate): bool => ! in_array($candidate->id, $used, true)); + } + $variant ??= $this->createVariant($product, $payload, $position, (bool) ($payload['is_default'] ?? $position === 0)); + $this->updateVariant($product, $variant, $payload, $position); + if ((bool) ($payload['is_default'] ?? false)) { + $preferredDefaultId = (int) $variant->id; + } + if ($valueIds !== []) { + $variant->optionValues()->sync($valueIds); + } + $used[] = $variant->id; + } + + foreach ($product->variants as $variant) { + if (in_array($variant->id, $used, true)) { + continue; + } + if (OrderLine::query()->where('variant_id', $variant->id)->exists()) { + $variant->update(['status' => 'archived', 'is_default' => false]); + } else { + $variant->delete(); + } + } + if ($preferredDefaultId !== null) { + $product->variants()->whereKeyNot($preferredDefaultId)->update(['is_default' => false]); + $product->variants()->whereKey($preferredDefaultId)->update(['is_default' => true]); + } + } + + /** @param array $data */ + private function createVariant(Product $product, array $data, int $position, bool $default): ProductVariant + { + $store = Store::query()->findOrFail($product->store_id); + $variant = $product->variants()->create([ + 'sku' => $data['sku'] ?? null, + 'barcode' => $data['barcode'] ?? null, + 'price_amount' => (int) ($data['price_amount'] ?? 0), + 'compare_at_amount' => $data['compare_at_amount'] ?? null, + 'currency' => $data['currency'] ?? $store->default_currency, + 'weight_g' => $data['weight_g'] ?? null, + 'requires_shipping' => $data['requires_shipping'] ?? true, + 'is_default' => $data['is_default'] ?? $default, + 'position' => $data['position'] ?? $position, + 'status' => $data['status'] ?? 'active', + ]); + $inventory = (array) ($data['inventory'] ?? []); + InventoryItem::withoutGlobalScopes()->updateOrCreate( + ['variant_id' => $variant->id], + [ + 'store_id' => $product->store_id, + 'quantity_on_hand' => (int) ($inventory['quantity_on_hand'] ?? $data['quantity_on_hand'] ?? 0), + 'quantity_reserved' => 0, + 'policy' => $inventory['policy'] ?? $data['policy'] ?? 'deny', + ], + ); + + return $variant->refresh(); + } + + /** @param array $data */ + private function updateVariant(Product $product, ProductVariant $variant, array $data, int $position): void + { + $variant->update(Arr::only([ + ...$data, + 'position' => $data['position'] ?? $position, + ], [ + 'sku', 'barcode', 'price_amount', 'compare_at_amount', 'currency', 'weight_g', + 'requires_shipping', 'is_default', 'position', 'status', + ])); + $inventory = (array) ($data['inventory'] ?? []); + if ($inventory !== [] || array_key_exists('quantity_on_hand', $data) || array_key_exists('policy', $data)) { + $item = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->id)->firstOrFail(); + $item->update([ + 'quantity_on_hand' => (int) ($inventory['quantity_on_hand'] ?? $data['quantity_on_hand'] ?? $item->quantity_on_hand), + 'quantity_reserved' => (int) $item->quantity_reserved, + 'policy' => $inventory['policy'] ?? $data['policy'] ?? $item->policy, + ]); + } + } + + /** @param list> $values @return list */ + private function optionValueIds(Product $product, array $values): array + { + $ids = []; + foreach ($values as $value) { + if (isset($value['id'])) { + $id = (int) $value['id']; + if (! $product->options->flatMap->values->contains('id', $id)) { + throw new \InvalidArgumentException('A variant option value does not match the product options.'); + } + $ids[] = $id; + + continue; + } + $option = $product->options->first(fn ($candidate) => mb_strtolower($candidate->name) === mb_strtolower((string) ($value['option_name'] ?? ''))); + $optionValue = $option?->values->first(fn ($candidate) => mb_strtolower($candidate->value) === mb_strtolower((string) ($value['value'] ?? ''))); + if ($optionValue === null) { + throw new \InvalidArgumentException('A variant option value does not match the product options.'); + } + $ids[] = (int) $optionValue->id; + } + + sort($ids); + + return $ids; + } + + /** @param array $ids */ + private function variantKey(array $ids): string + { + sort($ids); + + return implode(':', $ids); + } + + /** @param array $data */ + private function syncCollections(Product $product, array $data): void + { + if (! array_key_exists('collections', $data)) { + return; + } + + $ids = collect(array_values((array) $data['collections']))->map(fn (mixed $id): int => (int) $id)->unique()->values(); + $validIds = ProductCollection::withoutGlobalScopes() + ->where('store_id', $product->store_id) + ->whereKey($ids) + ->pluck('id'); + if ($validIds->count() !== $ids->count()) { + throw new \InvalidArgumentException('A selected collection does not belong to this store.'); + } + + $product->collections()->sync($ids + ->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]]) + ->all()); + } + + private function normalizeDefaultVariant(Product $product): void + { + $variants = $product->variants()->reorder()->orderByRaw("CASE WHEN status = 'active' THEN 0 ELSE 1 END") + ->orderByDesc('is_default') + ->orderBy('position') + ->orderBy('id') + ->get(); + $default = $variants->first(); + if ($default === null) { + throw new \InvalidArgumentException('A product must have at least one variant.'); + } + + $product->variants()->whereKeyNot($default->id)->where('is_default', true)->update(['is_default' => false]); + if (! $default->is_default) { + $default->update(['is_default' => true]); + } + } + private function hasOrderReferences(Product $product): bool { return OrderLine::query()->where('product_id', $product->id) diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php index 12eff580..7dcdae29 100644 --- a/app/Services/RefundService.php +++ b/app/Services/RefundService.php @@ -26,15 +26,22 @@ public function create( ?string $reason = null, bool $restock = false, ?array $lines = null, + bool $notifyCustomer = true, ): Refund { - if ((int) $payment->order_id !== (int) $order->id || ! in_array($this->value($payment->status), ['captured', 'refunded'], true)) { - throw new DomainException('The selected payment is not refundable for this order.'); + if ($restock && ($lines === null || $lines === [])) { + throw new DomainException('Explicit line quantities are required when restocking a refund.'); } - $order->load('lines'); - $lineAmounts = []; - if ($lines !== null) { - foreach ($lines as $lineId => $quantity) { + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $lines, $notifyCustomer): Refund { + $order = Order::withoutGlobalScopes()->lockForUpdate()->findOrFail($order->id); + $payment = Payment::query()->lockForUpdate()->findOrFail($payment->id); + if ((int) $payment->order_id !== (int) $order->id || ! in_array($this->value($payment->status), ['captured', 'refunded'], true)) { + throw new DomainException('The selected payment is not refundable for this order.'); + } + + $order->load('lines'); + $lineAmounts = []; + foreach ($lines ?? [] as $lineId => $quantity) { $line = $order->lines->firstWhere('id', (int) $lineId); $alreadyRefunded = $line === null ? 0 : (int) DB::table('refund_lines') ->join('refunds', 'refunds.id', '=', 'refund_lines.refund_id') @@ -46,19 +53,17 @@ public function create( } $lineAmounts[(int) $lineId] = (int) round((int) $line->total_amount * $quantity / max(1, (int) $line->quantity)); } - } - $refunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); - $remaining = min((int) $order->total_amount, (int) $payment->amount) - $refunded; - $amount ??= $lineAmounts === [] ? $remaining : array_sum($lineAmounts); - if ($amount < 1 || $amount > $remaining) { - throw new DomainException('The refund amount exceeds the refundable balance.'); - } + $refunded = (int) $order->refunds()->where('status', 'processed')->sum('amount'); + $remaining = min((int) $order->total_amount, (int) $payment->amount) - $refunded; + $refundAmount = $amount ?? ($lineAmounts === [] ? $remaining : array_sum($lineAmounts)); + if ($refundAmount < 1 || $refundAmount > $remaining) { + throw new DomainException('The refund amount exceeds the refundable balance.'); + } - return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $lines, $lineAmounts): Refund { $refund = $order->refunds()->create([ 'payment_id' => $payment->id, - 'amount' => $amount, + 'amount' => $refundAmount, 'reason' => $reason, 'status' => 'pending', ]); @@ -69,7 +74,7 @@ public function create( 'amount' => $lineAmounts[(int) $lineId], ]); } - $result = $this->provider->refund($payment, $amount); + $result = $this->provider->refund($payment, $refundAmount); $refund->update([ 'status' => $result->success ? 'processed' : 'failed', 'provider_refund_id' => $result->providerRefundId, @@ -95,7 +100,7 @@ public function create( } } } - event(new OrderRefunded($order, $refund)); + event(new OrderRefunded($order, $refund, $notifyCustomer)); return $refund->refresh()->load('lines'); }); diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php index ab798b10..2d087e60 100644 --- a/app/Services/SearchService.php +++ b/app/Services/SearchService.php @@ -4,6 +4,7 @@ use App\Models\Product; use App\Models\SearchQuery; +use App\Models\SearchSettings; use App\Models\Store; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -14,27 +15,65 @@ final class SearchService { /** @param array $filters */ public function search(Store $store, string $query, array $filters = [], int $perPage = 12): LengthAwarePaginator + { + return $this->searchWithFacets($store, $query, $filters, 'relevance', $perPage)['paginator']; + } + + /** + * @param array $filters + * @return array{paginator: LengthAwarePaginator, facets: array} + */ + public function searchWithFacets(Store $store, string $query, array $filters = [], string $sort = 'relevance', int $perPage = 24): array { $query = trim($query); $page = max(1, LengthAwarePaginator::resolveCurrentPage()); - $ids = $this->matchingIds($store, $query); + $searchTerms = $this->searchTerms($store, $query); + $ids = $this->matchingIds($store, $searchTerms); $products = Product::withoutGlobalScopes() ->where('store_id', $store->id) ->where('status', 'active') ->whereNotNull('published_at') ->when($ids !== null, fn ($builder) => $builder->whereIn('id', $ids)) - ->when($ids === null && $query !== '', fn ($builder) => $builder->where(function ($nested) use ($query): void { - $nested->where('title', 'like', "%{$query}%") - ->orWhere('description_html', 'like', "%{$query}%") - ->orWhere('vendor', 'like', "%{$query}%"); + ->when($ids === null && $searchTerms !== [], fn ($builder) => $builder->where(function ($nested) use ($searchTerms): void { + foreach ($searchTerms as $termGroup) { + $nested->where(function ($termQuery) use ($termGroup): void { + foreach ($termGroup as $term) { + $termQuery->orWhere('title', 'like', "%{$term}%") + ->orWhere('description_html', 'like', "%{$term}%") + ->orWhere('vendor', 'like', "%{$term}%") + ->orWhere('product_type', 'like', "%{$term}%"); + } + }); + } })) ->when(isset($filters['vendor']), fn ($builder) => $builder->where('vendor', $filters['vendor'])) - ->with(['variants', 'media']) + ->when(isset($filters['collection_id']), fn ($builder) => $builder->whereHas('collections', fn ($collectionQuery) => $collectionQuery + ->where('collections.id', (int) $filters['collection_id']) + ->where('collections.store_id', $store->id))) + ->when(isset($filters['price_min']), fn ($builder) => $builder->whereHas('variants', fn ($variantQuery) => $variantQuery->where('price_amount', '>=', (int) $filters['price_min']))) + ->when(isset($filters['price_max']), fn ($builder) => $builder->whereHas('variants', fn ($variantQuery) => $variantQuery->where('price_amount', '<=', (int) $filters['price_max']))) + ->when(($filters['in_stock'] ?? false) === true, fn ($builder) => $builder->whereHas('variants.inventoryItem', fn ($inventoryQuery) => $inventoryQuery + ->where(fn ($available) => $available->where('policy', 'continue')->orWhereColumn('quantity_on_hand', '>', 'quantity_reserved')))) + ->when(isset($filters['tags']), function ($builder) use ($filters): void { + foreach ((array) $filters['tags'] as $tag) { + $builder->whereJsonContains('tags', (string) $tag); + } + }) + ->with(['variants.inventoryItem', 'media']) + ->withSum('orderLines as units_sold', 'quantity') ->get(); - if ($ids !== null) { + if ($sort === 'relevance' && $ids !== null) { $order = array_flip($ids); $products = $products->sortBy(fn (Product $product): int => $order[$product->id] ?? PHP_INT_MAX)->values(); + } elseif ($sort === 'price_asc') { + $products = $products->sortBy(fn (Product $product): int => (int) ($product->variants->min('price_amount') ?? PHP_INT_MAX))->values(); + } elseif ($sort === 'price_desc') { + $products = $products->sortByDesc(fn (Product $product): int => (int) ($product->variants->min('price_amount') ?? 0))->values(); + } elseif ($sort === 'newest') { + $products = $products->sortByDesc('published_at')->values(); + } elseif ($sort === 'best_selling') { + $products = $products->sortByDesc(fn (Product $product): int => (int) ($product->units_sold ?? 0))->values(); } $total = $products->count(); $items = $products->forPage($page, $perPage)->values(); @@ -46,7 +85,10 @@ public function search(Store $store, string $query, array $filters = [], int $pe 'results_count' => $total, ]); - return new LengthAwarePaginator($items, $total, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()]); + return [ + 'paginator' => new LengthAwarePaginator($items, $total, $perPage, $page, ['path' => request()->url(), 'query' => request()->query()]), + 'facets' => $this->facets($products), + ]; } /** @return Collection */ @@ -57,6 +99,23 @@ public function autocomplete(Store $store, string $prefix, int $limit = 8): Coll return collect($results->items()); } + /** @return Collection> */ + public function suggestions(Store $store, string $prefix, int $limit = 5): Collection + { + $products = collect($this->searchWithFacets($store, $prefix, [], 'relevance', $limit)['paginator']->items()) + ->map(fn (Product $product): array => ['type' => 'product', 'model' => $product]); + $remaining = max(0, $limit - $products->count()); + $collections = \App\Models\Collection::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('status', 'active') + ->where('title', 'like', '%'.trim($prefix).'%') + ->limit($remaining) + ->get() + ->map(fn ($collection): array => ['type' => 'collection', 'model' => $collection]); + + return $products->concat($collections)->take($limit)->values(); + } + public function syncProduct(Product $product): void { try { @@ -85,24 +144,57 @@ public function removeProduct(int $productId): void } /** @return list|null */ - private function matchingIds(Store $store, string $query): ?array + private function matchingIds(Store $store, array $termGroups): ?array { - if ($query === '') { + if ($termGroups === []) { return null; } try { - $tokens = preg_split('/\s+/', preg_replace('/[^\pL\pN\s-]+/u', ' ', $query) ?: '') ?: []; - $match = implode(' ', array_map(fn (string $token): string => '"'.str_replace('"', '""', $token).'"*', array_filter($tokens))); + $match = implode(' ', array_map(function (array $terms): string { + $escaped = array_map(fn (string $term): string => '"'.str_replace('"', '""', $term).'"*', $terms); + + return count($escaped) === 1 ? $escaped[0] : '('.implode(' OR ', $escaped).')'; + }, $termGroups)); if ($match === '') { return []; } - $rows = DB::select("SELECT product_id FROM products_fts WHERE products_fts MATCH ? AND store_id = ? ORDER BY rank", [$match, $store->id]); + $rows = DB::select('SELECT product_id FROM products_fts WHERE products_fts MATCH ? AND store_id = ? ORDER BY rank', [$match, $store->id]); return array_map(fn (object $row): int => (int) $row->product_id, $rows); } catch (Throwable) { return null; } } + + /** @return list> */ + private function searchTerms(Store $store, string $query): array + { + $settings = SearchSettings::withoutGlobalScopes()->where('store_id', $store->id)->first(); + $stopWords = collect((array) $settings?->stop_words_json)->map(fn ($word): string => mb_strtolower(trim((string) $word)))->filter(); + $synonyms = collect((array) $settings?->synonyms_json)->map(fn ($group) => collect((array) $group) + ->map(fn ($word): string => mb_strtolower(trim((string) $word)))->filter()->values()->all()); + $tokens = preg_split('/\s+/', mb_strtolower(preg_replace('/[^\pL\pN\s-]+/u', ' ', $query) ?: '')) ?: []; + + return collect($tokens)->map(fn (string $token): string => trim($token))->filter() + ->reject(fn (string $token): bool => $stopWords->contains($token)) + ->map(function (string $token) use ($synonyms): array { + $group = $synonyms->first(fn (array $words): bool => in_array($token, $words, true)); + + return $group === null ? [$token] : array_values(array_unique([$token, ...$group])); + })->values()->all(); + } + + /** @param Collection $products */ + private function facets(Collection $products): array + { + $prices = $products->flatMap(fn (Product $product) => $product->variants->pluck('price_amount'))->map(fn ($price): int => (int) $price); + + return [ + 'vendors' => $products->pluck('vendor')->filter()->countBy()->map(fn (int $count, string $value): array => compact('value', 'count'))->values()->all(), + 'tags' => $products->flatMap(fn (Product $product): array => (array) $product->tags)->countBy()->map(fn (int $count, string $value): array => compact('value', 'count'))->values()->all(), + 'price_range' => ['min' => $prices->isEmpty() ? null : $prices->min(), 'max' => $prices->isEmpty() ? null : $prices->max()], + ]; + } } diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php index e3a4a611..5e7e40fd 100644 --- a/app/Services/Tax/ManualTaxProvider.php +++ b/app/Services/Tax/ManualTaxProvider.php @@ -12,28 +12,37 @@ final class ManualTaxProvider implements TaxProvider public function calculate(TaxCalculationRequest $request): TaxCalculationResult { if ($request->taxSettings === null) { - return new TaxCalculationResult([], 0); + return new TaxCalculationResult([], 0, array_fill(0, count($request->lineItems), 0)); } $config = (array) $request->taxSettings->config_json; $rate = (int) ($config['rate_bps'] ?? $config['default_rate_bps'] ?? $config['rate'] ?? 0); if ($rate < 1) { - return new TaxCalculationResult([], 0); + return new TaxCalculationResult([], 0, array_fill(0, count($request->lineItems), 0)); } $inclusive = (bool) $request->taxSettings->prices_include_tax; - $tax = 0; + $lineAmounts = []; foreach ($request->lineItems as $amount) { - $tax += $inclusive + $lineAmounts[] = $inclusive ? $amount - intdiv($amount * 10000, 10000 + $rate) : (int) round($amount * $rate / 10000); } + $shippingTax = 0; if ($request->shippingAmount > 0) { - $tax += $inclusive + $shippingTax = $inclusive ? $request->shippingAmount - intdiv($request->shippingAmount * 10000, 10000 + $rate) : (int) round($request->shippingAmount * $rate / 10000); } + $tax = array_sum($lineAmounts) + $shippingTax; - return new TaxCalculationResult([new TaxLine((string) ($config['label'] ?? 'Tax'), $rate, $tax)], $tax); + return new TaxCalculationResult( + [new TaxLine((string) ($config['label'] ?? 'Tax'), $rate, $tax)], + $tax, + $lineAmounts, + $shippingTax, + 'manual', + ['rate' => $rate, 'prices_include_tax' => $inclusive], + ); } } diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php index f3ed4086..7658d19a 100644 --- a/app/Services/Tax/StripeTaxProvider.php +++ b/app/Services/Tax/StripeTaxProvider.php @@ -16,6 +16,13 @@ public function calculate(TaxCalculationRequest $request): TaxCalculationResult throw new RuntimeException('The configured tax provider is unavailable.'); } - return new TaxCalculationResult([], 0); + return new TaxCalculationResult( + [], + 0, + array_fill(0, count($request->lineItems), 0), + 0, + 'stripe', + ['status' => 'unavailable', 'fallback' => 'allow'], + ); } } diff --git a/app/Services/ThemeArchiveService.php b/app/Services/ThemeArchiveService.php new file mode 100644 index 00000000..b4d61528 --- /dev/null +++ b/app/Services/ThemeArchiveService.php @@ -0,0 +1,134 @@ +inspect($file); + $themeName = trim((string) ($name ?: ($manifest['name'] ?? ''))); + $version = trim((string) ($manifest['version'] ?? '1.0.0')); + if ($themeName === '' || mb_strlen($themeName) > 255) { + throw ValidationException::withMessages(['file' => 'The theme manifest must contain a valid name.']); + } + if (preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version) !== 1) { + throw ValidationException::withMessages(['file' => 'The theme manifest version must use semantic versioning.']); + } + + $directory = null; + try { + return DB::transaction(function () use ($store, $themeName, $version, $manifest, $files, &$directory): Theme { + $theme = Theme::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'name' => $themeName, + 'version' => $version, + 'status' => 'draft', + ]); + $directory = "themes/{$store->id}/{$theme->id}"; + foreach ($files as $path => $contents) { + $storageKey = "{$directory}/{$path}"; + Storage::disk('local')->put($storageKey, $contents); + $theme->files()->create([ + 'path' => $path, + 'storage_key' => $storageKey, + 'sha256' => hash('sha256', $contents), + 'byte_size' => strlen($contents), + ]); + } + $theme->settings()->create(['settings_json' => (array) ($manifest['settings'] ?? [])]); + + return $theme->load(['files', 'settings']); + }); + } catch (Throwable $exception) { + if ($directory !== null) { + Storage::disk('local')->deleteDirectory($directory); + } + throw $exception; + } + } + + /** @return array{0: array, 1: array} */ + private function inspect(UploadedFile $file): array + { + $zip = new ZipArchive; + if ($zip->open($file->getRealPath()) !== true) { + throw ValidationException::withMessages(['file' => 'The uploaded theme is not a readable ZIP archive.']); + } + + try { + if ($zip->numFiles < 2 || $zip->numFiles > self::MAX_ENTRIES) { + throw ValidationException::withMessages(['file' => 'The theme archive has an invalid number of files.']); + } + $files = []; + $uncompressedBytes = 0; + for ($index = 0; $index < $zip->numFiles; $index++) { + $stat = $zip->statIndex($index); + if (! is_array($stat)) { + throw ValidationException::withMessages(['file' => 'The theme archive contains an unreadable entry.']); + } + $path = str_replace('\\', '/', (string) $stat['name']); + if (str_ends_with($path, '/')) { + continue; + } + if (! $this->safePath($path)) { + throw ValidationException::withMessages(['file' => "The theme archive contains an unsafe path: {$path}."]); + } + $uncompressedBytes += (int) ($stat['size'] ?? 0); + if ($uncompressedBytes > self::MAX_UNCOMPRESSED_BYTES) { + throw ValidationException::withMessages(['file' => 'The expanded theme archive is too large.']); + } + $contents = $zip->getFromIndex($index); + if (! is_string($contents)) { + throw ValidationException::withMessages(['file' => "The theme file {$path} could not be read."]); + } + $files[$path] = $contents; + } + } finally { + $zip->close(); + } + + if (! isset($files['theme.json'])) { + throw ValidationException::withMessages(['file' => 'The theme archive must contain theme.json.']); + } + try { + $manifest = json_decode($files['theme.json'], true, 32, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw ValidationException::withMessages(['file' => 'The theme manifest is invalid JSON.']); + } + if (! is_array($manifest) || array_is_list($manifest)) { + throw ValidationException::withMessages(['file' => 'The theme manifest must be a JSON object.']); + } + $required = (array) ($manifest['required_templates'] ?? ['templates/index.blade.php']); + foreach ($required as $template) { + if (! is_string($template) || ! str_starts_with($template, 'templates/') || ! isset($files[$template])) { + throw ValidationException::withMessages(['file' => 'The theme archive is missing a required template.']); + } + } + + return [$manifest, $files]; + } + + private function safePath(string $path): bool + { + if ($path === '' || str_starts_with($path, '/') || str_contains($path, "\0") || str_contains($path, '../')) { + return false; + } + + return preg_match('/\.(?:json|blade\.php|css|js|svg|png|jpe?g|gif|webp|avif|woff2?)$/i', $path) === 1; + } +} diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php index 0f8064b7..ed356cac 100644 --- a/app/Services/ThemeSettingsService.php +++ b/app/Services/ThemeSettingsService.php @@ -11,6 +11,24 @@ final class ThemeSettingsService /** @return array */ public function forStore(Store $store): array { + $previewId = request()->query('theme_preview'); + if (is_numeric($previewId) && auth()->guard('web')->check() + && auth()->guard('web')->user()->stores()->whereKey($store->id)->exists()) { + $theme = Theme::withoutGlobalScopes() + ->where('store_id', $store->id) + ->with('settings') + ->find((int) $previewId); + if ($theme !== null) { + $sessionSettings = request()->session()->get("theme_preview.{$theme->id}"); + + return is_array($sessionSettings) + && (int) ($sessionSettings['user_id'] ?? 0) === (int) auth()->guard('web')->id() + && is_array($sessionSettings['settings'] ?? null) + ? $sessionSettings['settings'] + : (array) ($theme->settings?->settings_json ?? []); + } + } + return Cache::remember("theme-settings:{$store->id}", now()->addMinutes(5), function () use ($store): array { $theme = Theme::withoutGlobalScopes()->where('store_id', $store->id)->where('status', 'published')->with('settings')->first(); diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php index a2ed1a42..3796d936 100644 --- a/app/Services/VariantMatrixService.php +++ b/app/Services/VariantMatrixService.php @@ -14,6 +14,10 @@ public function rebuildMatrix(Product $product): void DB::transaction(function () use ($product): void { $product->load(['options.values', 'variants.optionValues', 'variants.inventoryItem']); $groups = $product->options->sortBy('position')->map(fn ($option) => $option->values->sortBy('position')->pluck('id')->all())->all(); + $combinationCount = array_reduce($groups, fn (int $count, array $group): int => $count * count($group), 1); + if ($combinationCount > 100) { + throw new \InvalidArgumentException('Product options may generate at most 100 variants.'); + } $combinations = $groups === [] ? [[]] : $this->cartesian(array_values($groups)); $existing = $product->variants; $template = $product->variants->first(); @@ -24,7 +28,7 @@ public function rebuildMatrix(Product $product): void $matchingVariant = $existing->first(fn (ProductVariant $variant): bool => ! in_array($variant->id, $matchedVariantIds, true) && $this->key($variant->optionValues->modelKeys()) === $key); if ($matchingVariant !== null) { - $matchingVariant->update(['position' => $position, 'status' => 'active', 'is_default' => $groups === []]); + $matchingVariant->update(['position' => $position, 'status' => 'active', 'is_default' => $position === 0]); $matchedVariantIds[] = $matchingVariant->id; continue; @@ -38,7 +42,7 @@ public function rebuildMatrix(Product $product): void 'currency' => $template?->currency ?? $product->store?->default_currency ?? 'USD', 'weight_g' => $template?->weight_g, 'requires_shipping' => $template?->requires_shipping ?? true, - 'is_default' => $groups === [], + 'is_default' => $position === 0, 'position' => $position, 'status' => 'active', ]); @@ -53,7 +57,7 @@ public function rebuildMatrix(Product $product): void continue; } if (OrderLine::query()->where('variant_id', $variant->id)->exists()) { - $variant->update(['status' => 'archived']); + $variant->update(['status' => 'archived', 'is_default' => false]); } else { $variant->delete(); } diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php index ec81e96f..e05b01c2 100644 --- a/app/Services/WebhookService.php +++ b/app/Services/WebhookService.php @@ -7,24 +7,39 @@ use App\Models\WebhookDelivery; use App\Models\WebhookSubscription; use Illuminate\Support\Str; +use Throwable; final class WebhookService { + private OutboundDispatcher $outbound; + + public function __construct(?OutboundDispatcher $outbound = null) + { + $this->outbound = $outbound ?? app(OutboundDispatcher::class); + } + /** @param array $payload */ public function dispatch(Store $store, string $eventType, array $payload): void { - WebhookSubscription::withoutGlobalScopes() - ->where('store_id', $store->id) - ->where('event_type', $eventType) - ->where('status', 'active') - ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void { - $delivery = $subscription->deliveries()->create([ - 'event_id' => (string) Str::uuid(), - 'attempt_count' => 0, - 'status' => 'pending', - ]); - DeliverWebhook::dispatch($delivery, $eventType, $payload); - }); + $storeId = (int) $store->id; + $this->outbound->afterCommit(function () use ($storeId, $eventType, $payload): void { + WebhookSubscription::withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('event_type', $eventType) + ->where('status', 'active') + ->each(function (WebhookSubscription $subscription) use ($eventType, $payload): void { + try { + $delivery = $subscription->deliveries()->create([ + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 0, + 'status' => 'pending', + ]); + DeliverWebhook::dispatch($delivery, $eventType, $payload); + } catch (Throwable $exception) { + report($exception); + } + }); + }); } public function sign(string $payload, string $secret): string diff --git a/app/Services/WebhookTargetValidator.php b/app/Services/WebhookTargetValidator.php new file mode 100644 index 00000000..cdffa8f8 --- /dev/null +++ b/app/Services/WebhookTargetValidator.php @@ -0,0 +1,96 @@ +dns->resolve($hostname); + if ($addresses === [] || collect($addresses)->contains(fn (string $address): bool => ! $this->isPublicAddress($address))) { + throw new UnsafeWebhookTargetException('The webhook hostname must resolve exclusively to public IP addresses.'); + } + + $port = (int) ($parts['port'] ?? 443); + if ($port < 1 || $port > 65535) { + throw new UnsafeWebhookTargetException('The webhook port is invalid.'); + } + + return new ValidatedWebhookTarget($url, $hostname, $port, $addresses[0]); + } + + private function isPublicAddress(string $address): bool + { + if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; + } + + foreach ([ + '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', + '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', + '64:ff9b:1::/48', '100::/64', '2001:db8::/32', + ] as $cidr) { + if ($this->inCidr($address, $cidr)) { + return false; + } + } + + return true; + } + + private function inCidr(string $address, string $cidr): bool + { + [$network, $prefix] = explode('/', $cidr, 2); + $addressBytes = @inet_pton($address); + $networkBytes = @inet_pton($network); + if ($addressBytes === false || $networkBytes === false || strlen($addressBytes) !== strlen($networkBytes)) { + return false; + } + + $bits = (int) $prefix; + $bytes = intdiv($bits, 8); + $remainder = $bits % 8; + if (substr($addressBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) { + return false; + } + if ($remainder === 0) { + return true; + } + + $mask = (0xFF << (8 - $remainder)) & 0xFF; + + return (ord($addressBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask); + } +} diff --git a/app/Support/SafeUrl.php b/app/Support/SafeUrl.php new file mode 100644 index 00000000..e8dbff36 --- /dev/null +++ b/app/Support/SafeUrl.php @@ -0,0 +1,70 @@ + $settings @return array */ + public static function sanitizeThemeSettings(array $settings): array + { + foreach ([ + 'announcement.url', + 'home.hero.cta_url', + 'home.hero.image_url', + 'header.logo_url', + ] as $key) { + if (data_get($settings, $key) !== null) { + data_set($settings, $key, self::normalize(data_get($settings, $key))); + } + } + + return $settings; + } +} diff --git a/app/ValueObjects/TaxCalculationResult.php b/app/ValueObjects/TaxCalculationResult.php index a6833f94..c29cf25b 100644 --- a/app/ValueObjects/TaxCalculationResult.php +++ b/app/ValueObjects/TaxCalculationResult.php @@ -6,8 +6,19 @@ final readonly class TaxCalculationResult implements JsonSerializable { - /** @param list $taxLines */ - public function __construct(public array $taxLines, public int $totalAmount) {} + /** + * @param list $taxLines + * @param list $lineAmounts + * @param array $providerResponse + */ + public function __construct( + public array $taxLines, + public int $totalAmount, + public array $lineAmounts = [], + public int $shippingAmount = 0, + public string $provider = 'manual', + public array $providerResponse = [], + ) {} /** @return array{tax_lines: list, total_amount: int} */ public function jsonSerialize(): array @@ -15,6 +26,10 @@ public function jsonSerialize(): array return [ 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), 'total_amount' => $this->totalAmount, + 'line_amounts' => $this->lineAmounts, + 'shipping_amount' => $this->shippingAmount, + 'provider' => $this->provider, + 'provider_response' => $this->providerResponse, ]; } } diff --git a/app/ValueObjects/ValidatedWebhookTarget.php b/app/ValueObjects/ValidatedWebhookTarget.php new file mode 100644 index 00000000..def38e36 --- /dev/null +++ b/app/ValueObjects/ValidatedWebhookTarget.php @@ -0,0 +1,20 @@ +ip, ':') ? "[{$this->ip}]" : $this->ip; + + return "{$this->hostname}:{$this->port}:{$ip}"; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 3b657069..047ef3a7 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,12 +2,14 @@ use App\Http\Middleware\CheckStoreRole; use App\Http\Middleware\CustomerAuthenticate; +use App\Http\Middleware\EnsureUserIsActive; use App\Http\Middleware\ResolveStore; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; use Laravel\Sanctum\Http\Middleware\CheckAbilities; @@ -23,8 +25,10 @@ 'store.resolve' => ResolveStore::class, 'role.check' => CheckStoreRole::class, 'customer.auth' => CustomerAuthenticate::class, + 'user.active' => EnsureUserIsActive::class, 'abilities' => CheckAbilities::class, ]); + $middleware->web(append: [EnsureUserIsActive::class]); $middleware->group('storefront', [ResolveStore::class]); $middleware->group('storefront.api', [ EncryptCookies::class, @@ -33,6 +37,7 @@ ResolveStore::class, ]); $middleware->group('admin.store', [ResolveStore::class]); + $middleware->prependToPriorityList(SubstituteBindings::class, ResolveStore::class); $middleware->redirectGuestsTo(fn ($request): string => $request->is('account', 'account/*') ? '/account/login' : ($request->is('admin', 'admin/*') ? '/admin/login' : '/login')); diff --git a/composer.lock b/composer.lock index 53f07a3b..e6568b9b 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "bacon/bacon-qr-code", - "version": "v3.0.3", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { @@ -57,9 +57,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2025-11-19T17:15:36+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "brick/math", @@ -315,6 +315,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -748,25 +796,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.14.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "aef242412e13128b5049864867bb49fc37dd39de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aef242412e13128b5049864867bb49fc37dd39de", + "reference": "aef242412e13128b5049864867bb49fc37dd39de", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.12.4", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-client-implementation": "1.0" @@ -774,9 +823,10 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.6", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -854,7 +904,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.14.0" }, "funding": [ { @@ -870,28 +920,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-07-08T22:54:09+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -937,7 +988,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.5.1" }, "funding": [ { @@ -953,27 +1004,29 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-07-08T15:48:39+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.12.4", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", + "reference": "51e27f9e2b332ab3e72f4520d5ff4f3c68c3577c", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -981,8 +1034,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1053,7 +1107,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.12.4" }, "funding": [ { @@ -1069,29 +1123,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-07-08T15:56:20+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.5", + "version": "v1.0.9", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d7580af6d3f8384325d9cd3e99b21c3ed1848176", + "reference": "d7580af6d3f8384325d9cd3e99b21c3ed1848176", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "symfony/polyfill-php80": "^1.24" + "symfony/polyfill-php80": "^1.25" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1139,7 +1193,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.9" }, "funding": [ { @@ -1155,32 +1209,33 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:27:06+00:00" + "time": "2026-07-08T16:19:22+00:00" }, { "name": "laravel/fortify", - "version": "v1.34.1", + "version": "v1.37.2", "source": { "type": "git", "url": "https://github.com/laravel/fortify.git", - "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2" + "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/412575e9c0cb21d49a30b7045ad4902019f538c2", - "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2", + "url": "https://api.github.com/repos/laravel/fortify/zipball/5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c", + "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c", "shasum": "" }, "require": { "bacon/bacon-qr-code": "^3.0", "ext-json": "*", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", "pragmarx/google2fa": "^9.0" }, "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "orchestra/testbench": "^9.15|^10.8|^11.0", "phpstan/phpstan": "^1.10" }, "type": "library", @@ -1218,20 +1273,20 @@ "issues": "https://github.com/laravel/fortify/issues", "source": "https://github.com/laravel/fortify" }, - "time": "2026-02-03T06:55:55+00:00" + "time": "2026-05-15T22:59:10+00:00" }, { "name": "laravel/framework", - "version": "v12.51.0", + "version": "v12.63.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16" + "reference": "7adfddbf4738f2e6cae5419b0e6bc46d4cccfbcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/ce4de3feb211e47c4f959d309ccf8a2733b1bc16", - "reference": "ce4de3feb211e47c4f959d309ccf8a2733b1bc16", + "url": "https://api.github.com/repos/laravel/framework/zipball/7adfddbf4738f2e6cae5419b0e6bc46d4cccfbcf", + "reference": "7adfddbf4738f2e6cae5419b0e6bc46d4cccfbcf", "shasum": "" }, "require": { @@ -1252,7 +1307,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.7", + "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1272,8 +1327,8 @@ "symfony/mailer": "^7.2.0", "symfony/mime": "^7.2.0", "symfony/polyfill-php83": "^1.33", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", "symfony/process": "^7.2.0", "symfony/routing": "^7.2.0", "symfony/uid": "^7.2.0", @@ -1347,7 +1402,7 @@ "orchestra/testbench-core": "^10.9.0", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "^2.1.41", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0|^1.0", @@ -1440,20 +1495,88 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-10T18:20:19+00:00" + "time": "2026-07-07T14:16:35+00:00" + }, + { + "name": "laravel/passkeys", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" + }, + "require-dev": { + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.13", + "version": "v0.3.21", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" + "reference": "7753c65c281c2550c7c183f14e18062073b7d821" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", + "url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821", + "reference": "7753c65c281c2550c7c183f14e18062073b7d821", "shasum": "" }, "require": { @@ -1497,22 +1620,85 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.13" + "source": "https://github.com/laravel/prompts/tree/v0.3.21" + }, + "time": "2026-06-26T00:11:25+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } }, - "time": "2026-02-06T12:17:10+00:00" + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.9", + "version": "v2.0.13", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e" + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/8f631589ab07b7b52fead814965f5a800459cb3e", - "reference": "8f631589ab07b7b52fead814965f5a800459cb3e", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", "shasum": "" }, "require": { @@ -1560,7 +1746,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-03T06:55:34+00:00" + "time": "2026-04-16T14:03:50+00:00" }, { "name": "laravel/tinker", @@ -1630,16 +1816,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7", + "reference": "1902f60f984235023acbe03db6ad614a37b3c3e7", "shasum": "" }, "require": { @@ -1661,12 +1847,12 @@ "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4 || ^2.0", "nyholm/psr7": "^1.5", - "phpstan/phpstan": "^1.8.2", - "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -1733,7 +1919,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-07-12T15:29:16+00:00" }, { "name": "league/config", @@ -1819,16 +2005,16 @@ }, { "name": "league/flysystem", - "version": "3.31.0", + "version": "3.35.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + "reference": "b277b5dc3d56650b68904117124e79c851e12376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", - "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", "shasum": "" }, "require": { @@ -1896,9 +2082,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" }, - "time": "2026-01-23T15:38:47+00:00" + "time": "2026-07-06T14:42:07+00:00" }, { "name": "league/flysystem-local", @@ -1951,16 +2137,16 @@ }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -1970,7 +2156,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -1991,7 +2177,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -2003,24 +2189,24 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2093,7 +2279,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -2101,20 +2287,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -2177,7 +2363,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -2185,33 +2371,33 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "livewire/flux", - "version": "v2.12.0", + "version": "v2.15.0", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003" + "reference": "c570fb836278e9be881d08b7e386a1a62ab9faf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/78bc26f54a29c28ff916751b9f796f4ce1592003", - "reference": "78bc26f54a29c28ff916751b9f796f4ce1592003", + "url": "https://api.github.com/repos/livewire/flux/zipball/c570fb836278e9be881d08b7e386a1a62ab9faf7", + "reference": "c570fb836278e9be881d08b7e386a1a62ab9faf7", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/view": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1|^0.2|^0.3", "livewire/livewire": "^3.7.4|^4.0", "php": "^8.1", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "conflict": { - "livewire/blaze": "<1.0.0" + "livewire/blaze": "<1.0.0-beta.2" }, "type": "library", "extra": { @@ -2249,42 +2435,42 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.12.0" + "source": "https://github.com/livewire/flux/tree/v2.15.0" }, - "time": "2026-02-09T23:35:27+00:00" + "time": "2026-06-19T02:25:18+00:00" }, { "name": "livewire/livewire", - "version": "v4.1.4", + "version": "v4.3.3", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "4697085e02a1f5f11410a1b5962400e3539f8843" + "reference": "8021f2561865c4c297a3bfca37212a99034377e7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/4697085e02a1f5f11410a1b5962400e3539f8843", - "reference": "4697085e02a1f5f11410a1b5962400e3539f8843", + "url": "https://api.github.com/repos/livewire/livewire/zipball/8021f2561865c4c297a3bfca37212a99034377e7", + "reference": "8021f2561865c4c297a3bfca37212a99034377e7", "shasum": "" }, "require": { - "illuminate/database": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", "league/mime-type-detection": "^1.9", "php": "^8.1", - "symfony/console": "^6.0|^7.0", - "symfony/http-kernel": "^6.2|^7.0" + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" }, "require-dev": { "calebporzio/sushi": "^2.1", - "laravel/framework": "^10.15.0|^11.0|^12.0", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", "mockery/mockery": "^1.3.1", - "orchestra/testbench": "^8.21.0|^9.0|^10.0", - "orchestra/testbench-dusk": "^8.24|^9.1|^10.0", - "phpunit/phpunit": "^10.4|^11.5", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", "psy/psysh": "^0.11.22|^0.12" }, "type": "library", @@ -2319,7 +2505,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.1.4" + "source": "https://github.com/livewire/livewire/tree/v4.3.3" }, "funding": [ { @@ -2327,7 +2513,7 @@ "type": "github" } ], - "time": "2026-02-09T22:59:54+00:00" + "time": "2026-06-27T03:16:11+00:00" }, { "name": "monolog/monolog", @@ -2434,16 +2620,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.1", + "version": "3.13.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/2937ad3d1d2c506fd2bc97d571438a95641f44e2", + "reference": "2937ad3d1d2c506fd2bc97d571438a95641f44e2", "shasum": "" }, "require": { @@ -2535,20 +2721,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:26:29+00:00" + "time": "2026-07-09T18:23:49+00:00" }, { "name": "nette/schema", - "version": "v1.3.4", + "version": "v1.3.5", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7" + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7", - "reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "shasum": "" }, "require": { @@ -2556,8 +2742,10 @@ "php": "8.1 - 8.5" }, "require-dev": { + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.6", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -2598,22 +2786,22 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.4" + "source": "https://github.com/nette/schema/tree/v1.3.5" }, - "time": "2026-02-08T02:54:00+00:00" + "time": "2026-02-23T03:47:12+00:00" }, { "name": "nette/utils", - "version": "v4.1.2", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", - "reference": "f76b5dc3d6c6d3043c8d937df2698515b99cbaf5", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -2625,8 +2813,10 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan": "^2.0@stable", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -2687,26 +2877,25 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.2" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-03T17:21:09+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -2745,37 +2934,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -2807,7 +2996,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -2818,7 +3007,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -2834,7 +3023,7 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -2906,106 +3095,329 @@ "time": "2025-09-24T15:06:41+00:00" }, { - "name": "phpoption/phpoption", - "version": "1.9.5", + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - }, "branch-alias": { - "dev-master": "1.9-dev" + "dev-2.x": "2.x-dev" } }, "autoload": { "psr-4": { - "PhpOption\\": "src/PhpOption/" + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache-2.0" + "MIT" ], "authors": [ { - "name": "Johannes M. Schmitt", - "email": "schmittjoh@gmail.com", - "homepage": "https://github.com/schmittjoh" - }, - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" } ], - "description": "Option Type for PHP", + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", "keywords": [ - "language", - "option", - "php", - "type" + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" ], "support": { - "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", - "type": "tidelift" - } - ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2020-06-27T09:03:43+00:00" }, { - "name": "pragmarx/google2fa", - "version": "v9.0.0", + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", "source": { "type": "git", - "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { - "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", - "php": "^7.1|^8.0" + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, "autoload": { "psr-4": { - "PragmaRX\\Google2FA\\": "src/" + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3446,16 +3858,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.19", + "version": "v0.12.24", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", "shasum": "" }, "require": { @@ -3519,9 +3931,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" }, - "time": "2026-01-30T17:33:13+00:00" + "time": "2026-06-29T15:41:09+00:00" }, { "name": "ralouphie/getallheaders", @@ -3645,20 +4057,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.2", + "version": "4.9.3", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "8429c78ca35a09f27565311b98101e2826affde0" + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", - "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", "shasum": "" }, "require": { - "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": ">=0.8.16 <=0.18", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -3717,42 +4129,44 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.2" + "source": "https://github.com/ramsey/uuid/tree/4.9.3" }, - "time": "2025-12-14T04:43:48+00:00" + "time": "2026-06-18T03:57:49+00:00" }, { - "name": "symfony/clock", - "version": "v8.0.0", + "name": "spomky-labs/cbor-php", + "version": "3.2.3", "source": { "type": "git", - "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", + "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32", "shasum": "" }, "require": { - "php": ">=8.4", - "psr/clock": "^1.0" + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.0" }, - "provide": { - "psr/clock-implementation": "1.0" + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" }, "type": "library", "autoload": { - "files": [ - "Resources/now.php" - ], "psr-4": { - "Symfony\\Component\\Clock\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "CBOR\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3760,61 +4174,240 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" } ], - "description": "Decouples applications from the system clock", - "homepage": "https://symfony.com", + "description": "CBOR Encoder/Decoder for PHP", "keywords": [ - "clock", - "psr20", - "time" + "Concise Binary Object Representation", + "RFC7049", + "cbor" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", + "url": "https://github.com/Spomky", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-04-01T12:15:20+00:00" }, { - "name": "symfony/console", - "version": "v7.4.4", + "name": "spomky-labs/pki-framework", + "version": "1.4.2", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8", + "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-mbstring": "*", + "php": ">=8.1", + "psr/clock": "^1.0" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31|^0.32", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0|^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-03-23T22:56:56+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", "symfony/string": "^7.2|^8.0" @@ -3874,7 +4467,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -3894,24 +4487,24 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.0", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", - "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -3943,7 +4536,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, "funding": [ { @@ -3963,20 +4556,20 @@ "type": "tidelift" } ], - "time": "2025-10-30T14:17:19+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3989,7 +4582,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4014,7 +4607,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -4025,25 +4618,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "4e1a093b481f323e6e326451f9760c3868430673" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/4e1a093b481f323e6e326451f9760c3868430673", + "reference": "4e1a093b481f323e6e326451f9760c3868430673", "shasum": "" }, "require": { @@ -4092,7 +4689,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.14" }, "funding": [ { @@ -4112,24 +4709,25 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-06-05T06:22:21+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.1.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -4177,7 +4775,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" }, "funding": [ { @@ -4197,20 +4795,20 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-06-09T12:28:30+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -4224,7 +4822,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4257,7 +4855,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -4268,25 +4866,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/finder", - "version": "v7.4.5", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", - "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { @@ -4321,7 +4923,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.5" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -4341,20 +4943,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.5", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6" + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/446d0db2b1f21575f1284b74533e425096abdfb6", - "reference": "446d0db2b1f21575f1284b74533e425096abdfb6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/06db5ae1552177bf8572f8908839f12e3c06aed3", + "reference": "06db5ae1552177bf8572f8908839f12e3c06aed3", "shasum": "" }, "require": { @@ -4403,7 +5005,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.5" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.14" }, "funding": [ { @@ -4423,20 +5025,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T16:16:02+00:00" + "time": "2026-06-11T07:31:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.5", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a" + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/229eda477017f92bd2ce7615d06222ec0c19e82a", - "reference": "229eda477017f92bd2ce7615d06222ec0c19e82a", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/e99af79b1e776646eda0e1c23b7b45c184ff99be", + "reference": "e99af79b1e776646eda0e1c23b7b45c184ff99be", "shasum": "" }, "require": { @@ -4478,7 +5080,7 @@ "symfony/config": "^6.4|^7.0|^8.0", "symfony/console": "^6.4|^7.0|^8.0", "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/finder": "^6.4|^7.0|^8.0", @@ -4522,7 +5124,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.5" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.14" }, "funding": [ { @@ -4542,20 +5144,20 @@ "type": "tidelift" } ], - "time": "2026-01-28T10:33:42+00:00" + "time": "2026-06-27T09:14:35+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.4", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6" + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/7b750074c40c694ceb34cb926d6dffee231c5cd6", - "reference": "7b750074c40c694ceb34cb926d6dffee231c5cd6", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f88ce03ae73e3edb5c176ce1f337709996e88495", + "reference": "f88ce03ae73e3edb5c176ce1f337709996e88495", "shasum": "" }, "require": { @@ -4606,7 +5208,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.4" + "source": "https://github.com/symfony/mailer/tree/v7.4.14" }, "funding": [ { @@ -4626,20 +5228,20 @@ "type": "tidelift" } ], - "time": "2026-01-08T08:25:11+00:00" + "time": "2026-06-13T08:51:35+00:00" }, { "name": "symfony/mime", - "version": "v7.4.5", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148" + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b18c7e6e9eee1e19958138df10412f3c4c316148", - "reference": "b18c7e6e9eee1e19958138df10412f3c4c316148", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", "shasum": "" }, "require": { @@ -4650,7 +5252,7 @@ }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" @@ -4658,7 +5260,7 @@ "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/property-access": "^6.4|^7.0|^8.0", @@ -4695,7 +5297,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.5" + "source": "https://github.com/symfony/mime/tree/v7.4.13" }, "funding": [ { @@ -4715,20 +5317,20 @@ "type": "tidelift" } ], - "time": "2026-01-27T08:59:58+00:00" + "time": "2026-05-23T16:22:37+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4778,7 +5380,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4798,20 +5400,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -4860,7 +5462,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -4880,20 +5482,20 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -4947,7 +5549,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -4967,20 +5569,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -5032,7 +5634,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -5052,20 +5654,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -5117,7 +5719,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -5137,20 +5739,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -5201,7 +5803,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -5221,20 +5823,20 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -5281,7 +5883,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -5301,20 +5903,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -5361,7 +5963,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -5381,20 +5983,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -5441,7 +6043,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -5461,20 +6063,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -5524,7 +6126,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -5544,20 +6146,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -5589,7 +6191,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -5609,43 +6211,34 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { - "name": "symfony/routing", - "version": "v7.4.4", + "name": "symfony/property-access", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/routing.git", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147" + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0798827fe2c79caeed41d70b680c2c3507d10147", - "reference": "0798827fe2c79caeed41d70b680c2c3507d10147", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" }, "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Routing\\": "" + "Symfony\\Component\\PropertyAccess\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5665,16 +6258,21 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Maps an HTTP request to a set of configuration variables", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", "homepage": "https://symfony.com", "keywords": [ - "router", - "routing", - "uri", - "url" + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.4" + "source": "https://github.com/symfony/property-access/tree/v8.1.0" }, "funding": [ { @@ -5694,46 +6292,45 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:19:02+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.6.1", + "name": "symfony/property-info", + "version": "v8.1.0", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "url": "https://github.com/symfony/property-info.git", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" }, "conflict": { - "ext-psr": "<1.1|>=2" + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.6-dev" - } + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, + "type": "library", "autoload": { "psr-4": { - "Symfony\\Contracts\\Service\\": "" + "Symfony\\Component\\PropertyInfo\\": "" }, "exclude-from-classmap": [ - "/Test/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5742,26 +6339,26 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "KΓ©vin Dunglas", + "email": "dunglas@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to writing services", + "description": "Extracts information about PHP class' properties using metadata of popular sources", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/property-info/tree/v8.1.0" }, "funding": [ { @@ -5781,46 +6378,43 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/string", - "version": "v8.0.4", + "name": "symfony/routing", + "version": "v7.4.13", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "shasum": "" }, "require": { - "php": ">=8.4", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "symfony/translation-contracts": "<2.5" + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\String\\": "" + "Symfony\\Component\\Routing\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5832,26 +6426,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "description": "Maps an HTTP request to a set of configuration variables", "homepage": "https://symfony.com", "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" + "router", + "routing", + "uri", + "url" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/routing/tree/v7.4.13" }, "funding": [ { @@ -5871,46 +6463,322 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { - "name": "symfony/translation", - "version": "v8.0.4", + "name": "symfony/serializer", + "version": "v8.1.1", "source": { "type": "git", - "url": "https://github.com/symfony/translation.git", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10" + "url": "https://github.com/symfony/serializer.git", + "reference": "f911b744bc24658f435ea30439cfe536f0173a3a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", - "reference": "db70c8ce7db74fd2da7b1d268db46b2a8ce32c10", + "url": "https://api.github.com/repos/symfony/serializer/zipball/f911b744bc24658f435ea30439cfe536f0173a3a", + "reference": "f911b744bc24658f435ea30439cfe536f0173a3a", "shasum": "" }, "require": { - "php": ">=8.4", - "symfony/polyfill-mbstring": "^1.0", - "symfony/translation-contracts": "^3.6.1" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "nikic/php-parser": "<5.0", - "symfony/http-client-contracts": "<2.5", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "symfony/translation-implementation": "2.3|3.0" + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" }, "require-dev": { - "nikic/php-parser": "^5.0", - "psr/log": "^1|^2|^3", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", "symfony/config": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", "symfony/dependency-injection": "^7.4|^8.0", - "symfony/finder": "^7.4|^8.0", - "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", - "symfony/polyfill-intl-icu": "^1.21", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T09:05:56+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014", + "reference": "342b4218630dc2cf284cedcb2080c80b13404014", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", "symfony/routing": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3", "symfony/yaml": "^7.4|^8.0" @@ -5944,7 +6812,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.4" + "source": "https://github.com/symfony/translation/tree/v8.1.1" }, "funding": [ { @@ -5964,20 +6832,20 @@ "type": "tidelift" } ], - "time": "2026-01-13T13:06:50+00:00" + "time": "2026-06-06T11:11:44+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { @@ -5990,7 +6858,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6026,7 +6894,89 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/type-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.0" }, "funding": [ { @@ -6046,20 +6996,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v7.4.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "2676b524340abcfe4d6151ec698463cebafee439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", "shasum": "" }, "require": { @@ -6104,7 +7054,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.9" }, "funding": [ { @@ -6124,20 +7074,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-04-30T15:19:22+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.4", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282" + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282", - "reference": "0e4769b46a0c3c62390d124635ce59f66874b282", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", "shasum": "" }, "require": { @@ -6191,7 +7141,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.4" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" }, "funding": [ { @@ -6211,7 +7161,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T22:13:48+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6234,151 +7184,393 @@ "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^8.5.21 || ^9.5.10" + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.4", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-07-06T19:11:50+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.5.2", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" }, + "type": "library", "autoload": { "psr-4": { - "TijsVerkoyen\\CssToInlineStyles\\": "src" + "Cose\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Tijs Verkoyen", - "email": "css_to_inline_styles@verkoyen.eu", - "role": "Developer" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" } ], - "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", - "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], "support": { - "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.5.2" }, - "time": "2025-12-02T11:56:42+00:00" + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-03T09:49:50+00:00" }, { - "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "name": "web-auth/webauthn-lib", + "version": "5.3.5", "source": { "type": "git", - "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", "shasum": "" }, "require": { - "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", - "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", - "symfony/polyfill-ctype": "^1.26", - "symfony/polyfill-mbstring": "^1.26", - "symfony/polyfill-php80": "^1.26" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-filter": "*", - "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" }, "suggest": { - "ext-filter": "Required to use the boolean validator." + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" }, "type": "library", "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - }, - "branch-alias": { - "dev-master": "5.6-dev" + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" } }, "autoload": { "psr-4": { - "Dotenv\\": "src/" + "Webauthn\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" }, { - "name": "Vance Lucas", - "email": "vance@vancelucas.com", - "homepage": "https://github.com/vlucas" + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", "keywords": [ - "dotenv", - "env", - "environment" + "FIDO2", + "fido", + "webauthn" ], "support": { - "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", + "url": "https://github.com/Spomky", "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", - "type": "tidelift" + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-05-31T15:00:08+00:00" }, { - "name": "voku/portable-ascii", - "version": "2.0.3", + "name": "webmozart/assert", + "version": "2.4.1", "source": { "type": "git", - "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", "shasum": "" }, "require": { - "php": ">=7.0.0" - }, - "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" }, "suggest": { - "ext-intl": "Use Intl for transliterator_transliterate() support" + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, "autoload": { "psr-4": { - "voku\\": "src/voku/" + "Webmozart\\Assert\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -6387,59 +7579,40 @@ ], "authors": [ { - "name": "Lars Moelleken", - "homepage": "https://www.moelleken.org/" + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], - "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", - "homepage": "https://github.com/voku/portable-ascii", + "description": "Assertions to validate method input/output with nice error messages.", "keywords": [ - "ascii", - "clean", - "php" + "assert", + "check", + "validate" ], "support": { - "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" }, - "funding": [ - { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", - "type": "github" - }, - { - "url": "https://opencollective.com/portable-ascii", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", - "type": "tidelift" - } - ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.17.0", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -6450,24 +7623,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.8", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -6507,7 +7680,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.17.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -6519,55 +7692,149 @@ "type": "paypal" } ], - "time": "2026-02-05T09:14:44+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { - "name": "doctrine/deprecations", - "version": "1.1.6", + "name": "composer/pcre", + "version": "3.4.0", "source": { "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^7.4 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", "psr/log": "^1 || ^2 || ^3" }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "src" + "Composer\\XdebugHandler\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, - "time": "2026-02-07T07:09:04+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" }, { "name": "fakerphp/faker", @@ -6877,35 +8144,36 @@ }, { "name": "laravel/boost", - "version": "v1.0.18", + "version": "v1.8.13", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "cdcb12114315491f72a2cecb5130d8b9dffa0103" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/cdcb12114315491f72a2cecb5130d8b9dffa0103", + "reference": "cdcb12114315491f72a2cecb5130d8b9dffa0103", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "laravel/mcp": "^0.1.0", - "laravel/prompts": "^0.1.9|^0.3", - "laravel/roster": "^0.2", - "php": "^8.1|^8.2" - }, - "require-dev": { - "laravel/pint": "^1.14|^1.23", - "mockery/mockery": "^1.6", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "pestphp/pest": "^2.0|^3.0", - "phpstan/phpstan": "^2.0" + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "laravel/mcp": "^0.5.1", + "laravel/prompts": "0.1.25|^0.3.6", + "laravel/roster": "^0.2.9", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.20.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" }, "type": "library", "extra": { @@ -6927,7 +8195,7 @@ "license": [ "MIT" ], - "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", "homepage": "https://github.com/laravel/boost", "keywords": [ "ai", @@ -6938,35 +8206,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-03-27T17:24:01+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.5.9", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/39e8da60eb7bce4737c5d868d35a3fe78938c129", + "reference": "39e8da60eb7bce4737c5d868d35a3fe78938c129", "shasum": "" }, "require": { - "illuminate/console": "^10.0|^11.0|^12.0", - "illuminate/contracts": "^10.0|^11.0|^12.0", - "illuminate/http": "^10.0|^11.0|^12.0", - "illuminate/routing": "^10.0|^11.0|^12.0", - "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/validation": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^8.22.0|^9.0|^10.0", - "phpstan/phpstan": "^2.0" + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" }, "type": "library", "extra": { @@ -6982,8 +8256,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +8263,15 @@ "license": [ "MIT" ], - "description": "The easiest way to add MCP servers to your Laravel app.", + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", "homepage": "https://github.com/laravel/mcp", "keywords": [ - "dev", "laravel", "mcp" ], @@ -7002,20 +8279,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-02-17T19:05:53+00:00" }, { "name": "laravel/pail", - "version": "v1.2.6", + "version": "v1.2.7", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", "shasum": "" }, "require": { @@ -7082,20 +8359,20 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2026-02-09T13:44:54+00:00" + "time": "2026-05-20T22:24:57+00:00" }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.3", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", "shasum": "" }, "require": { @@ -7106,13 +8383,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.8", + "illuminate/view": "^12.62.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6" }, "bin": [ "builds/pint" @@ -7149,20 +8427,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-06-16T15:34:04+00:00" }, { "name": "laravel/roster", - "version": "v0.2.2", + "version": "v0.2.9", "source": { "type": "git", "url": "https://github.com/laravel/roster.git", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f" + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f", - "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f", + "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", "shasum": "" }, "require": { @@ -7170,7 +8448,8 @@ "illuminate/contracts": "^10.0|^11.0|^12.0", "illuminate/routing": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1|^8.2" + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" }, "require-dev": { "laravel/pint": "^1.14", @@ -7209,20 +8488,20 @@ "issues": "https://github.com/laravel/roster/issues", "source": "https://github.com/laravel/roster" }, - "time": "2025-07-24T12:31:13+00:00" + "time": "2025-10-20T09:56:46+00:00" }, { "name": "laravel/sail", - "version": "v1.53.0", + "version": "v1.63.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "url": "https://api.github.com/repos/laravel/sail/zipball/51bbce3f803c1d386cabbb44e618c955a12ff5fc", + "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc", "shasum": "" }, "require": { @@ -7272,7 +8551,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-02-06T12:16:02+00:00" + "time": "2026-06-18T08:54:14+00:00" }, { "name": "mockery/mockery", @@ -7419,39 +8698,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -7514,45 +8790,47 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "pestphp/pest", - "version": "v4.3.2", + "version": "v4.7.5", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" + "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5dc49a71d63602a9b98fed0f2017c4679ef9f8e0", + "reference": "5dc49a71d63602a9b98fed0f2017c4679ef9f8e0", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.1", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", - "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.8", - "symfony/process": "^7.4.4|^8.0.0" + "phpunit/phpunit": "^12.5.30", + "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.8", + "phpunit/phpunit": ">12.5.30", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.2.1", - "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "mrpunyapal/peststan": "^0.2.11", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.24" }, "bin": [ "bin/pest" @@ -7579,6 +8857,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -7618,7 +8897,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.2" + "source": "https://github.com/pestphp/pest/tree/v4.7.5" }, "funding": [ { @@ -7630,7 +8909,7 @@ "type": "github" } ], - "time": "2026-01-28T01:01:19+00:00" + "time": "2026-07-06T17:06:29+00:00" }, { "name": "pestphp/pest-plugin", @@ -7704,26 +8983,26 @@ }, { "name": "pestphp/pest-plugin-arch", - "version": "v4.0.0", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "ta-tikoma/phpunit-architecture-test": "^0.8.5" + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "pestphp/pest": "^4.0.0", - "pestphp/pest-dev-tools": "^4.0.0" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", "extra": { @@ -7758,7 +9037,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, "funding": [ { @@ -7770,31 +9049,31 @@ "type": "github" } ], - "time": "2025-08-20T13:10:51+00:00" + "time": "2026-04-10T17:20:19+00:00" }, { "name": "pestphp/pest-plugin-laravel", - "version": "v4.0.0", + "version": "v4.1.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-laravel.git", - "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e" + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/e12a07046b826a40b1c8632fd7b80d6b8d7b628e", - "reference": "e12a07046b826a40b1c8632fd7b80d6b8d7b628e", + "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/3057a36669ff11416cc0dc2b521b3aec58c488d0", + "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0", "shasum": "" }, "require": { - "laravel/framework": "^11.45.2|^12.25.0", - "pestphp/pest": "^4.0.0", + "laravel/framework": "^11.45.2|^12.52.0|^13.0", + "pestphp/pest": "^4.4.1", "php": "^8.3.0" }, "require-dev": { - "laravel/dusk": "^8.3.3", - "orchestra/testbench": "^9.13.0|^10.5.0", - "pestphp/pest-dev-tools": "^4.0.0" + "laravel/dusk": "^8.3.6", + "orchestra/testbench": "^9.13.0|^10.9.0|^11.0", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", "extra": { @@ -7832,7 +9111,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.0.0" + "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.1.0" }, "funding": [ { @@ -7844,7 +9123,7 @@ "type": "github" } ], - "time": "2025-08-20T12:46:37+00:00" + "time": "2026-02-21T00:29:45+00:00" }, { "name": "pestphp/pest-plugin-mutate", @@ -7937,399 +9216,177 @@ "shasum": "" }, "require": { - "pestphp/pest-plugin": "^4.0.0", - "php": "^8.3" - }, - "require-dev": { - "faissaloux/pest-plugin-inside": "^1.9", - "pestphp/pest": "^4.0.0", - "pestphp/pest-dev-tools": "^4.0.0" - }, - "type": "library", - "extra": { - "pest": { - "plugins": [ - "Pest\\Profanity\\Plugin" - ] - } - }, - "autoload": { - "psr-4": { - "Pest\\Profanity\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "The Pest Profanity Plugin", - "keywords": [ - "framework", - "pest", - "php", - "plugin", - "profanity", - "test", - "testing", - "unit" - ], - "support": { - "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" - }, - "time": "2025-12-08T00:13:17+00:00" - }, - { - "name": "phar-io/manifest", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" - }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2024-03-03T12:33:53+00:00" - }, - { - "name": "phar-io/version", - "version": "3.2.1", - "source": { - "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Library for handling version information and constraints", - "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" - }, - "time": "2022-02-21T01:04:05+00:00" - }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.1", - "ext-filter": "*", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1 || ^2" + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3" }, "require-dev": { - "mockery/mockery": "~1.3.5 || ~1.6.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "faissaloux/pest-plugin-inside": "^1.9", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.x-dev" + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" + ] } }, "autoload": { "psr-4": { - "phpDocumentor\\Reflection\\": "src" + "Pest\\Profanity\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - }, - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } + "description": "The Pest Profanity Plugin", + "keywords": [ + "framework", + "pest", + "php", + "plugin", + "profanity", + "test", + "testing", + "unit" ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { - "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2025-12-08T00:13:17+00:00" }, { - "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "name": "phar-io/manifest", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" } ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" }, - "time": "2025-11-21T15:09:14+00:00" + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { - "name": "phpstan/phpdoc-parser", - "version": "2.3.2", + "name": "phar-io/version", + "version": "3.2.1", "source": { "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" + "php": "^7.2 || ^8.0" }, "type": "library", "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2026-01-25T14:56:51+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -8338,16 +9395,15 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8385,7 +9441,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -8405,7 +9461,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", @@ -8666,16 +9722,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.30", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb", + "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb", "shasum": "" }, "require": { @@ -8689,19 +9745,20 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", - "sebastian/exporter": "^7.0.2", - "sebastian/global-state": "^8.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", "sebastian/object-enumerator": "^7.0.0", - "sebastian/type": "^6.0.3", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -8743,51 +9800,35 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-06-15T13:12:30+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -8816,7 +9857,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -8836,20 +9877,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -8857,10 +9898,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -8908,7 +9949,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -8928,7 +9969,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -9057,23 +10098,23 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -9081,7 +10122,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -9109,7 +10150,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { @@ -9129,29 +10170,29 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9199,7 +10240,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -9219,30 +10260,30 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -9273,7 +10314,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -9293,28 +10334,28 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9343,15 +10384,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -9545,23 +10598,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9590,7 +10643,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -9610,7 +10663,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -9720,27 +10773,28 @@ }, { "name": "symfony/yaml", - "version": "v8.0.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", - "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", + "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<7.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -9771,7 +10825,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.1" + "source": "https://github.com/symfony/yaml/tree/v7.4.14" }, "funding": [ { @@ -9791,27 +10845,27 @@ "type": "tidelift" } ], - "time": "2025-12-04T18:17:06+00:00" + "time": "2026-06-08T20:24:16+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.6", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { @@ -9848,9 +10902,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2026-01-30T07:16:00+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -9901,68 +10955,6 @@ } ], "time": "2025-12-08T11:19:18+00:00" - }, - { - "name": "webmozart/assert", - "version": "2.1.2", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" - }, - "time": "2026-01-13T14:02:24+00:00" } ], "aliases": [], diff --git a/config/code_quality.php b/config/code_quality.php new file mode 100644 index 00000000..c8f5617f --- /dev/null +++ b/config/code_quality.php @@ -0,0 +1,89 @@ + [ + 'checks' => ['deterministic'], + 'fail_on' => 'error', + 'format' => 'pretty', + 'timeout_seconds' => (int) env('CODE_QUALITY_TIMEOUT', 600), + 'check_timeout_seconds' => (int) env('CODE_QUALITY_CHECK_TIMEOUT', 180), + ], + + 'ai' => [ + 'enabled' => (bool) env('CODE_QUALITY_AI_ENABLED', false), + 'binary' => env('CODE_QUALITY_CODEX_BINARY', 'codex'), + 'model' => env('CODE_QUALITY_AI_MODEL', 'gpt-5.4-mini'), + 'concurrency' => (int) env('CODE_QUALITY_AI_CONCURRENCY', 3), + 'timeout_seconds' => (int) env('CODE_QUALITY_AI_TIMEOUT', 180), + 'reasoning_effort' => env('CODE_QUALITY_AI_REASONING', 'low'), + 'verbosity' => env('CODE_QUALITY_AI_VERBOSITY', 'low'), + 'max_prompt_file_bytes' => (int) env('CODE_QUALITY_AI_MAX_PROMPT_FILE_BYTES', 120_000), + ], + + 'paths' => [ + 'exclude' => [ + 'vendor/**', + 'node_modules/**', + '.git/**', + 'storage/**', + 'bootstrap/cache/**', + 'public/build/**', + 'worker/dist/**', + 'coverage/**', + 'test-results/**', + '.env', + '.env.*', + ], + ], + + 'domain' => [ + 'multi_tenant' => (bool) env('CODE_QUALITY_MULTI_TENANT', true), + 'tenant_keys' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('CODE_QUALITY_TENANT_KEYS', 'store_id')), + ))), + 'ai_free_path_keywords' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('CODE_QUALITY_AI_FREE_PATH_KEYWORDS', '')), + ))), + ], + + 'frontend' => [ + 'route_helper_names' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('CODE_QUALITY_FRONTEND_ROUTE_HELPERS', 'route,action,wayfinder')), + ))), + ], + + 'allowlists' => [ + 'global_models' => [ + 'User', + 'Organization', + 'Store', + 'App', + ], + // These rows are tenant-bound through a required parent foreign key; + // every query enters through the parent relationship/service boundary. + 'tenant_via_parent_models' => [ + 'CartLine', 'CustomerAddress', 'Fulfillment', 'FulfillmentLine', 'NavigationItem', + 'OauthClient', 'OauthToken', 'OrderLine', 'Payment', 'ProductMedia', 'ProductOption', + 'ProductOptionValue', 'Refund', 'RefundLine', 'ShippingRate', 'ThemeFile', + 'ThemeSettings', 'WebhookDelivery', + ], + // Storefront endpoints are intentionally anonymous or authorize through + // an opaque session/HMAC helper; platform creation is Sanctum-ability gated. + 'authorization_methods' => [ + 'app/Http/Controllers/Api/Admin/PlatformController.php:store', + 'app/Http/Controllers/Api/Storefront/AnalyticsController.php:store', + 'app/Http/Controllers/Api/Storefront/CartController.php:store', + 'app/Http/Controllers/Api/Storefront/CartController.php:show', + 'app/Http/Controllers/Api/Storefront/CheckoutController.php:store', + 'app/Http/Controllers/Api/Storefront/CheckoutController.php:show', + 'app/Http/Controllers/Api/Storefront/OrderController.php:show', + 'app/Http/Controllers/Api/Storefront/SearchController.php:index', + ], + 'raw_sql' => [], + 'dangerously_set_inner_html' => [], + 'hardcoded_model_strings' => [], + ], +]; diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 00000000..2b5926f9 --- /dev/null +++ b/config/cors.php @@ -0,0 +1,12 @@ + ['api/*', 'sanctum/csrf-cookie'], + 'allowed_methods' => ['*'], + 'allowed_origins' => [env('FRONTEND_URL', '*')], + 'allowed_origins_patterns' => [], + 'allowed_headers' => ['*'], + 'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'], + 'max_age' => 0, + 'supports_credentials' => true, +]; diff --git a/config/session.php b/config/session.php index e6197a0f..914da71b 100644 --- a/config/session.php +++ b/config/session.php @@ -1,7 +1,5 @@ env('SESSION_ENCRYPT', false), + 'encrypt' => env('SESSION_ENCRYPT', true), /* |-------------------------------------------------------------------------- @@ -129,7 +127,7 @@ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + 'shop_session' ), /* diff --git a/package-lock.json b/package-lock.json index b558d2d8..242f2693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,24 +5,24 @@ "packages": { "": { "dependencies": { - "@tailwindcss/vite": "^4.1.11", - "autoprefixer": "^10.4.20", - "axios": "^1.7.4", - "concurrently": "^9.0.1", - "laravel-vite-plugin": "^2.0", + "@tailwindcss/vite": "^4.3.2", + "autoprefixer": "^10.5.2", + "axios": "^1.18.1", + "concurrently": "^9.2.4", + "laravel-vite-plugin": "^2.1.0", "tailwindcss": "^4.0.7", - "vite": "^7.0.4" + "vite": "^7.3.6" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@rollup/rollup-linux-x64-gnu": "^4.0.0", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", "lightningcss-linux-x64-gnu": "^1.29.1" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -36,9 +36,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -52,9 +52,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -68,9 +68,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -84,9 +84,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -100,9 +100,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -148,9 +148,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -164,9 +164,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -180,9 +180,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -196,9 +196,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -212,9 +212,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -228,9 +228,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -244,9 +244,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -260,9 +260,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -276,9 +276,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -292,9 +292,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -308,9 +308,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -324,9 +324,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -340,9 +340,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -356,9 +356,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -372,9 +372,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -404,9 +404,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -420,9 +420,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -481,9 +481,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", "cpu": [ "arm" ], @@ -494,9 +494,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", "cpu": [ "arm64" ], @@ -507,9 +507,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", "cpu": [ "arm64" ], @@ -520,9 +520,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", "cpu": [ "x64" ], @@ -533,9 +533,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", "cpu": [ "arm64" ], @@ -546,9 +546,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", "cpu": [ "x64" ], @@ -559,9 +559,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", "cpu": [ "arm" ], @@ -572,9 +572,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", "cpu": [ "arm" ], @@ -585,9 +585,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", "cpu": [ "arm64" ], @@ -598,9 +598,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", "cpu": [ "arm64" ], @@ -611,9 +611,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", "cpu": [ "loong64" ], @@ -624,9 +624,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", "cpu": [ "loong64" ], @@ -637,9 +637,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", "cpu": [ "ppc64" ], @@ -650,9 +650,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", "cpu": [ "ppc64" ], @@ -663,9 +663,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", "cpu": [ "riscv64" ], @@ -676,9 +676,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", "cpu": [ "riscv64" ], @@ -689,9 +689,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", "cpu": [ "s390x" ], @@ -702,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.5.tgz", - "integrity": "sha512-Dq1bqBdLaZ1Gb/l2e5/+o3B18+8TI9ANlA1SkejZqDgdU/jK/ThYaMPMJpVMMXy2uRHvGKbkz9vheVGdq3cJfA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", "cpu": [ "x64" ], @@ -715,9 +715,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", "cpu": [ "x64" ], @@ -728,9 +728,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", "cpu": [ "x64" ], @@ -741,9 +741,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", "cpu": [ "arm64" ], @@ -754,9 +754,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", "cpu": [ "arm64" ], @@ -767,9 +767,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", "cpu": [ "ia32" ], @@ -780,9 +780,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", "cpu": [ "x64" ], @@ -793,9 +793,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", "cpu": [ "x64" ], @@ -806,47 +806,47 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -856,13 +856,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -872,13 +872,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -888,13 +888,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -904,13 +904,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -920,13 +920,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], @@ -936,13 +936,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], @@ -952,13 +952,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], @@ -968,13 +968,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], @@ -984,13 +984,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1005,21 +1005,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -1029,13 +1029,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -1045,29 +1045,41 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", - "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "tailwindcss": "4.1.18" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1099,9 +1111,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.24", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", - "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", "funding": [ { "type": "opencollective", @@ -1118,8 +1130,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001766", + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1135,29 +1147,33 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "funding": [ { "type": "opencollective", @@ -1174,11 +1190,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -1201,9 +1217,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", "funding": [ { "type": "opencollective", @@ -1293,14 +1309,14 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -1316,6 +1332,23 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1349,9 +1382,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -1361,13 +1394,13 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -1392,9 +1425,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1419,9 +1452,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1431,32 +1464,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -1486,9 +1519,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1506,16 +1539,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -1658,9 +1691,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1669,6 +1702,19 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1679,9 +1725,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1707,9 +1753,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -1722,23 +1768,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -1756,9 +1802,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -1776,9 +1822,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -1796,9 +1842,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -1816,9 +1862,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -1836,9 +1882,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -1856,9 +1902,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -1876,9 +1922,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -1896,9 +1942,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -1916,9 +1962,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -1936,9 +1982,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -1955,26 +2001,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2014,10 +2040,16 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -2033,10 +2065,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/picocolors": { "version": "1.1.1", @@ -2045,9 +2080,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -2057,9 +2092,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.18", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz", + "integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==", "funding": [ { "type": "opencollective", @@ -2076,7 +2111,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2091,10 +2126,13 @@ "license": "MIT" }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/require-directory": { "version": "2.1.1", @@ -2106,12 +2144,12 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -2121,47 +2159,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -2172,9 +2197,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -2234,15 +2259,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" @@ -2253,13 +2278,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2314,12 +2339,12 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -2398,9 +2423,9 @@ } }, "node_modules/vite-plugin-full-reload/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" diff --git a/package.json b/package.json index 688bea86..c80a016c 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,16 @@ "dev": "vite" }, "dependencies": { - "@tailwindcss/vite": "^4.1.11", - "autoprefixer": "^10.4.20", - "axios": "^1.7.4", - "concurrently": "^9.0.1", - "laravel-vite-plugin": "^2.0", + "@tailwindcss/vite": "^4.3.2", + "autoprefixer": "^10.5.2", + "axios": "^1.18.1", + "concurrently": "^9.2.4", + "laravel-vite-plugin": "^2.1.0", "tailwindcss": "^4.0.7", - "vite": "^7.0.4" + "vite": "^7.3.6" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@rollup/rollup-linux-x64-gnu": "^4.0.0", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", "lightningcss-linux-x64-gnu": "^1.29.1" } diff --git a/resources/css/app.css b/resources/css/app.css index d780f2de..b9347927 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -460,3 +460,154 @@ select:focus[data-flux-control] { transition: none; } } + +@layer base { + :root { + --admin-sidebar-width: 16rem; + } +} + +@layer components { + .admin-page-container { + @apply mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8; + } + + .admin-page-stack { + @apply space-y-6; + } + + .admin-form-columns { + @apply grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)]; + } + + .admin-section-stack { + @apply space-y-6; + } + + .admin-card { + @apply rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900; + } + + .admin-table-shell { + @apply overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900; + } + + .admin-table { + @apply min-w-full table-auto text-left text-sm; + } + + .admin-table thead { + @apply bg-zinc-50 dark:bg-zinc-800/70; + } + + .admin-table th { + @apply whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400 sm:px-6; + } + + .admin-table td { + @apply px-4 py-4 align-middle text-zinc-700 dark:text-zinc-300 sm:px-6; + } + + .admin-table tbody tr { + @apply transition-colors hover:bg-zinc-50/80 dark:hover:bg-zinc-800/40; + } + + .admin-table tbody tr:has(input[type='checkbox']:checked) { + @apply bg-blue-50/70 dark:bg-blue-950/20; + } + + .admin-table-link { + @apply rounded-sm font-medium text-zinc-950 underline-offset-4 hover:text-blue-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-white dark:hover:text-blue-300; + } + + .admin-sort-button { + @apply -m-2 inline-flex min-h-9 items-center gap-1.5 rounded-lg p-2 text-left font-semibold transition hover:bg-zinc-100 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-blue-600 dark:hover:bg-zinc-700 dark:hover:text-white; + } + + .admin-loading-overlay { + @apply absolute inset-0 z-10 items-center justify-center gap-2 bg-white/70 text-sm font-medium text-zinc-600 backdrop-blur-[1px] dark:bg-zinc-900/70 dark:text-zinc-300; + } + + .admin-empty-state { + @apply rounded-xl border border-dashed border-zinc-300 bg-zinc-50/60 px-6 py-14 text-center dark:border-zinc-700 dark:bg-zinc-900/40; + } + + .admin-list-toolbar { + @apply flex flex-col gap-3 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 sm:flex-row sm:items-center; + } + + .admin-bulk-bar { + @apply flex flex-col gap-3 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-900 dark:bg-blue-950/40 sm:flex-row sm:items-center sm:justify-between; + } + + .admin-tabs { + @apply flex max-w-full items-center gap-1 overflow-x-auto border-b border-zinc-200 dark:border-zinc-700; + scrollbar-width: thin; + } + + .admin-tab { + @apply relative inline-flex min-h-11 shrink-0 items-center gap-2 border-b-2 border-transparent px-3 py-2 text-sm font-medium text-zinc-500 transition hover:border-zinc-300 hover:text-zinc-900 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-zinc-400 dark:hover:border-zinc-600 dark:hover:text-white; + } + + .admin-tab[aria-selected='true'] { + @apply border-blue-600 text-blue-700 dark:border-blue-400 dark:text-blue-300; + } + + .admin-tab-count { + @apply inline-flex min-w-5 items-center justify-center rounded-full bg-zinc-100 px-1.5 py-0.5 text-[0.6875rem] leading-4 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300; + } + + .admin-kpi-grid { + @apply grid gap-4 sm:grid-cols-2 xl:grid-cols-4; + } + + .admin-kpi-card { + @apply relative overflow-hidden rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900; + } + + .admin-sticky-save-bar { + @apply fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_30px_-20px_rgba(0,0,0,0.35)] backdrop-blur dark:border-zinc-700 dark:bg-zinc-900/95; + padding-bottom: env(safe-area-inset-bottom); + } + + @media (min-width: 64rem) { + .admin-sticky-save-bar { + left: var(--admin-sidebar-width); + } + } + + .admin-toast-region { + @apply pointer-events-none fixed right-4 top-4 z-[100] flex w-[calc(100%-2rem)] max-w-sm flex-col gap-2; + } + + .admin-toast { + @apply pointer-events-auto flex items-start gap-3 rounded-xl border border-zinc-200 border-l-4 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-900; + } + + .admin-toast-success { + @apply border-l-emerald-500; + } + + .admin-toast-error { + @apply border-l-red-500; + } + + .admin-toast-info { + @apply border-l-blue-500; + } + + .admin-line-clamp-1, + .admin-line-clamp-2 { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + } + + .admin-line-clamp-1 { + -webkit-line-clamp: 1; + } + + .admin-line-clamp-2 { + -webkit-line-clamp: 2; + } +} diff --git a/resources/views/admin/analytics/index.blade.php b/resources/views/admin/analytics/index.blade.php new file mode 100644 index 00000000..7f88ceb0 --- /dev/null +++ b/resources/views/admin/analytics/index.blade.php @@ -0,0 +1,17 @@ +
      + Export CSV@if($exportUrl)Download export@endif +
      TodayLast 7 daysLast 30 daysCustomAllStorefrontAPIAllDesktopMobileTablet@if($dateRange === 'custom')@endif
      +

      {{ number_format($ordersCount) }}

      {{ number_format($conversionRate, 2) }}%

      +
      @php $max=max(1,collect($salesChartData)->max('revenue') ?? 1); @endphp @forelse($salesChartData as $point)
      @empty

      No sales data for this period.

      @endforelse
      +
      Visits
      {{ number_format($visitsCount) }}
      Add to cart
      {{ number_format($addToCartCount) }}
      Checkout started
      {{ number_format($checkoutStartedCount) }}
      Orders
      {{ number_format($ordersCount) }}
      @forelse($topReferrers as $source)
      {{ $source['source'] }}{{ $source['sessions'] }} sessions{{ $source['conversion'] }}%
      @empty

      No referrer data yet.

      @endforelse
      + + + RankProductUnits soldRevenueShare + @forelse($topProducts as $index => $product) + {{ $index + 1 }}{{ $product['title'] }}{{ $product['units'] }}{{ $product['percentage'] }}% + @empty + + @endforelse + + +
      diff --git a/resources/views/admin/apps/index.blade.php b/resources/views/admin/apps/index.blade.php new file mode 100644 index 00000000..7cd9f4ab --- /dev/null +++ b/resources/views/admin/apps/index.blade.php @@ -0,0 +1,5 @@ +
      + +

      Installed apps

      @if($this->installedApps->isEmpty())@else@endif
      + @if($this->availableApps->isNotEmpty())

      Available apps

      @foreach($this->availableApps as $app)

      {{ $app->name }}

      Ready to install

      Install
      @endforeach
      @endif +
      diff --git a/resources/views/admin/apps/show.blade.php b/resources/views/admin/apps/show.blade.php new file mode 100644 index 00000000..5da5e4c4 --- /dev/null +++ b/resources/views/admin/apps/show.blade.php @@ -0,0 +1,25 @@ +
      + + + @php($status = $installation->status instanceof BackedEnum ? $installation->status->value : $installation->status) + @if($status === 'active') + Suspend + @elseif($status === 'suspended') + Resume + @endif + Uninstall + + +

      Installed {{ $installation->installed_at?->diffForHumans() ?? 'recently' }}

      {{ number_format($this->usage['calls']) }}

      Recorded webhook calls

      {{ $this->usage['last_call_at'] ? Illuminate\Support\Carbon::parse($this->usage['last_call_at'])->diffForHumans() : 'Never' }}

      +
      @forelse((array)$installation->scopes_json as $scope)@empty

      No scopes granted.

      @endforelse
      + + + EventURLStatusDeliveries + @forelse($this->webhooks as $webhook) + {{ $webhook->event_type }}{{ $webhook->target_url }}{{ $webhook->deliveries->count() }} + @empty + + @endforelse + + +
      diff --git a/resources/views/admin/auth/forgot-password.blade.php b/resources/views/admin/auth/forgot-password.blade.php new file mode 100644 index 00000000..a6a2f32d --- /dev/null +++ b/resources/views/admin/auth/forgot-password.blade.php @@ -0,0 +1,13 @@ +
      +

      Reset your password

      +

      Enter your email and we’ll send reset instructions if an account exists.

      + @if($sent) +
      If that email exists, we sent a reset link.
      + @else +
      + + Send reset link + + @endif + Back to sign in +
      diff --git a/resources/views/admin/auth/login.blade.php b/resources/views/admin/auth/login.blade.php new file mode 100644 index 00000000..989e290e --- /dev/null +++ b/resources/views/admin/auth/login.blade.php @@ -0,0 +1,16 @@ +
      +

      Sign in to your store

      +

      Manage products, orders, customers, and your storefront.

      + @if(session('status'))
      {{ session('status') }}
      @endif +
      + + + + + Sign inSigning in… + + +
      diff --git a/resources/views/admin/auth/reset-password.blade.php b/resources/views/admin/auth/reset-password.blade.php new file mode 100644 index 00000000..1e6f491d --- /dev/null +++ b/resources/views/admin/auth/reset-password.blade.php @@ -0,0 +1,9 @@ +
      +

      Choose a new password

      +
      + + + + Reset password + +
      diff --git a/resources/views/admin/collections/form.blade.php b/resources/views/admin/collections/form.blade.php new file mode 100644 index 00000000..9579b621 --- /dev/null +++ b/resources/views/admin/collections/form.blade.php @@ -0,0 +1,19 @@ +
      + +
      +
      +
      + +
      + @if($this->searchResults->isNotEmpty())
      @foreach($this->searchResults as $result)
      {{ $result->title }}Add
      @endforeach
      @endif +
      +
      + @forelse($this->assignedProducts as $assigned)
      @if($assigned->media->first())@else@endif
      {{ $assigned->title }}
      + @empty

      No products assigned yet.

      @endforelse +
      +
      +
      + ActiveArchived +
      + + diff --git a/resources/views/admin/collections/index.blade.php b/resources/views/admin/collections/index.blade.php new file mode 100644 index 00000000..4bd4538f --- /dev/null +++ b/resources/views/admin/collections/index.blade.php @@ -0,0 +1,14 @@ +
      + @if($adminRole !== 'support')Add collection@endif + All statusesActiveArchived + @if($this->collections->isEmpty() && $search === '' && $statusFilter === 'all') + Add collection + @else + + TitleProductsStatusUpdatedActions + @forelse($this->collections as $collection)@if($adminRole === 'support'){{ $collection->title }}@else{{ $collection->title }}@endif

      /{{ $collection->handle }}

      {{ $collection->products_count }}{{ $collection->updated_at->diffForHumans(short: true) }}@if(in_array($adminRole, ['owner','admin']))@endif + @empty@endforelse + {{ $this->collections->links() }} +
      + @endif +
      diff --git a/resources/views/admin/customers/index.blade.php b/resources/views/admin/customers/index.blade.php new file mode 100644 index 00000000..c698db92 --- /dev/null +++ b/resources/views/admin/customers/index.blade.php @@ -0,0 +1,10 @@ +
      + + + + NameEmailOrdersTotal spentCustomer since + @forelse($this->customers as $customer){{ $customer->name ?: 'Guest customer' }}{{ $customer->email }}{{ $customer->orders_count }}{{ $customer->created_at->format('M j, Y') }} + @empty@endforelse + {{ $this->customers->links() }} + +
      diff --git a/resources/views/admin/customers/show.blade.php b/resources/views/admin/customers/show.blade.php new file mode 100644 index 00000000..b3560cd2 --- /dev/null +++ b/resources/views/admin/customers/show.blade.php @@ -0,0 +1,18 @@ +
      + +
      +
      +
      Name
      {{ $customer->name ?: 'Not provided' }}
      Email
      {{ $customer->email }}
      Created
      {{ $customer->created_at->format('M j, Y g:i A') }}
      Account
      {{ $customer->password_hash ? 'Registered' : 'Guest checkout' }}
      + +
      @forelse($this->orders as $order)@empty@endforelse
      OrderDatePaymentTotal
      {{ $order->order_number }}{{ ($order->placed_at ?? $order->created_at)->format('M j, Y') }}
      This customer has no orders yet.
      +
      {{ $this->orders->links() }}
      +
      +
      + + @if($adminRole !== 'support')Add@endif +
      @forelse($customer->addresses as $address)@php $a = (array)$address->address_json; @endphp

      {{ $address->label ?: 'Address' }}

      @if($address->is_default)@endif
      @if($adminRole !== 'support')
      @endif

      {{ trim(($a['first_name'] ?? '').' '.($a['last_name'] ?? '')) }}

      {{ $a['address1'] ?? '' }}

      @if($a['address2'] ?? null)

      {{ $a['address2'] }}

      @endif

      {{ $a['zip'] ?? '' }} {{ $a['city'] ?? '' }}

      {{ $a['country'] ?? $a['country_code'] ?? '' }}

      @if(!$address->is_default && $adminRole !== 'support')Set as default@endif
      @empty

      No saved addresses.

      @endforelse
      +
      +
      + +
      {{ $editingAddress ? 'Edit address' : 'Add address' }}Keep delivery details accurate for customer support.
      CancelSave address
      +
      diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php new file mode 100644 index 00000000..c1edca97 --- /dev/null +++ b/resources/views/admin/dashboard.blade.php @@ -0,0 +1,66 @@ +
      + + + + Today + Last 7 days + Last 30 days + Custom range + + + + + @if($dateRange === 'custom') + + + + + @endif + + @php + $kpis = [ + ['label' => 'Total sales', 'value' => number_format($totalSales / 100, 2).' '.$adminStore->default_currency, 'change' => $salesChange], + ['label' => 'Orders', 'value' => number_format($ordersCount), 'change' => $ordersChange], + ['label' => 'Average order value', 'value' => number_format($averageOrderValue / 100, 2).' '.$adminStore->default_currency, 'change' => $aovChange], + ['label' => 'Visitors', 'value' => number_format($visitorsCount), 'change' => $visitorsChange], + ]; + @endphp +
      + @foreach($kpis as $kpi) + +

      {{ $kpi['label'] }}

      +

      {{ $kpi['value'] }}

      + +
      + @endforeach +
      + + @php $chartMax = max(1, ...array_column($ordersChartData, 'count')); @endphp + + + + +
      + +
      + @forelse($topProducts as $product) + @empty@endforelse +
      ProductUnits soldRevenue
      {{ $product['title'] }}{{ number_format($product['units_sold']) }}
      No sales data for this period.
      +
      + + @php $funnelMax = max(1, $funnelData['visits']); $steps = ['visits' => 'Visits', 'add_to_cart' => 'Add to cart', 'checkout_started' => 'Checkout started', 'checkout_completed' => 'Checkout completed']; @endphp +
      + @foreach($steps as $key => $label) +
      {{ $label }}{{ number_format($funnelData[$key]) }}
      + @endforeach +
      +
      +
      +
      diff --git a/resources/views/admin/developers/index.blade.php b/resources/views/admin/developers/index.blade.php new file mode 100644 index 00000000..b3fd8a7f --- /dev/null +++ b/resources/views/admin/developers/index.blade.php @@ -0,0 +1,28 @@ +
      + + @if($generatedToken)

      Copy this token now

      It will not be shown again.

      {{ $generatedToken }}
      @endif + + Generate new token + + NameLast usedCreatedActions + @forelse($this->tokens as $token) + {{ Str::after($token->name, 'store:'.$adminStore->id.':') }}{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}{{ $token->created_at->format('M j, Y') }}Revoke + @empty + + @endforelse + + + + Add webhook + + Event typeEndpointStatusDeliveriesActions + @forelse($this->webhooks as $webhook) + {{ $webhook->event_type }}{{ $webhook->target_url }}{{ $webhook->deliveries->count() }}
      + @empty + + @endforelse +
      +
      +
      Generate API token
      CancelGenerate
      +
      {{ $editingWebhook ? 'Edit webhook' : 'Add webhook' }}@foreach($this->eventTypes() as $eventType){{ $eventType }}@endforeach
      CancelSave
      +
      diff --git a/resources/views/admin/discounts/form.blade.php b/resources/views/admin/discounts/form.blade.php new file mode 100644 index 00000000..33d23a5b --- /dev/null +++ b/resources/views/admin/discounts/form.blade.php @@ -0,0 +1,17 @@ +
      + +
      + @if($type === 'code')
      Generate
      @endif +
      @if($valueType !== 'free_shipping')
      @endif
      + + +
      +
      @if($this->productResults->isNotEmpty())
      @foreach($this->productResults as $product)@endforeach
      @endif
      @foreach($this->selectedProducts as $product){{ $product->title }}@endforeach
      +
      @if($this->collectionResults->isNotEmpty())
      @foreach($this->collectionResults as $collection)@endforeach
      @endif
      @foreach($this->selectedCollections as $collection){{ $collection->title }}@endforeach
      +
      +
      +
      +
      + + + diff --git a/resources/views/admin/discounts/index.blade.php b/resources/views/admin/discounts/index.blade.php new file mode 100644 index 00000000..a1d38ae4 --- /dev/null +++ b/resources/views/admin/discounts/index.blade.php @@ -0,0 +1,8 @@ +
      + @if($adminRole !== 'support')Create discount@endif + All statusesActiveScheduledExpired + DiscountTypeValueUsageStatusDatesActions + @forelse($this->discounts as $discount)@php $valueType = $discount->value_type->value ?? $discount->value_type; $status = $discount->status->value ?? $discount->status; $displayStatus = $status === 'active' && $discount->starts_at?->isFuture() ? 'scheduled' : ($discount->ends_at?->isPast() ? 'expired' : $status); @endphp@if($adminRole === 'support'){{ $discount->code ?: 'Automatic discount' }}@else{{ $discount->code ?: 'Automatic discount' }}@endif@if($valueType === 'percent'){{ $discount->value_amount }}%@elseif($valueType === 'fixed')@else Free shipping @endif{{ $discount->usage_count }} / {{ $discount->usage_limit ?? 'unlimited' }}{{ $discount->starts_at?->format('M j, Y') }}@if($discount->ends_at)
      to {{ $discount->ends_at->format('M j, Y') }}@endif@if(in_array($adminRole,['owner','admin']))@endif + @empty@endforelse{{ $this->discounts->links() }} +
      +
      diff --git a/resources/views/admin/inventory/index.blade.php b/resources/views/admin/inventory/index.blade.php new file mode 100644 index 00000000..a80662de --- /dev/null +++ b/resources/views/admin/inventory/index.blade.php @@ -0,0 +1,12 @@ +
      + + All stockIn stockLow stockOut of stock + + ProductVariantSKUOn handReservedAvailablePolicy + @forelse($this->inventoryItems as $item) + @php $variantName = $item->variant->optionValues->sortBy(fn($value) => $value->option?->position)->pluck('value')->implode(' / ') ?: 'Default'; $available = $item->availableQuantity(); @endphp + {{ $item->variant->product->title }}{{ $variantName }}{{ $item->variant->sku ?: 'β€”' }}{{ $item->quantity_reserved }} + @empty@endforelse + {{ $this->inventoryItems->links() }} + +
      diff --git a/resources/views/admin/layout/sidebar.blade.php b/resources/views/admin/layout/sidebar.blade.php new file mode 100644 index 00000000..d99c3ca6 --- /dev/null +++ b/resources/views/admin/layout/sidebar.blade.php @@ -0,0 +1,43 @@ +@php + $items = [ + ['label' => 'Dashboard', 'path' => '/admin', 'match' => 'admin.dashboard', 'icon' => 'chart-bar-square'], + ['group' => 'Products'], + ['label' => 'Products', 'path' => '/admin/products', 'match' => 'admin.products.*', 'icon' => 'cube'], + ['label' => 'Collections', 'path' => '/admin/collections', 'match' => 'admin.collections.*', 'icon' => 'rectangle-stack'], + ['label' => 'Inventory', 'path' => '/admin/inventory', 'match' => 'admin.inventory.*', 'icon' => 'archive-box'], + ['group' => 'Sales'], + ['label' => 'Orders', 'path' => '/admin/orders', 'match' => 'admin.orders.*', 'icon' => 'shopping-bag'], + ['label' => 'Customers', 'path' => '/admin/customers', 'match' => 'admin.customers.*', 'icon' => 'users'], + ['label' => 'Discounts', 'path' => '/admin/discounts', 'match' => 'admin.discounts.*', 'icon' => 'tag'], + ['group' => 'Content'], + ['label' => 'Pages', 'path' => '/admin/pages', 'match' => 'admin.pages.*', 'icon' => 'document-text'], + ['label' => 'Navigation', 'path' => '/admin/navigation', 'match' => 'admin.navigation.*', 'icon' => 'bars-3', 'roles' => ['owner','admin']], + ['label' => 'Themes', 'path' => '/admin/themes', 'match' => 'admin.themes.*', 'icon' => 'paint-brush', 'roles' => ['owner','admin']], + ['group' => 'Insights'], + ['label' => 'Analytics', 'path' => '/admin/analytics', 'match' => 'admin.analytics.*', 'icon' => 'chart-pie', 'roles' => ['owner','admin','staff']], + ['label' => 'Search', 'path' => '/admin/search/settings', 'match' => 'admin.search.*', 'icon' => 'magnifying-glass', 'roles' => ['owner','admin']], + ['label' => 'Settings', 'path' => '/admin/settings', 'match' => 'admin.settings.*', 'icon' => 'cog-6-tooth', 'roles' => ['owner','admin']], + ['label' => 'Apps', 'path' => '/admin/apps', 'match' => 'admin.apps.*', 'icon' => 'squares-2x2', 'roles' => ['owner','admin']], + ['label' => 'Developers', 'path' => '/admin/developers', 'match' => 'admin.developers.*', 'icon' => 'code-bracket', 'roles' => ['owner','admin']], + ]; +@endphp + diff --git a/resources/views/admin/layout/top-bar.blade.php b/resources/views/admin/layout/top-bar.blade.php new file mode 100644 index 00000000..fa0befb0 --- /dev/null +++ b/resources/views/admin/layout/top-bar.blade.php @@ -0,0 +1,36 @@ +
      +
      + + + {{ $currentStoreName }} + + @foreach($stores as $store) + {{ $store->name }} + @endforeach + + +
      +
      + + + + + {{ $user?->initials() }} + + + + +

      {{ $user?->name }}

      {{ $user?->email }}

      + + @if(in_array($role, ['owner', 'admin'])) + Settings + + @endif + Log out +
      +
      +
      +
      diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php new file mode 100644 index 00000000..b8ea91c9 --- /dev/null +++ b/resources/views/admin/layouts/app.blade.php @@ -0,0 +1,57 @@ + + + + @include('partials.head') + + + + Skip to main content + + + + +
      + +
      + @if(($breadcrumbs ?? []) !== []) + + @endif + {{ $slot }} +
      +
      + +
      + +
      + @fluxScripts + + diff --git a/resources/views/admin/layouts/auth.blade.php b/resources/views/admin/layouts/auth.blade.php new file mode 100644 index 00000000..0e11e629 --- /dev/null +++ b/resources/views/admin/layouts/auth.blade.php @@ -0,0 +1,26 @@ + + + + @include('partials.head') + + + +
      + +
      + @fluxScripts + + diff --git a/resources/views/admin/navigation/index.blade.php b/resources/views/admin/navigation/index.blade.php new file mode 100644 index 00000000..5b90e120 --- /dev/null +++ b/resources/views/admin/navigation/index.blade.php @@ -0,0 +1,9 @@ +
      + +

      Menus

      @foreach($this->menus as $menu)@endforeach
      + @if($editingMenu)Add item +
      @forelse($menuItems as $index => $item)

      {{ $item['label'] }}

      {{ $item['type'] }}: {{ $item['url'] ?: ($item['resource_id'] ?? 'β€”') }}

      @empty

      This menu has no items.

      @endforelse
      +
      Save menu
      +
      @else@endif +
      {{ $editingItemIndex === null ? 'Add menu item' : 'Edit menu item' }}Custom linkPageCollectionProduct@if($itemType === 'link')@else@foreach(($this->resources[$itemType] ?? []) as $resource){{ $resource['label'] }}@endforeach@endif
      CancelSave item
      +
      diff --git a/resources/views/admin/orders/index.blade.php b/resources/views/admin/orders/index.blade.php new file mode 100644 index 00000000..53c909cc --- /dev/null +++ b/resources/views/admin/orders/index.blade.php @@ -0,0 +1,11 @@ +
      + + +
      @foreach(['all','pending','paid','fulfilled','cancelled','refunded'] as $status)@endforeach
      + + CustomerPaymentFulfillment + @forelse($this->orders as $order){{ $order->order_number }}{{ $order->placed_at?->format('M j, Y g:i A') ?: $order->created_at->format('M j, Y g:i A') }}

      {{ $order->customer?->name ?: 'Guest' }}

      {{ $order->email }}

      + @empty@endforelse + {{ $this->orders->links() }} +
      +
      diff --git a/resources/views/admin/orders/show.blade.php b/resources/views/admin/orders/show.blade.php new file mode 100644 index 00000000..5be46d28 --- /dev/null +++ b/resources/views/admin/orders/show.blade.php @@ -0,0 +1,45 @@ +@php + $financial = $order->financial_status->value ?? $order->financial_status; + $fulfillmentStatus = $order->fulfillment_status->value ?? $order->fulfillment_status; + $paymentMethod = $order->payment_method->value ?? $order->payment_method; + $addressLines = fn($address) => array_filter([trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')), $address['line1'] ?? $address['address_line_1'] ?? null, $address['line2'] ?? $address['address_line_2'] ?? null, trim(($address['postal_code'] ?? '').' '.($address['city'] ?? '')), $address['province'] ?? $address['state'] ?? null, $address['country'] ?? $address['country_code'] ?? null]); +@endphp +
      + + + + +
      + @if($paymentMethod === 'bank_transfer' && $financial === 'pending' && $adminRole !== 'support')Confirm payment@endif + @if(in_array($financial, ['paid','partially_refunded']) && $fulfillmentStatus !== 'fulfilled' && $adminRole !== 'support')Create fulfillment@endif + @if(in_array($financial, ['paid','partially_refunded']) && in_array($adminRole, ['owner','admin']))Refund@endif +
      + @if(!in_array($financial, ['paid','partially_refunded']))Payment must be confirmed before items can be fulfilled. Current financial status: {{ Str::headline($financial) }}.@endif + +
      +
      + +
      @foreach($order->lines as $line)@endforeach
      ProductSKUQtyUnit priceTotal
      {{ $line->title_snapshot }}{{ $line->sku_snapshot ?: 'β€”' }}{{ $line->quantity }}
      +
      Subtotal
      Discount
      -
      Shipping
      Tax
      Total
      +
      + +
      @forelse($order->payments as $payment)

      {{ Str::headline($payment->method->value ?? $payment->method) }}

      {{ $payment->provider_payment_id ?: 'No provider reference' }}

      @empty

      No payment records.

      @endforelse
      + + +
      @forelse($order->fulfillments as $fulfillment)
      @if(($fulfillment->status->value ?? $fulfillment->status) === 'pending' && $adminRole !== 'support')Mark shipped@elseif(($fulfillment->status->value ?? $fulfillment->status) === 'shipped' && $adminRole !== 'support')Mark delivered@endif

      {{ $fulfillment->tracking_company ?: 'No carrier' }} Β· {{ $fulfillment->tracking_number ?: 'No tracking number' }}

      @if($fulfillment->tracking_url)Track shipment@endif
        @foreach($fulfillment->lines as $line)
      • {{ $line->quantity }} Γ— {{ $line->orderLine?->title_snapshot }}
      • @endforeach
      @empty

      No fulfillments created yet.

      @endforelse
      +
      + +
      1. Order placed

        {{ ($order->placed_at ?? $order->created_at)->format('M j, Y g:i A') }}

      2. @foreach($order->payments as $payment)
      3. Payment {{ Str::headline($payment->status->value ?? $payment->status) }}

        {{ $payment->created_at?->format('M j, Y g:i A') }}

      4. @endforeach @foreach($order->refunds as $refund)
      5. Refund {{ Str::headline($refund->status->value ?? $refund->status) }}

        Β· {{ $refund->created_at?->format('M j, Y g:i A') }}

      6. @endforeach
      +
      + +
      +

      {{ $order->customer?->name ?: 'Guest' }}

      {{ $order->email }}

      @if($order->customer)View customer@endif
      +
      @foreach($addressLines((array)$order->shipping_address_json) as $line)

      {{ $line }}

      @endforeach
      +
      @foreach($addressLines((array)$order->billing_address_json) as $line)

      {{ $line }}

      @endforeach
      +
      +
      + +
      Create fulfillmentChoose the quantities to ship now.
      @foreach($order->lines as $line)@php $remaining = max(0, $line->quantity - $line->fulfillmentLines->sum('quantity')); @endphp @if($remaining > 0)

      {{ $line->title_snapshot }}

      {{ $remaining }} available to fulfill

      @endif @endforeach
      CancelCreate fulfillment
      + +
      Refund orderRefund a custom amount and optionally restock selected quantities.
      @foreach($order->lines as $line)

      {{ $line->title_snapshot }}

      Select a quantity to restock

      @endforeach
      CancelCreate refund
      +
      diff --git a/resources/views/admin/pages/form.blade.php b/resources/views/admin/pages/form.blade.php new file mode 100644 index 00000000..14775f91 --- /dev/null +++ b/resources/views/admin/pages/form.blade.php @@ -0,0 +1,8 @@ +
      + @if($page)Delete page@endif +
      +
      +
      DraftPublishedArchived
      +
      + + diff --git a/resources/views/admin/pages/index.blade.php b/resources/views/admin/pages/index.blade.php new file mode 100644 index 00000000..a1012268 --- /dev/null +++ b/resources/views/admin/pages/index.blade.php @@ -0,0 +1,10 @@ +
      + Create page + + @if($this->pages->isEmpty() && $search === '')Create page@else + TitleHandleStatusUpdated + @forelse($this->pages as $page){{ $page->title }}/{{ $page->handle }}{{ $page->updated_at->diffForHumans(short: true) }}@empty@endforelse + {{ $this->pages->links() }} + + @endif +
      diff --git a/resources/views/admin/products/form.blade.php b/resources/views/admin/products/form.blade.php new file mode 100644 index 00000000..ba663bb3 --- /dev/null +++ b/resources/views/admin/products/form.blade.php @@ -0,0 +1,71 @@ +
      + + + @if($product && in_array($adminRole, ['owner', 'admin'])) + + Archive + + @endif + + + +
      +
      + +
      + + +
      +
      + + + @if($media) +
      + @foreach($media as $item) +
      +
      {{ $item['alt_text'] }}
      +
      +
      + @endforeach +
      + @endif + +
      Preparing uploads…
      @error('newMedia.*')

      {{ $message }}

      @enderror +
      + + +
      + @foreach($options as $optionIndex => $option) +
      +
      +
      + @foreach($option['values'] as $valueIndex => $value)
      @endforeach +
      + Add value +
      + @endforeach + @if(count($options) < 3)Add option@endif +
      +
      + @foreach($variants as $variantIndex => $variant)@endforeach +
      VariantSKUPrice (minor)Compare atQuantityShips
      {{ $variant['title'] }}
      +
      + + {{ url('/products') }}/{{ $handle ?: 'product-handle' }} +
      + +
      + DraftActiveArchived + +
      Separate tags with commas.
      +
      @forelse($this->availableCollections as $collection)@empty

      No collections yet.

      @endforelse
      +
      +
      + + @if($adminRole !== 'support') + + @endif + diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php new file mode 100644 index 00000000..d7f66d37 --- /dev/null +++ b/resources/views/admin/products/index.blade.php @@ -0,0 +1,48 @@ +
      + + @if($adminRole !== 'support')Add product@endif + + + + + + All statusesDraftActiveArchived + All types@foreach($this->productTypes as $type){{ $type }}@endforeach + + + + @if(count($selectedIds) && $adminRole !== 'support') +
      +

      {{ count($selectedIds) }} {{ Str::plural('product', count($selectedIds)) }} selected

      +
      + Set active + Archive + @if(in_array($adminRole, ['owner', 'admin'])) + + Delete + + @endif +
      +
      + @endif + + @if($this->products->isEmpty() && $search === '' && $statusFilter === 'all' && $typeFilter === '') + Add product + @else + + ImageStatusInventoryTypeVendor + @forelse($this->products as $product) + @php $inventory = $product->variants->sum(fn($variant) => (int) ($variant->inventoryItem?->quantity_on_hand ?? 0)); $image = $product->media->first(); @endphp + + + @if($image)@else
      @endif + {{ $product->title }}

      {{ $product->variants_count }} {{ Str::plural('variant', $product->variants_count) }}

      + + {{ $inventory }} + {{ $product->product_type ?: 'β€”' }}{{ $product->vendor ?: 'β€”' }}{{ $product->updated_at->diffForHumans(short: true) }} + + @empty@endforelse + {{ $this->products->links() }} +
      + @endif +
      diff --git a/resources/views/admin/search/settings.blade.php b/resources/views/admin/search/settings.blade.php new file mode 100644 index 00000000..52cbea2e --- /dev/null +++ b/resources/views/admin/search/settings.blade.php @@ -0,0 +1,7 @@ +
      + +
      @foreach($synonymGroups as $index => $group)
      @endforeach
      Add synonym group
      + +
      Reindex now

      Last indexed: {{ $lastIndexedAt ? Illuminate\Support\Carbon::parse($lastIndexedAt)->diffForHumans() : 'Never' }}

      @if($reindexProgress !== null)

      {{ $reindexProgress }}% complete

      @endif
      +
      Save
      +
      diff --git a/resources/views/admin/settings/checkout.blade.php b/resources/views/admin/settings/checkout.blade.php new file mode 100644 index 00000000..40193c92 --- /dev/null +++ b/resources/views/admin/settings/checkout.blade.php @@ -0,0 +1,9 @@ +
      + + +
      +
      + +
      Save
      +
      +
      diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php new file mode 100644 index 00000000..d7f3a999 --- /dev/null +++ b/resources/views/admin/settings/index.blade.php @@ -0,0 +1,40 @@ +
      + + + + + @if($activeTab === 'general') +
      + +
      +
      + +
      + EURUSDGBPCHFCADAUD + EnglishGermanFrenchSpanishItalianDutch +
      @foreach(timezone_identifiers_list() as $zone){{ $zone }}@endforeach
      +
      +
      +
      Save
      +
      + @else + + Add domain + + HostnameTypePrimaryTLSActions + @forelse($this->domains as $domain) + {{ $domain->hostname }}@if($domain->is_primary)@elseβ€”@endif
      @unless($domain->is_primary)Set primary
      @endunless + @empty@endforelse +
      +
      + @endif + +
      Add domainEnter a hostname without a protocol or path.
      StorefrontAdminAPI
      CancelAdd domain
      +
      diff --git a/resources/views/admin/settings/notifications.blade.php b/resources/views/admin/settings/notifications.blade.php new file mode 100644 index 00000000..fc2b83b4 --- /dev/null +++ b/resources/views/admin/settings/notifications.blade.php @@ -0,0 +1,9 @@ +
      + + +
      +
      + +
      Save
      +
      +
      diff --git a/resources/views/admin/settings/shipping.blade.php b/resources/views/admin/settings/shipping.blade.php new file mode 100644 index 00000000..64c87d2b --- /dev/null +++ b/resources/views/admin/settings/shipping.blade.php @@ -0,0 +1,23 @@ +
      + Add zone + + +
      + @forelse($this->zones as $zone) + + EditDelete + NameTypePriceStatusActions + @forelse($zone->rates as $rate)@php $config=(array)$rate->config_json; $amount=(int)data_get($config,'amount',data_get($config,'ranges.0.amount',data_get($config,'fallback_amount',0))); @endphp + {{ $rate->name }}
      Edit
      + @empty@endforelse +
      +
      Add rate
      +
      + @emptyAdd zone@endforelse +
      + +
      @foreach($this->countries as $code => $name){{ $name }}@endforeach
      Test
      @if($testResult)
      @if($testResult['zone'])

      Matched zone: {{ $testResult['zone'] }}

        @foreach($testResult['rates'] as $resultRate)
      • {{ $resultRate['name'] }} β€”
      • @endforeach
      @else

      No shipping zone matches this address.

      @endif
      @endif
      + +
      {{ $editingZone ? 'Edit shipping zone' : 'Add shipping zone' }}
      Countries
      @foreach($this->countries as $code => $name)@endforeach
      @error('zoneCountries')

      {{ $message }}

      @enderror
      CancelSave zone
      +
      {{ $editingRate ? 'Edit shipping rate' : 'Add shipping rate' }}Flat rateWeight-basedPrice-basedCarrier-calculated@if($rateType === 'weight')
      @elseif($rateType === 'price')
      @elseif($rateType === 'carrier')

      Carrier-calculated rates require a carrier integration to be configured.

      @endif @unless($rateType === 'carrier')@endunless
      CancelSave
      +
      diff --git a/resources/views/admin/settings/taxes.blade.php b/resources/views/admin/settings/taxes.blade.php new file mode 100644 index 00000000..12626313 --- /dev/null +++ b/resources/views/admin/settings/taxes.blade.php @@ -0,0 +1,8 @@ +
      + + +
      + @if($mode === 'manual')
      @foreach($manualRates as $index => $rate)
      @endforeach
      Add rate
      @else
      Stripe TaxNone
      @endif + +
      Save
      + diff --git a/resources/views/admin/themes/editor.blade.php b/resources/views/admin/themes/editor.blade.php new file mode 100644 index 00000000..a42c7944 --- /dev/null +++ b/resources/views/admin/themes/editor.blade.php @@ -0,0 +1,16 @@ +
      +
      Back to themes
      SaveSave and publish
      +
      + +
      + +
      +
      diff --git a/resources/views/admin/themes/index.blade.php b/resources/views/admin/themes/index.blade.php new file mode 100644 index 00000000..e6908c58 --- /dev/null +++ b/resources/views/admin/themes/index.blade.php @@ -0,0 +1,17 @@ +
      + + @if($this->themes->isEmpty()) + + @else +
      + @foreach($this->themes as $theme)@php $status=$theme->status instanceof BackedEnum ? $theme->status->value : $theme->status; @endphp +
      $status === 'published', 'border-zinc-200 dark:border-zinc-800' => $status !== 'published'])> +
      +

      {{ $theme->name }}

      Version {{ $theme->version }}

      +
      Customize@if($status !== 'published')Publish@endifDuplicate@if($status !== 'published')@endif
      +
      +
      + @endforeach +
      + @endif +
      diff --git a/resources/views/components/admin/breadcrumbs.blade.php b/resources/views/components/admin/breadcrumbs.blade.php new file mode 100644 index 00000000..750e23bf --- /dev/null +++ b/resources/views/components/admin/breadcrumbs.blade.php @@ -0,0 +1,41 @@ +@props([ + 'items' => [], + 'includeHome' => true, +]) + +@php + $items = collect($items)->values(); + $firstLabel = strtolower((string) data_get($items->first(), 'label', '')); + + if ($includeHome && $firstLabel !== 'home') { + $items->prepend(['label' => __('Home'), 'url' => url('/admin')]); + } +@endphp + + diff --git a/resources/views/components/admin/card.blade.php b/resources/views/components/admin/card.blade.php new file mode 100644 index 00000000..d596d1c0 --- /dev/null +++ b/resources/views/components/admin/card.blade.php @@ -0,0 +1,40 @@ +@props([ + 'as' => 'section', + 'title' => null, + 'description' => null, + 'headingLevel' => 'h2', + 'padding' => true, +]) + +@php + $tag = in_array(strtolower((string) $as), ['div', 'section', 'article', 'aside'], true) + ? strtolower((string) $as) + : 'section'; + $headingLevel = in_array(strtolower((string) $headingLevel), ['h2', 'h3', 'h4'], true) + ? strtolower((string) $headingLevel) + : 'h2'; +@endphp + +<{{ $tag }} {{ $attributes->class(['admin-card', 'p-6' => $padding]) }}> + @if (filled($title) || filled($description) || isset($actions)) +
      +
      + @if (filled($title)) + <{{ $headingLevel }} class="text-base font-semibold text-zinc-950 dark:text-white">{{ $title }} + @endif + + @if (filled($description)) +

      {{ $description }}

      + @endif +
      + + @isset($actions) +
      {{ $actions }}
      + @endisset +
      + @endif + +
      filled($title) || filled($description) || isset($actions)])> + {{ $slot }} +
      + diff --git a/resources/views/components/admin/confirmation-modal.blade.php b/resources/views/components/admin/confirmation-modal.blade.php new file mode 100644 index 00000000..e5a3b6e0 --- /dev/null +++ b/resources/views/components/admin/confirmation-modal.blade.php @@ -0,0 +1,56 @@ +@props([ + 'name', + 'title', + 'description' => null, + 'confirmAction' => null, + 'confirmLabel' => 'Confirm', + 'confirmingLabel' => 'Working…', + 'cancelLabel' => 'Cancel', + 'target' => null, + 'show' => false, +]) + +@php + $name = preg_match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', (string) $name) === 1 ? (string) $name : 'confirmation-modal'; + $confirmAction = filled($confirmAction) && preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\([^)]*\))?$/', (string) $confirmAction) === 1 + ? (string) $confirmAction + : null; + if (blank($target) && filled($confirmAction)) { + $target = \Illuminate\Support\Str::before($confirmAction, '('); + } +@endphp + +@isset($trigger) + {{ $trigger }} +@endisset + + +
      +
      + {{ $title }} + + @if (filled($description)) + {{ $description }} + @endif + + @if (! $slot->isEmpty()) +
      {{ $slot }}
      + @endif +
      + +
      + + {{ $cancelLabel }} + + + @if (filled($confirmAction)) + + {{ $confirmLabel }} + {{ $confirmingLabel }} + + @else + {{ $confirmLabel }} + @endif +
      +
      +
      diff --git a/resources/views/components/admin/empty-state.blade.php b/resources/views/components/admin/empty-state.blade.php new file mode 100644 index 00000000..b0fe0cf8 --- /dev/null +++ b/resources/views/components/admin/empty-state.blade.php @@ -0,0 +1,25 @@ +@props([ + 'title', + 'description' => null, +]) + +
      class('admin-empty-state') }}> + @isset($icon) +
      {{ $icon }}
      + @else + + @endisset + +

      {{ $title }}

      + + @if (filled($description)) +

      {{ $description }}

      + @endif + + @isset($action) +
      {{ $action }}
      + @endisset +
      diff --git a/resources/views/components/admin/field.blade.php b/resources/views/components/admin/field.blade.php new file mode 100644 index 00000000..7ba1a649 --- /dev/null +++ b/resources/views/components/admin/field.blade.php @@ -0,0 +1,33 @@ +@props([ + 'name', + 'label', + 'for' => null, + 'description' => null, + 'error' => null, + 'required' => false, +]) + +@php + $errors ??= new \Illuminate\Support\ViewErrorBag(); + $error ??= $errors->first((string) $name); + $fieldId = $for ?: str_replace(['.', '[', ']'], '-', (string) $name); + $descriptionId = $fieldId.'-description'; + $errorId = $fieldId.'-error'; +@endphp + + + + {{ $label }} + @if ($required)@endif + + + @if (filled($description)) + {{ $description }} + @endif + + {{ $slot }} + + @if (filled($error)) + + @endif + diff --git a/resources/views/components/admin/form-section.blade.php b/resources/views/components/admin/form-section.blade.php new file mode 100644 index 00000000..bb16833b --- /dev/null +++ b/resources/views/components/admin/form-section.blade.php @@ -0,0 +1,17 @@ +@props([ + 'title', + 'description' => null, +]) + + +
      +
      +

      {{ $title }}

      + @if (filled($description)) +

      {{ $description }}

      + @endif +
      + +
      {{ $slot }}
      +
      +
      diff --git a/resources/views/components/admin/list-toolbar.blade.php b/resources/views/components/admin/list-toolbar.blade.php new file mode 100644 index 00000000..51c30ba5 --- /dev/null +++ b/resources/views/components/admin/list-toolbar.blade.php @@ -0,0 +1,30 @@ +@props([ + 'selectedCount' => 0, +]) + +
      class('space-y-3') }}> +
      + @isset($search) +
      {{ $search }}
      + @endisset + + @isset($filters) +
      {{ $filters }}
      + @endisset + + @isset($actions) +
      {{ $actions }}
      + @endisset +
      + + @if ((int) $selectedCount > 0) +
      +

      + {{ trans_choice(':count item selected|:count items selected', (int) $selectedCount, ['count' => (int) $selectedCount]) }} +

      + @isset($bulk) +
      {{ $bulk }}
      + @endisset +
      + @endif +
      diff --git a/resources/views/components/admin/loading-overlay.blade.php b/resources/views/components/admin/loading-overlay.blade.php new file mode 100644 index 00000000..8a240bda --- /dev/null +++ b/resources/views/components/admin/loading-overlay.blade.php @@ -0,0 +1,19 @@ +@props([ + 'target' => null, + 'label' => 'Loading', + 'delay' => true, +]) + +
      class('admin-loading-overlay') }} + @if ($delay) wire:loading.delay.flex @else wire:loading.flex @endif + @if (filled($target)) wire:target="{{ $target }}" @endif + role="status" + aria-live="polite" +> + + {{ $label }} +
      diff --git a/resources/views/components/admin/money.blade.php b/resources/views/components/admin/money.blade.php new file mode 100644 index 00000000..6ec02a9f --- /dev/null +++ b/resources/views/components/admin/money.blade.php @@ -0,0 +1,14 @@ +@props([ + 'amount', + 'currency' => 'USD', +]) + +@php + $amount = (int) $amount; + $currency = strtoupper((string) $currency); + $currency = preg_match('/^[A-Z]{3}$/', $currency) === 1 ? $currency : 'USD'; + $sign = $amount < 0 ? '-' : ''; + $formatted = $sign.number_format(abs($amount) / 100, 2, '.', ',').' '.$currency; +@endphp + +class('whitespace-nowrap tabular-nums') }}>{{ $formatted }} diff --git a/resources/views/components/admin/page-header.blade.php b/resources/views/components/admin/page-header.blade.php new file mode 100644 index 00000000..5e18b354 --- /dev/null +++ b/resources/views/components/admin/page-header.blade.php @@ -0,0 +1,25 @@ +@props([ + 'title', + 'description' => null, + 'breadcrumbs' => [], +]) + +
      class('space-y-4') }}> + @if (collect($breadcrumbs)->isNotEmpty()) + + @endif + +
      +
      +

      {{ $title }}

      + + @if (filled($description)) +

      {{ $description }}

      + @endif +
      + + @isset($actions) +
      {{ $actions }}
      + @endisset +
      +
      diff --git a/resources/views/components/admin/status-badge.blade.php b/resources/views/components/admin/status-badge.blade.php new file mode 100644 index 00000000..432dcaf6 --- /dev/null +++ b/resources/views/components/admin/status-badge.blade.php @@ -0,0 +1,43 @@ +@props([ + 'status', + 'label' => null, + 'variant' => null, + 'showDot' => true, +]) + +@php + $status = $status instanceof \BackedEnum ? $status->value : (string) $status; + $normalizedStatus = strtolower(str_replace([' ', '-'], '_', $status)); + $variant ??= match ($normalizedStatus) { + 'active', 'paid', 'fulfilled', 'published', 'completed', 'enabled', 'ready', 'success', 'in_stock', 'verified', 'installed', 'connected' => 'success', + 'pending', 'draft', 'processing', 'unfulfilled', 'scheduled', 'partially_refunded', 'partial', 'low_stock', 'unverified' => 'warning', + 'cancelled', 'canceled', 'failed', 'expired', 'disabled', 'refunded', 'archived', 'voided', 'out_of_stock' => 'danger', + 'authorized', 'shipping_selected', 'payment_selected', 'started', 'addressed', 'available', 'backorder' => 'info', + default => 'neutral', + }; + $variant = in_array($variant, ['success', 'warning', 'danger', 'info', 'neutral'], true) ? $variant : 'neutral'; + $label ??= \Illuminate\Support\Str::headline($status); + + $variantClasses = [ + 'success' => 'bg-emerald-50 text-emerald-700 ring-emerald-600/15 dark:bg-emerald-950/60 dark:text-emerald-300 dark:ring-emerald-400/20', + 'warning' => 'bg-amber-50 text-amber-800 ring-amber-600/15 dark:bg-amber-950/60 dark:text-amber-300 dark:ring-amber-400/20', + 'danger' => 'bg-red-50 text-red-700 ring-red-600/15 dark:bg-red-950/60 dark:text-red-300 dark:ring-red-400/20', + 'info' => 'bg-blue-50 text-blue-700 ring-blue-600/15 dark:bg-blue-950/60 dark:text-blue-300 dark:ring-blue-400/20', + 'neutral' => 'bg-zinc-100 text-zinc-700 ring-zinc-500/15 dark:bg-zinc-800 dark:text-zinc-300 dark:ring-white/10', + ]; + + $dotClasses = [ + 'success' => 'bg-emerald-500', + 'warning' => 'bg-amber-500', + 'danger' => 'bg-red-500', + 'info' => 'bg-blue-500', + 'neutral' => 'bg-zinc-400', + ]; +@endphp + +class(['inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium leading-none ring-1 ring-inset', $variantClasses[$variant]]) }}> + @if ($showDot) + + @endif + {{ $label }} + diff --git a/resources/views/components/admin/sticky-save-bar.blade.php b/resources/views/components/admin/sticky-save-bar.blade.php new file mode 100644 index 00000000..913599fc --- /dev/null +++ b/resources/views/components/admin/sticky-save-bar.blade.php @@ -0,0 +1,56 @@ +@props([ + 'saveAction' => 'save', + 'saveLabel' => 'Save', + 'savingLabel' => 'Saving…', + 'discardUrl' => null, + 'discardAction' => null, + 'discardLabel' => 'Discard', + 'target' => null, + 'dirtyOnly' => true, +]) + +@php + $saveAction = preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\([^)]*\))?$/', (string) $saveAction) === 1 + ? (string) $saveAction + : 'save'; + $discardAction = filled($discardAction) && preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\([^)]*\))?$/', (string) $discardAction) === 1 + ? (string) $discardAction + : null; + $target ??= \Illuminate\Support\Str::before($saveAction, '('); +@endphp + +
      class('admin-sticky-save-wrap') }} + @if ($dirtyOnly) wire:dirty @endif +> + + +
      +
      +
      + @isset($message) + {{ $message }} + @else +

      {{ __('You have unsaved changes.') }}

      + @endisset +
      + + @isset($actions) +
      {{ $actions }}
      + @else +
      + @if (filled($discardUrl)) + {{ $discardLabel }} + @elseif (filled($discardAction)) + {{ $discardLabel }} + @endif + + + {{ $saveLabel }} + {{ $savingLabel }} + +
      + @endisset +
      +
      +
      diff --git a/resources/views/components/admin/table-empty.blade.php b/resources/views/components/admin/table-empty.blade.php new file mode 100644 index 00000000..6eeb366e --- /dev/null +++ b/resources/views/components/admin/table-empty.blade.php @@ -0,0 +1,29 @@ +@props([ + 'colspan' => 1, + 'title' => 'No results', + 'description' => null, +]) + + + +
      + @isset($icon) +
      {{ $icon }}
      + @else + + @endisset + +

      {{ $title }}

      + + @if (filled($description)) +

      {{ $description }}

      + @endif + + @isset($action) +
      {{ $action }}
      + @endisset +
      + + diff --git a/resources/views/components/admin/table-loading.blade.php b/resources/views/components/admin/table-loading.blade.php new file mode 100644 index 00000000..df01ab00 --- /dev/null +++ b/resources/views/components/admin/table-loading.blade.php @@ -0,0 +1,17 @@ +@props([ + 'rows' => 5, + 'columns' => 5, +]) + +@for ($row = 0; $row < max(1, (int) $rows); $row++) + + @for ($column = 0; $column < max(1, (int) $columns); $column++) + + + + @endfor + +@endfor diff --git a/resources/views/components/admin/table-shell.blade.php b/resources/views/components/admin/table-shell.blade.php new file mode 100644 index 00000000..f353d1bc --- /dev/null +++ b/resources/views/components/admin/table-shell.blade.php @@ -0,0 +1,36 @@ +@props([ + 'caption' => null, + 'loadingTarget' => null, + 'loadingLabel' => 'Loading table', +]) + +
      class('admin-table-shell') }}> +
      + + @if (filled($caption)) + + @endif + + @isset($head) + {{ $head }} + @endisset + + + {{ $slot }} + +
      {{ $caption }}
      + + @if (filled($loadingTarget)) + + @endif +
      + + @isset($pagination) +
      + {{ $pagination }} +
      + @endisset +
      diff --git a/resources/views/components/admin/tabs.blade.php b/resources/views/components/admin/tabs.blade.php new file mode 100644 index 00000000..fd4ab1f8 --- /dev/null +++ b/resources/views/components/admin/tabs.blade.php @@ -0,0 +1,49 @@ +@props([ + 'items', + 'active', + 'wireModel' => null, + 'label' => 'Filters', +]) + +@php + $active = $active instanceof \BackedEnum ? $active->value : (string) $active; + $wireModel = filled($wireModel) && preg_match('/^[a-zA-Z][a-zA-Z0-9_.]*$/', (string) $wireModel) === 1 + ? (string) $wireModel + : null; +@endphp + +
      class('admin-tabs') }} role="tablist" aria-label="{{ $label }}"> + @foreach ($items as $item) + @php + $value = data_get($item, 'value', data_get($item, 'label', '')); + $value = $value instanceof \BackedEnum ? $value->value : (string) $value; + $itemLabel = (string) data_get($item, 'label', \Illuminate\Support\Str::headline($value)); + $url = data_get($item, 'url'); + $count = data_get($item, 'count'); + $disabled = (bool) data_get($item, 'disabled', false); + $isActive = $active === $value; + $setAction = $wireModel + ? '$set('.\Illuminate\Support\Js::from($wireModel).', '.\Illuminate\Support\Js::from($value).')' + : null; + @endphp + + @if (filled($url)) + + {{ $itemLabel }} + @if ($count !== null){{ $count }}@endif + + @else + + @endif + @endforeach +
      diff --git a/resources/views/components/admin/toast-container.blade.php b/resources/views/components/admin/toast-container.blade.php new file mode 100644 index 00000000..13031a02 --- /dev/null +++ b/resources/views/components/admin/toast-container.blade.php @@ -0,0 +1,69 @@ +@props([ + 'duration' => 5000, + 'initial' => [], +]) + +@php + $duration = max(1000, min(30000, (int) $duration)); + $initial = collect($initial) + ->map(fn (mixed $toast): array => [ + 'type' => in_array(data_get($toast, 'type'), ['success', 'error', 'info'], true) ? data_get($toast, 'type') : 'info', + 'message' => (string) data_get($toast, 'message', ''), + ]) + ->filter(fn (array $toast): bool => $toast['message'] !== '') + ->values() + ->all(); +@endphp + +
      class('admin-toast-region') }} + aria-live="polite" + aria-relevant="additions" +> + +
      diff --git a/resources/views/storefront/checkout/confirmation.blade.php b/resources/views/storefront/checkout/confirmation.blade.php index 580d48fb..0d410fc6 100644 --- a/resources/views/storefront/checkout/confirmation.blade.php +++ b/resources/views/storefront/checkout/confirmation.blade.php @@ -12,5 +12,5 @@
      Subtotal
      @if($order->discount_amount > 0)
      Discount
      -
      @endif
      Shipping
      Tax
      Total
      -
      Continue shopping@auth('customer')View order@endauth
      + diff --git a/routes/api.php b/routes/api.php index 067c2b0f..d0075825 100644 --- a/routes/api.php +++ b/routes/api.php @@ -40,56 +40,56 @@ Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show']); }); -Route::prefix('admin/v1')->middleware(['auth:sanctum', 'throttle:api.admin'])->group(function (): void { +Route::prefix('admin/v1')->middleware(['auth:sanctum', 'user.active', 'throttle:api.admin'])->group(function (): void { Route::post('platform/organizations', [PlatformController::class, 'organization'])->middleware('abilities:manage-platform'); Route::post('platform/stores', [PlatformController::class, 'store'])->middleware('abilities:manage-platform'); Route::prefix('stores/{storeId}')->middleware('store.resolve')->group(function (): void { - Route::get('me', [PlatformController::class, 'me']); - Route::post('invites', [PlatformController::class, 'invite'])->middleware('abilities:manage-platform'); + Route::get('me', [PlatformController::class, 'me'])->middleware('role.check:owner,admin,staff,support'); + Route::post('invites', [PlatformController::class, 'invite'])->middleware(['abilities:manage-platform', 'role.check:owner,admin']); - Route::get('products', [AdminProductController::class, 'index'])->middleware('abilities:read-products'); - Route::post('products', [AdminProductController::class, 'store'])->middleware('abilities:write-products'); - Route::get('products/{productId}', [AdminProductController::class, 'show'])->middleware('abilities:read-products'); - Route::put('products/{productId}', [AdminProductController::class, 'update'])->middleware('abilities:write-products'); - Route::delete('products/{productId}', [AdminProductController::class, 'destroy'])->middleware('abilities:write-products'); - Route::post('products/{productId}/media/presign-upload', [AdminMediaController::class, 'presign'])->middleware('abilities:write-products'); + Route::get('products', [AdminProductController::class, 'index'])->middleware(['abilities:read-products', 'role.check:owner,admin,staff,support']); + Route::post('products', [AdminProductController::class, 'store'])->middleware(['abilities:write-products', 'role.check:owner,admin,staff']); + Route::get('products/{productId}', [AdminProductController::class, 'show'])->middleware(['abilities:read-products', 'role.check:owner,admin,staff,support']); + Route::put('products/{productId}', [AdminProductController::class, 'update'])->middleware(['abilities:write-products', 'role.check:owner,admin,staff']); + Route::delete('products/{productId}', [AdminProductController::class, 'destroy'])->middleware(['abilities:write-products', 'role.check:owner,admin']); + Route::post('products/{productId}/media/presign-upload', [AdminMediaController::class, 'presign'])->middleware(['abilities:write-products', 'role.check:owner,admin,staff']); - Route::get('collections', [AdminCollectionController::class, 'index'])->middleware('abilities:read-collections'); - Route::post('collections', [AdminCollectionController::class, 'store'])->middleware('abilities:write-collections'); - Route::put('collections/{collectionId}', [AdminCollectionController::class, 'update'])->middleware('abilities:write-collections'); - Route::delete('collections/{collectionId}', [AdminCollectionController::class, 'destroy'])->middleware('abilities:write-collections'); + Route::get('collections', [AdminCollectionController::class, 'index'])->middleware(['abilities:read-collections', 'role.check:owner,admin,staff,support']); + Route::post('collections', [AdminCollectionController::class, 'store'])->middleware(['abilities:write-collections', 'role.check:owner,admin,staff']); + Route::put('collections/{collectionId}', [AdminCollectionController::class, 'update'])->middleware(['abilities:write-collections', 'role.check:owner,admin,staff']); + Route::delete('collections/{collectionId}', [AdminCollectionController::class, 'destroy'])->middleware(['abilities:write-collections', 'role.check:owner,admin']); - Route::get('orders', [AdminOrderController::class, 'index'])->middleware('abilities:read-orders'); - Route::get('orders/{orderId}', [AdminOrderController::class, 'show'])->middleware('abilities:read-orders'); - Route::post('orders/{orderId}/fulfillments', [AdminOrderController::class, 'fulfill'])->middleware('abilities:write-orders'); - Route::post('orders/{orderId}/refunds', [AdminOrderController::class, 'refund'])->middleware('abilities:write-orders'); - Route::post('orders/{orderId}/confirm-payment', [AdminOrderController::class, 'confirmPayment'])->middleware('abilities:write-orders'); + Route::get('orders', [AdminOrderController::class, 'index'])->middleware(['abilities:read-orders', 'role.check:owner,admin,staff,support']); + Route::get('orders/{orderId}', [AdminOrderController::class, 'show'])->middleware(['abilities:read-orders', 'role.check:owner,admin,staff,support']); + Route::post('orders/{orderId}/fulfillments', [AdminOrderController::class, 'fulfill'])->middleware(['abilities:write-orders', 'role.check:owner,admin,staff']); + Route::post('orders/{orderId}/refunds', [AdminOrderController::class, 'refund'])->middleware(['abilities:write-orders', 'role.check:owner,admin']); + Route::post('orders/{orderId}/confirm-payment', [AdminOrderController::class, 'confirmPayment'])->middleware(['abilities:write-orders', 'role.check:owner,admin']); - Route::get('discounts', [AdminDiscountController::class, 'index'])->middleware('abilities:read-discounts'); - Route::post('discounts', [AdminDiscountController::class, 'store'])->middleware('abilities:write-discounts'); - Route::put('discounts/{discountId}', [AdminDiscountController::class, 'update'])->middleware('abilities:write-discounts'); - Route::delete('discounts/{discountId}', [AdminDiscountController::class, 'destroy'])->middleware('abilities:write-discounts'); + Route::get('discounts', [AdminDiscountController::class, 'index'])->middleware(['abilities:read-discounts', 'role.check:owner,admin,staff,support']); + Route::post('discounts', [AdminDiscountController::class, 'store'])->middleware(['abilities:write-discounts', 'role.check:owner,admin,staff']); + Route::put('discounts/{discountId}', [AdminDiscountController::class, 'update'])->middleware(['abilities:write-discounts', 'role.check:owner,admin,staff']); + Route::delete('discounts/{discountId}', [AdminDiscountController::class, 'destroy'])->middleware(['abilities:write-discounts', 'role.check:owner,admin']); - Route::get('shipping/zones', [AdminSettingsController::class, 'zones'])->middleware('abilities:read-settings'); - Route::post('shipping/zones', [AdminSettingsController::class, 'storeZone'])->middleware('abilities:write-settings'); - Route::put('shipping/zones/{zoneId}', [AdminSettingsController::class, 'updateZone'])->middleware('abilities:write-settings'); - Route::post('shipping/zones/{zoneId}/rates', [AdminSettingsController::class, 'storeRate'])->middleware('abilities:write-settings'); - Route::get('tax/settings', [AdminSettingsController::class, 'tax'])->middleware('abilities:read-settings'); - Route::put('tax/settings', [AdminSettingsController::class, 'updateTax'])->middleware('abilities:write-settings'); + Route::get('shipping/zones', [AdminSettingsController::class, 'zones'])->middleware(['abilities:read-settings', 'role.check:owner,admin']); + Route::post('shipping/zones', [AdminSettingsController::class, 'storeZone'])->middleware(['abilities:write-settings', 'role.check:owner,admin']); + Route::put('shipping/zones/{zoneId}', [AdminSettingsController::class, 'updateZone'])->middleware(['abilities:write-settings', 'role.check:owner,admin']); + Route::post('shipping/zones/{zoneId}/rates', [AdminSettingsController::class, 'storeRate'])->middleware(['abilities:write-settings', 'role.check:owner,admin']); + Route::get('tax/settings', [AdminSettingsController::class, 'tax'])->middleware(['abilities:read-settings', 'role.check:owner,admin']); + Route::put('tax/settings', [AdminSettingsController::class, 'updateTax'])->middleware(['abilities:write-settings', 'role.check:owner,admin']); - Route::get('pages', [AdminContentController::class, 'pages'])->middleware('abilities:read-content'); - Route::post('pages', [AdminContentController::class, 'storePage'])->middleware('abilities:write-content'); - Route::put('pages/{pageId}', [AdminContentController::class, 'updatePage'])->middleware('abilities:write-content'); - Route::delete('pages/{pageId}', [AdminContentController::class, 'destroyPage'])->middleware('abilities:write-content'); - Route::post('themes', [AdminContentController::class, 'storeTheme'])->middleware('abilities:write-themes'); - Route::post('themes/{themeId}/publish', [AdminContentController::class, 'publishTheme'])->middleware('abilities:write-themes'); - Route::put('themes/{themeId}/settings', [AdminContentController::class, 'updateThemeSettings'])->middleware('abilities:write-themes'); - Route::post('search/reindex', [AdminContentController::class, 'reindex'])->middleware('abilities:write-settings'); - Route::get('search/status', [AdminContentController::class, 'searchStatus'])->middleware('abilities:read-settings'); - Route::get('analytics/summary', [AdminAnalyticsController::class, 'summary'])->middleware('abilities:read-analytics'); - Route::post('exports/orders', [AdminAnalyticsController::class, 'exportOrders'])->middleware('abilities:read-orders'); - Route::get('exports/{exportId}', [AdminAnalyticsController::class, 'export'])->middleware('abilities:read-orders')->name('api.admin.exports.show'); + Route::get('pages', [AdminContentController::class, 'pages'])->middleware(['abilities:read-content', 'role.check:owner,admin,staff']); + Route::post('pages', [AdminContentController::class, 'storePage'])->middleware(['abilities:write-content', 'role.check:owner,admin,staff']); + Route::put('pages/{pageId}', [AdminContentController::class, 'updatePage'])->middleware(['abilities:write-content', 'role.check:owner,admin,staff']); + Route::delete('pages/{pageId}', [AdminContentController::class, 'destroyPage'])->middleware(['abilities:write-content', 'role.check:owner,admin']); + Route::post('themes', [AdminContentController::class, 'storeTheme'])->middleware(['abilities:write-themes', 'role.check:owner,admin']); + Route::post('themes/{themeId}/publish', [AdminContentController::class, 'publishTheme'])->middleware(['abilities:write-themes', 'role.check:owner,admin']); + Route::put('themes/{themeId}/settings', [AdminContentController::class, 'updateThemeSettings'])->middleware(['abilities:write-themes', 'role.check:owner,admin']); + Route::post('search/reindex', [AdminContentController::class, 'reindex'])->middleware(['abilities:write-settings', 'role.check:owner,admin']); + Route::get('search/status', [AdminContentController::class, 'searchStatus'])->middleware(['abilities:read-settings', 'role.check:owner,admin']); + Route::get('analytics/summary', [AdminAnalyticsController::class, 'summary'])->middleware(['abilities:read-analytics', 'role.check:owner,admin,staff']); + Route::post('exports/orders', [AdminAnalyticsController::class, 'exportOrders'])->middleware(['abilities:read-orders', 'role.check:owner,admin,staff,support']); + Route::get('exports/{exportId}', [AdminAnalyticsController::class, 'export'])->middleware(['abilities:read-orders', 'role.check:owner,admin,staff,support'])->name('api.admin.exports.show'); }); }); diff --git a/routes/web.php b/routes/web.php index b206ec67..b6021e7e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,35 @@ 'The OAuth app ecosystem is not implemented in this release.', ], 501))->withoutMiddleware(ValidateCsrfToken::class); +Route::prefix('admin')->name('admin.')->group(function (): void { + Route::get('/login', AdminLogin::class)->name('login'); + Route::get('/forgot-password', AdminForgotPassword::class)->name('password.request'); + Route::get('/reset-password/{token}', AdminResetPassword::class)->name('password.reset'); + + Route::post('/logout', function (Request $request) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('admin.login')->withHeaders([ + 'Cache-Control' => 'no-store, no-cache, must-revalidate, private', + 'Pragma' => 'no-cache', + ]); + })->middleware('auth')->name('logout'); + + Route::middleware(['auth', 'verified', 'admin.store', 'role.check:owner,admin,staff,support'])->group(function (): void { + Route::get('/', AdminDashboard::class)->name('dashboard'); + Route::get('/products', AdminProducts::class)->name('products.index'); + Route::get('/products/create', AdminProductForm::class)->name('products.create'); + Route::get('/products/{product}/edit', AdminProductForm::class)->name('products.edit'); + Route::get('/inventory', AdminInventory::class)->name('inventory.index'); + Route::get('/collections', AdminCollections::class)->name('collections.index'); + Route::get('/collections/create', AdminCollectionForm::class)->name('collections.create'); + Route::get('/collections/{collection}/edit', AdminCollectionForm::class)->name('collections.edit'); + Route::get('/orders', AdminOrders::class)->name('orders.index'); + Route::get('/orders/{order}', AdminOrder::class)->name('orders.show'); + Route::get('/customers', AdminCustomers::class)->name('customers.index'); + Route::get('/customers/{customer}', AdminCustomer::class)->name('customers.show'); + Route::get('/discounts', AdminDiscounts::class)->name('discounts.index'); + Route::get('/discounts/create', AdminDiscountForm::class)->name('discounts.create'); + Route::get('/discounts/{discount}/edit', AdminDiscountForm::class)->name('discounts.edit'); + Route::get('/settings', AdminSettings::class)->name('settings.index'); + Route::get('/settings/shipping', AdminShipping::class)->name('settings.shipping'); + Route::get('/settings/taxes', AdminTaxes::class)->name('settings.taxes'); + Route::get('/settings/checkout', AdminCheckoutSettings::class)->name('settings.checkout'); + Route::get('/settings/notifications', AdminNotificationSettings::class)->name('settings.notifications'); + Route::get('/themes', AdminThemes::class)->name('themes.index'); + Route::get('/themes/{theme}/editor', AdminThemeEditor::class)->name('themes.editor'); + Route::get('/pages', AdminPages::class)->name('pages.index'); + Route::get('/pages/create', AdminPageForm::class)->name('pages.create'); + Route::get('/pages/{page}/edit', AdminPageForm::class)->name('pages.edit'); + Route::get('/navigation', AdminNavigation::class)->name('navigation.index'); + Route::get('/apps', AdminApps::class)->name('apps.index'); + Route::get('/apps/{installation}', AdminApp::class)->name('apps.show'); + Route::get('/developers', AdminDevelopers::class)->name('developers.index'); + Route::get('/analytics', AdminAnalytics::class)->name('analytics.index'); + Route::get('/search/settings', AdminSearchSettings::class)->name('search.settings'); + }); +}); + Route::middleware('storefront')->group(function (): void { Route::get('/', Home::class)->name('storefront.home'); Route::get('/collections', Collections::class)->name('storefront.collections.index'); diff --git a/specs/progress.md b/specs/progress.md index 656c15b6..dd9101d2 100644 --- a/specs/progress.md +++ b/specs/progress.md @@ -14,13 +14,13 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro | 1. Foundation, tenancy, authentication, authorization | Complete | All migrations pass; tenancy/auth infrastructure and factories are present | | 2. Catalog, inventory, collections, media | Complete | Catalog/cart/pricing and media behavior suites pass | | 3. Themes, CMS, navigation, storefront shell | Complete | Blade compilation, Vite build, and live Herd render smoke pass | -| 4. Cart, checkout, discounts, shipping, taxes | In progress | Domain and storefront API suites pass; Chrome flow pending | -| 5. Payments, orders, refunds, fulfillment | In progress | Lifecycle suite passes; admin UI and Chrome pending | -| 6. Customer accounts | In progress | Tenant auth/reset/address/order suite passes; Chrome pending | -| 7. Admin panel | In progress | Full resource surface under implementation and runtime smoke QA | -| 8. Search, analytics, apps, webhooks | In progress | Search/analytics/media/webhook/job suite passes; admin UI pending | -| 9. Accessibility, responsive, dark mode, error states | Pending | Build + Chrome review | -| 10. Full quality and acceptance verification | Pending | Pint, quality checker, all Pest, fresh seed, routes, config cache, Chrome | +| 4. Cart, checkout, discounts, shipping, taxes | Complete | Contract-aligned API, integrity, discount-stacking, digital/physical checkout, and concurrency suites pass | +| 5. Payments, orders, refunds, fulfillment | Complete | Idempotent payment, snapshot, refund/restock, export, fulfillment, and webhook lifecycle suites pass | +| 6. Customer accounts | Complete | Tenant auth/reset/address/order and guest-identity security suites pass | +| 7. Admin panel | Complete | All specified admin resources, roles, settings tabs, and mutation workflows pass HTTP/Livewire QA | +| 8. Search, analytics, apps, webhooks | Complete | Facets/suggestions, deduplication, persistent reindex, secure delivery, analytics, apps, and job suites pass | +| 9. Accessibility, responsive, dark mode, error states | In progress | Production build and Blade QA pass; final visible Chrome review pending | +| 10. Full quality and acceptance verification | In progress | Final integrated Pest gate passes 252 tests / 1,651 assertions; seed/Chrome gate pending | ## Iteration Log @@ -61,20 +61,34 @@ Build the complete self-contained multi-tenant shop described by Specs 01-09 fro - Restored stateful storefront API sessions and stable documented 422 errors for invalid variants, inventory, shipping, discounts, and checkout transitions. - Verified focused Pest suites for catalog/cart/pricing, checkout/order lifecycle, customer accounts, search/analytics/media/webhooks/jobs, and the complete storefront REST API. All currently pass. +### Iteration 4 - Complete admin, contract reconciliation, security, and reliability + +- Delivered the complete admin surface for dashboard, products/media/options/variants, inventory, collections, orders/fulfillment/refunds, customers, discounts, settings/domains/shipping/taxes/checkout/notifications, themes, pages, navigation, analytics, search, apps, and developers. +- Reconciled storefront REST responses and validation with the documented cart, checkout, payment, search/facets/suggestions, analytics, and signed order-status contracts. +- Bound guest carts/checkouts to their owning session or customer, froze reserved carts, locked optimistic mutations, enforced one live checkout per cart, and made payment completion idempotent under lock. +- Added automatic discount stacking, once-per-customer enforcement, transaction-safe usage accounting, per-line tax/discount snapshots, provider snapshots, lock-safe refunds, and double-restock prevention. +- Hardened tenant associations, variant cardinality/defaults, support/admin role boundaries, suspended users/stores, canonical password-reset links, guest identity claims, serialized secrets, scriptable URLs, and API token store binding. +- Added SSRF-safe DNS-pinned webhook delivery with redirects disabled, post-commit outbound isolation, tenant-context restoration, lifecycle-complete webhooks, and reusable signed order-status links. +- Added validated theme ZIP extraction and duplication, responsive media processing, persistent search reindex progress, complete CSV fields, global login throttles, API-token audit events, and Checkout/Notifications settings. +- Installed the reusable Laravel code-quality checker and retained its fixture regression suite; updated Composer and npm lockfiles until both package managers reported zero known vulnerabilities. +- Verified the production Vite build, all route/config/view caches, the checker’s 13-test contract suite, 89 commerce/admin tests, 96 reliability/security-adjacent tests, and the final integrated 252-test / 1,651-assertion Pest run. + ## Verification Ledger | Check | Latest Result | | --- | --- | | Working tree at start | Clean | | Existing shop code reused | No | -| Pest suite | All implemented focused shop slices pass; full integrated run pending admin completion | +| Pest suite | Final integrated gate passed: 252 tests / 1,651 assertions | | Vite production build | Passed | | Fresh migrate and seed | Passed (all migrations and all 18 seed stages) | -| Laravel code-quality checker | Not installed yet | +| Laravel code-quality checker | Installed; command contract suite passed 13 tests / 41 assertions | +| Composer audit | Zero known vulnerabilities after dependency update | +| npm audit | Zero known vulnerabilities after dependency update | | Chrome acceptance review | Not run yet | ## Open Risks - SQLite FTS5 is available and its migration passes locally. - `shop.test`, `acme-fashion.test`, and `acme-electronics.test` are seeded; live browser host routing remains to be verified. -- Final integrated quality scan and visible Chrome customer/admin review remain pending until the admin surface is route-complete. +- Final deterministic scan, post-hardening Pest/seed gate, and visible Chrome customer/admin review remain pending. diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd7..2b84daa6 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -1,9 +1,10 @@ get(route('login')); @@ -64,6 +65,6 @@ $response = $this->actingAs($user)->post(route('logout')); - $response->assertRedirect(route('home')); + $response->assertRedirect(route('storefront.home')); $this->assertGuest(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index bea78251..1e9f0c77 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -1,13 +1,18 @@ get(route('password.request')); + $response = $this->get(route('admin.password.request')); $response->assertOk(); }); @@ -17,7 +22,11 @@ $user = User::factory()->create(); - $this->post(route('password.request'), ['email' => $user->email]); + Livewire::test(ForgotPassword::class) + ->set('email', $user->email) + ->call('sendResetLink') + ->assertHasNoErrors() + ->assertSet('sent', true); Notification::assertSentTo($user, ResetPassword::class); }); @@ -27,11 +36,17 @@ $user = User::factory()->create(); - $this->post(route('password.request'), ['email' => $user->email]); + Livewire::test(ForgotPassword::class) + ->set('email', $user->email) + ->call('sendResetLink') + ->assertHasNoErrors(); - Notification::assertSentTo($user, ResetPassword::class, function ($notification) { - $response = $this->get(route('password.reset', $notification->token)); - $response->assertOk(); + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->get(route('admin.password.reset', [ + 'token' => $notification->token, + 'email' => $user->email, + ])); + $response->assertOk()->assertSee($user->email); return true; }); @@ -42,20 +57,22 @@ $user = User::factory()->create(); - $this->post(route('password.request'), ['email' => $user->email]); + Livewire::test(ForgotPassword::class) + ->set('email', $user->email) + ->call('sendResetLink') + ->assertHasNoErrors(); Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { - $response = $this->post(route('password.update'), [ - 'token' => $notification->token, - 'email' => $user->email, - 'password' => 'password', - 'password_confirmation' => 'password', - ]); + Livewire::test(ResetPasswordForm::class, ['token' => $notification->token]) + ->set('email', $user->email) + ->set('password', 'new-password') + ->set('password_confirmation', 'new-password') + ->call('resetPassword') + ->assertHasNoErrors() + ->assertRedirect('/admin/login'); - $response - ->assertSessionHasNoErrors() - ->assertRedirect(route('login', absolute: false)); + expect(Hash::check('new-password', $user->refresh()->password_hash))->toBeTrue(); return true; }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/CodeQualityCheckCommandTest.php b/tests/Feature/CodeQualityCheckCommandTest.php new file mode 100644 index 00000000..76494bfe --- /dev/null +++ b/tests/Feature/CodeQualityCheckCommandTest.php @@ -0,0 +1,295 @@ + + */ + public array $requests = []; + + public function __construct(private readonly ?CodeQualityException $exception) {} + + public function review(AiReviewRequest $request): AiReviewResult + { + $this->requests[] = $request; + + if ($this->exception !== null) { + throw $this->exception; + } + + return new AiReviewResult([ + new Finding( + id: 'cqf_test_ai', + checkId: $request->checkId, + source: 'ai', + severity: 'error', + confidence: 'high', + category: 'architecture', + title: 'Fake AI finding', + description: 'The fake reviewer found a concrete issue.', + file: $request->files[0] ?? null, + line: 1, + evidence: 'Fake evidence.', + recommendation: 'Fix the fake issue.', + dedupeKey: $request->checkId.':fake', + ), + ]); + } + }; +} + +test('reports deterministic findings as json and exits with blocking status', function () { + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['tests/Fixtures/CodeQuality/EnvOutsideConfig.php'], + '--checks' => ['deterministic.config-env'], + '--format' => 'json', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(1) + ->and($report['schema_version'])->toBe(1) + ->and($report['status'])->toBe('failed') + ->and($report['findings'])->toHaveCount(1) + ->and($report['findings'][0]['check_id'])->toBe('deterministic.config-env') + ->and($report['findings'][0]['blocking'])->toBeTrue(); +}); + +test('unknown check ids fail fast', function () { + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['tests/Fixtures/CodeQuality/CleanService.php'], + '--checks' => ['deterministic.nope'], + '--format' => 'json', + ]); + + expect($exit)->toBe(2) + ->and(Artisan::output())->toContain('Unknown code-quality check or group'); +}); + +test('all with no ai excludes ai checks', function () { + $reviewer = fakeAiReviewer(); + app()->instance(AiReviewer::class, $reviewer); + + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['tests/Fixtures/CodeQuality/CleanService.php'], + '--checks' => ['all'], + '--no-ai' => true, + '--format' => 'json', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(0) + ->and($reviewer->requests)->toHaveCount(0) + ->and($report['configuration']['checks'])->not->toContain('ai.architecture'); +}); + +test('ai checks call the configured reviewer when enabled', function () { + $reviewer = fakeAiReviewer(); + app()->instance(AiReviewer::class, $reviewer); + + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['app/Services/CodeQuality/CodeQualityRunOptions.php'], + '--checks' => ['ai.architecture'], + '--ai' => true, + '--format' => 'json', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(1) + ->and($reviewer->requests)->toHaveCount(1) + ->and($report['findings'][0]['source'])->toBe('ai') + ->and($report['findings'][0]['blocking'])->toBeTrue(); +}); + +test('json output can be written to a report file', function () { + $output = 'storage/app/code-quality/test-command-report.json'; + @unlink(base_path($output)); + + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['tests/Fixtures/CodeQuality/EnvOutsideConfig.php'], + '--checks' => ['deterministic.config-env'], + '--format' => 'json', + '--output' => $output, + ]); + + $written = json_decode((string) file_get_contents(base_path($output)), true, flags: JSON_THROW_ON_ERROR); + + expect($exit)->toBe(1) + ->and($written['run_id'])->toBe(codeQualityJsonOutput()['run_id']) + ->and($written['findings'][0]['check_id'])->toBe('deterministic.config-env'); + + @unlink(base_path($output)); +}); + +test('advisory findings below the failure threshold do not fail the command', function () { + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['tests/Fixtures/CodeQuality/frontend-hardcoded-route.tsx'], + '--checks' => ['deterministic.frontend-routes'], + '--format' => 'json', + '--fail-on' => 'error', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(0) + ->and($report['status'])->toBe('passed') + ->and($report['findings'][0]['severity'])->toBe('warning') + ->and($report['findings'][0]['blocking'])->toBeFalse(); +}); + +test('configured ai-free path boundaries flag ai infrastructure references', function () { + config(['code_quality.domain.ai_free_path_keywords' => ['Heartbeat']]); + + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['tests/Fixtures/CodeQuality/HeartbeatAiReference.php'], + '--checks' => ['deterministic.ai-free-boundaries'], + '--format' => 'json', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(1) + ->and($report['findings'][0]['check_id'])->toBe('deterministic.ai-free-boundaries') + ->and($report['findings'][0]['category'])->toBe('ai-boundary'); +}); + +test('checks with no matching scoped files are reported as skipped', function () { + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['tests/Fixtures/CodeQuality/CleanService.php'], + '--checks' => ['deterministic.dependencies'], + '--format' => 'json', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(0) + ->and($report['summary']['skipped_checks'])->toBe(1) + ->and($report['check_results'][0]['status'])->toBe('skipped'); +}); + +test('checker failures return exit code three with a structured report', function () { + app()->instance(AiReviewer::class, fakeAiReviewer(CodeQualityException::checkerFailed('fake checker failed'))); + + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['app/Services/CodeQuality/CodeQualityRunOptions.php'], + '--checks' => ['ai.architecture'], + '--ai' => true, + '--format' => 'json', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(3) + ->and($report['status'])->toBe('errored') + ->and($report['check_results'][0]['status'])->toBe('errored'); +}); + +test('timeouts return exit code four with a structured report', function () { + app()->instance(AiReviewer::class, fakeAiReviewer(CodeQualityException::timedOut('fake timeout'))); + + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['app/Services/CodeQuality/CodeQualityRunOptions.php'], + '--checks' => ['ai.architecture'], + '--ai' => true, + '--format' => 'json', + ]); + + $report = codeQualityJsonOutput(); + + expect($exit)->toBe(4) + ->and($report['status'])->toBe('timed_out') + ->and($report['check_results'][0]['status'])->toBe('timed_out'); +}); + +test('ai unavailable returns exit code five', function () { + app()->instance(AiReviewer::class, fakeAiReviewer(CodeQualityException::aiUnavailable('fake codex missing'))); + + $exit = Artisan::call('code-quality:check', [ + 'paths' => ['app/Services/CodeQuality/CodeQualityRunOptions.php'], + '--checks' => ['ai.architecture'], + '--ai' => true, + '--format' => 'json', + ]); + + expect($exit)->toBe(5) + ->and(Artisan::output())->toContain('fake codex missing'); +}); + +test('codex ai output schema requires every declared finding property', function () { + $method = new ReflectionMethod(CodexAiRunner::class, 'schema'); + $schema = $method->invoke(app(CodexAiRunner::class)); + $item = $schema['properties']['findings']['items']; + + expect($item['additionalProperties'])->toBeFalse() + ->and($item['required'])->toEqualCanonicalizing(array_keys($item['properties'])); +}); + +test('codex ai runner normalizes finding check ids to the source check', function () { + $runner = new CodexAiRunner(new class extends ProcessRunner + { + public function run(array $command, string $workingDirectory, int $timeoutSeconds, array $environment = []): ProcessResult + { + return new ProcessResult( + command: $command, + exitCode: 0, + output: json_encode([ + 'findings' => [[ + 'id' => 'F1', + 'check_id' => 'made-up-check', + 'source' => 'deterministic', + 'severity' => 'warning', + 'confidence' => 'high', + 'category' => 'architecture', + 'title' => 'Bad check id', + 'description' => 'The model returned a wrong check id.', + 'file' => null, + 'line' => null, + 'end_line' => null, + 'symbol' => null, + 'evidence' => null, + 'recommendation' => 'Normalize it.', + 'docs' => [], + 'dedupe_key' => null, + ]], + ], JSON_THROW_ON_ERROR), + errorOutput: '', + durationMs: 1, + ); + } + }, app('files')); + + $result = $runner->review(new AiReviewRequest( + runId: 'cqr_test', + checkId: 'ai.architecture', + checkName: 'architecture review', + basePath: base_path(), + files: ['app/Services/CodeQuality/Runners/CodexAiRunner.php'], + checklistExcerpt: 'Architecture', + deterministicFindings: [], + model: 'test-model', + timeoutSeconds: 1, + )); + + expect($result->findings)->toHaveCount(1) + ->and($result->findings[0]->checkId)->toBe('ai.architecture') + ->and($result->findings[0]->source)->toBe('ai'); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8b5843f4..792f9be4 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,12 @@ get('/'); +use Illuminate\Foundation\Testing\RefreshDatabase; + +uses(RefreshDatabase::class); + +it('renders the storefront home page for a resolved tenant', function () { + $context = createStoreContext(); + $response = $this->get("http://{$context['domain']->hostname}/"); $response->assertStatus(200); }); diff --git a/tests/Feature/Shop/AdminApiTest.php b/tests/Feature/Shop/AdminApiTest.php new file mode 100644 index 00000000..e045d8f1 --- /dev/null +++ b/tests/Feature/Shop/AdminApiTest.php @@ -0,0 +1,419 @@ + $abilities */ +function adminApiToken(array $context, array $abilities): string +{ + $abilities[] = 'store:'.$context['store']->id; + + return $context['user']->createToken('api-test', array_values(array_unique($abilities)))->plainTextToken; +} + +function adminStoreUrl(Store $store, string $path): string +{ + return "/api/admin/v1/stores/{$store->id}/".ltrim($path, '/'); +} + +function themeArchive(bool $includeTemplate = true): UploadedFile +{ + $path = tempnam(sys_get_temp_dir(), 'shop-theme-'); + $zip = new ZipArchive; + $zip->open($path, ZipArchive::OVERWRITE); + $zip->addFromString('theme.json', json_encode([ + 'name' => 'Archive Theme', + 'version' => '1.2.3', + 'required_templates' => ['templates/index.blade.php'], + 'settings' => ['colors' => ['primary' => '#123456']], + ], JSON_THROW_ON_ERROR)); + if ($includeTemplate) { + $zip->addFromString('templates/index.blade.php', '
      {{ $slot ?? "Theme" }}
      '); + } else { + $zip->addFromString('assets/theme.css', 'body { color: #123456; }'); + } + $zip->close(); + + return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); +} + +describe('admin product API', function () { + it('lists filters paginates creates shows updates and archives products', function () { + $context = createStoreContext(); + $token = adminApiToken($context, ['read-products', 'write-products']); + Product::factory()->count(26)->for($context['store'])->create(); + $other = Store::factory()->create(); + Product::factory()->count(3)->for($other)->create(); + + $this->withToken($token) + ->getJson(adminStoreUrl($context['store'], 'products')) + ->assertOk() + ->assertJsonCount(25, 'data') + ->assertJsonPath('meta.total', 26) + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.last_page', 2); + + $created = $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'products'), [ + 'title' => 'API Product', + 'description_html' => '

      Created through the API.

      ', + 'vendor' => 'API Vendor', + 'tags' => ['api', 'new'], + 'variant' => [ + 'sku' => 'API-SKU-1', + 'price_amount' => 4500, + 'quantity_on_hand' => 12, + ], + ])->assertCreated() + ->assertJsonPath('data.title', 'API Product') + ->assertJsonPath('data.handle', 'api-product') + ->assertJsonPath('data.variants.0.sku', 'API-SKU-1') + ->assertJsonPath('data.variants.0.price_amount', 4500) + ->assertJsonPath('data.variants.0.inventory_item.quantity_on_hand', 12); + $productId = $created->json('data.id'); + + $this->withToken($token)->getJson(adminStoreUrl($context['store'], "products/{$productId}")) + ->assertOk() + ->assertJsonPath('data.variants.0.inventory_item.quantity_on_hand', 12); + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "products/{$productId}"), [ + 'title' => 'Updated API Product', + 'status' => 'active', + ])->assertOk() + ->assertJsonPath('data.title', 'Updated API Product') + ->assertJsonPath('data.handle', 'updated-api-product') + ->assertJsonPath('data.status', 'active'); + $this->withToken($token)->getJson(adminStoreUrl($context['store'], 'products?status=active&query=Updated')) + ->assertOk()->assertJsonPath('meta.total', 1); + $this->withToken($token)->deleteJson(adminStoreUrl($context['store'], "products/{$productId}")) + ->assertOk()->assertJsonPath('data.status', 'archived'); + $this->withToken($token)->getJson(adminStoreUrl($other, "products/{$productId}"))->assertForbidden(); + }); + + it('accepts the documented variants array and nested inventory contract', function () { + $context = createStoreContext(); + $token = adminApiToken($context, ['write-products']); + + $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'products'), [ + 'title' => 'Documented Variant Product', + 'variants' => [[ + 'sku' => 'DOC-SKU-1', + 'price_amount' => 3200, + 'currency' => 'EUR', + 'requires_shipping' => true, + 'is_default' => true, + 'inventory' => ['quantity_on_hand' => 17, 'policy' => 'deny'], + ]], + ])->assertCreated() + ->assertJsonPath('data.variants.0.sku', 'DOC-SKU-1') + ->assertJsonPath('data.variants.0.price_amount', 3200) + ->assertJsonPath('data.variants.0.inventory_item.quantity_on_hand', 17); + }); + + it('requires authentication abilities and the store role for mutations', function () { + $context = createStoreContext(); + $this->getJson(adminStoreUrl($context['store'], 'products'))->assertUnauthorized(); + + $reader = adminApiToken($context, ['read-products']); + $this->withToken($reader)->postJson(adminStoreUrl($context['store'], 'products'), ['title' => 'Forbidden']) + ->assertForbidden(); + + $support = createStoreContext('support'); + $supportToken = adminApiToken($support, ['write-products']); + app('auth')->forgetGuards(); + $this->withToken($supportToken)->postJson(adminStoreUrl($support['store'], 'products'), [ + 'title' => 'Role Escalation', + 'variant' => ['price_amount' => 1000], + ])->assertForbidden(); + }); + + it('rejects a token against another store even when its user is a member there', function () { + $issuing = createStoreContext(); + $other = createStoreContext(); + DB::table('store_users')->insert([ + 'store_id' => $other['store']->id, + 'user_id' => $issuing['user']->id, + 'role' => 'admin', + 'created_at' => now(), + ]); + $token = adminApiToken($issuing, ['read-products', 'write-products']); + + app('auth')->forgetGuards(); + $this->withToken($token) + ->getJson(adminStoreUrl($other['store'], 'products')) + ->assertForbidden(); + + app('auth')->forgetGuards(); + $this->withToken($token) + ->postJson(adminStoreUrl($other['store'], 'products'), [ + 'title' => 'Cross-store escalation', + 'variant' => ['price_amount' => 1000], + ]) + ->assertForbidden(); + + expect(Product::withoutGlobalScopes()->where('store_id', $other['store']->id)->count())->toBe(0); + }); + + it('returns tenant-safe validation for duplicate handles and SKUs', function () { + $context = createStoreContext(); + $token = adminApiToken($context, ['write-products']); + $first = $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'products'), [ + 'title' => 'First Product', + 'handle' => 'duplicate-handle', + 'variant' => ['sku' => 'DUP-SKU', 'price_amount' => 1000], + ])->assertCreated(); + + $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'products'), [ + 'title' => 'Duplicate Handle', + 'handle' => 'duplicate-handle', + 'variant' => ['sku' => 'OTHER-SKU', 'price_amount' => 1000], + ])->assertUnprocessable()->assertJsonValidationErrors('handle'); + $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'products'), [ + 'title' => 'Duplicate SKU', + 'variant' => ['sku' => 'DUP-SKU', 'price_amount' => 1000], + ])->assertUnprocessable()->assertJsonValidationErrors('variant.sku'); + }); +}); + +describe('admin collection discount settings and content APIs', function () { + it('manages ordered collections and discounts inside the route tenant', function () { + $context = createStoreContext(); + $token = adminApiToken($context, [ + 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', + ]); + $products = Product::factory()->count(3)->for($context['store'])->create(); + + $collection = $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'collections'), [ + 'title' => 'API Collection', + 'status' => 'active', + 'product_ids' => $products->modelKeys(), + ])->assertCreated() + ->assertJsonPath('data.handle', 'api-collection') + ->assertJsonCount(3, 'data.products'); + $collectionId = $collection->json('data.id'); + expect(Collection::query()->findOrFail($collectionId)->products->pluck('pivot.position')->all())->toBe([0, 1, 2]); + + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "collections/{$collectionId}"), [ + 'title' => 'Updated Collection', + 'product_ids' => [$products[2]->id, $products[0]->id], + ])->assertOk()->assertJsonCount(2, 'data.products'); + expect(Collection::query()->findOrFail($collectionId)->products->modelKeys())->toBe([$products[2]->id, $products[0]->id]); + + $discount = $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'discounts'), [ + 'type' => 'code', + 'code' => 'API20', + 'value_type' => 'percent', + 'value_amount' => 20, + 'starts_at' => now()->subDay()->toIso8601String(), + 'ends_at' => now()->addMonth()->toIso8601String(), + 'status' => 'active', + ])->assertCreated()->assertJsonPath('data.value_amount', 20); + $discountId = $discount->json('data.id'); + + $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'discounts'), [ + 'type' => 'code', 'code' => 'API20', 'value_type' => 'fixed', 'value_amount' => 500, + 'starts_at' => now()->toIso8601String(), + ])->assertUnprocessable()->assertJsonValidationErrors('code'); + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "discounts/{$discountId}"), [ + 'value_amount' => 25, 'status' => 'disabled', + ])->assertOk()->assertJsonPath('data.status', 'disabled'); + $this->withToken($token)->deleteJson(adminStoreUrl($context['store'], "discounts/{$discountId}"))->assertOk(); + $this->withToken($token)->deleteJson(adminStoreUrl($context['store'], "collections/{$collectionId}"))->assertOk(); + }); + + it('configures shipping tax pages themes and search indexing', function () { + $context = createStoreContext(); + $token = adminApiToken($context, [ + 'read-settings', 'write-settings', 'read-content', 'write-content', 'write-themes', + ]); + + $zone = $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'shipping/zones'), [ + 'name' => 'Domestic', 'countries_json' => ['DE'], 'regions_json' => ['BE'], + ])->assertCreated()->assertJsonPath('data.name', 'Domestic'); + $zoneId = $zone->json('data.id'); + $this->withToken($token)->postJson(adminStoreUrl($context['store'], "shipping/zones/{$zoneId}/rates"), [ + 'name' => 'Standard', 'type' => 'flat', 'config_json' => ['amount' => 499], 'is_active' => true, + ])->assertCreated()->assertJsonPath('data.config_json.amount', 499); + $this->withToken($token)->getJson(adminStoreUrl($context['store'], 'shipping/zones')) + ->assertOk()->assertJsonPath('data.0.rates.0.name', 'Standard'); + + $this->withToken($token)->putJson(adminStoreUrl($context['store'], 'tax/settings'), [ + 'mode' => 'manual', 'provider' => 'none', 'prices_include_tax' => true, + 'config_json' => ['rate_bps' => 1900, 'label' => 'VAT'], + ])->assertOk()->assertJsonPath('data.prices_include_tax', true); + $this->withToken($token)->getJson(adminStoreUrl($context['store'], 'tax/settings')) + ->assertOk()->assertJsonPath('data.config_json.rate_bps', 1900); + + $page = $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'pages'), [ + 'title' => 'Shipping FAQ', 'body_html' => '

      Answers.

      ', 'status' => 'published', 'published_at' => now(), + ])->assertCreated()->assertJsonPath('data.handle', 'shipping-faq'); + $pageId = $page->json('data.id'); + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "pages/{$pageId}"), ['title' => 'Delivery FAQ']) + ->assertOk()->assertJsonPath('data.title', 'Delivery FAQ'); + + $theme = $this->withToken($token)->post(adminStoreUrl($context['store'], 'themes'), [ + 'name' => 'API Theme', + 'file' => themeArchive(), + ], ['Accept' => 'application/json'])->assertCreated() + ->assertJsonPath('data.status', 'draft') + ->assertJsonPath('data.version', '1.2.3') + ->assertJsonCount(2, 'data.files'); + $themeId = $theme->json('data.id'); + $this->withToken($token)->post(adminStoreUrl($context['store'], 'themes'), [ + 'file' => themeArchive(false), + ], ['Accept' => 'application/json'])->assertUnprocessable()->assertJsonValidationErrors('file'); + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "themes/{$themeId}/settings"), [ + 'settings' => ['colors' => ['primary' => '#123456']], + ])->assertOk()->assertJsonPath('data.settings_json.colors.primary', '#123456'); + $this->withToken($token)->postJson(adminStoreUrl($context['store'], "themes/{$themeId}/publish")) + ->assertOk()->assertJsonPath('data.status', 'published'); + + Product::factory()->for($context['store'])->create(['title' => 'Index Me', 'handle' => 'index-me']); + $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'search/reindex'))->assertStatus(202); + $this->withToken($token)->getJson(adminStoreUrl($context['store'], 'search/status')) + ->assertOk()->assertJsonPath('data.index_status', 'ready')->assertJsonPath('data.documents_count', 1); + $this->withToken($token)->deleteJson(adminStoreUrl($context['store'], "pages/{$pageId}"))->assertOk(); + }); +}); + +describe('admin order analytics and platform APIs', function () { + it('lists shows fulfills refunds and confirms orders', function () { + $fixture = paidOrderFixture(); + $context = [ + 'store' => $fixture['store'], + 'user' => $fixture['store']->users()->firstOrFail(), + ]; + $token = adminApiToken($context, ['read-orders', 'write-orders']); + + $this->withToken($token)->getJson(adminStoreUrl($fixture['store'], 'orders?status=paid')) + ->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $fixture['order']->id); + $this->withToken($token)->getJson(adminStoreUrl($fixture['store'], "orders/{$fixture['order']->id}")) + ->assertOk() + ->assertJsonPath('data.lines.0.id', $fixture['line']->id) + ->assertJsonPath('data.payments.0.id', $fixture['payment']->id); + $this->withToken($token)->postJson(adminStoreUrl($fixture['store'], "orders/{$fixture['order']->id}/fulfillments"), [ + 'lines' => [$fixture['line']->id => 1], + 'tracking_company' => 'DHL', + 'tracking_number' => 'TRACK-1', + 'tracking_url' => 'https://tracking.example/TRACK-1', + ])->assertCreated()->assertJsonPath('data.lines.0.quantity', 1); + + $refundFixture = paidOrderFixture(); + // Add the first token owner to the second fixture store so the same endpoint contract is exercised. + DB::table('store_users')->insert([ + 'store_id' => $refundFixture['store']->id, 'user_id' => $context['user']->id, 'role' => 'admin', 'created_at' => now(), + ]); + $refundToken = adminApiToken(['store' => $refundFixture['store'], 'user' => $context['user']], ['write-orders']); + app('auth')->forgetGuards(); + $this->withToken($refundToken)->postJson(adminStoreUrl($refundFixture['store'], "orders/{$refundFixture['order']->id}/refunds"), [ + 'amount' => 2000, 'reason' => 'API return', 'restock' => true, + 'lines' => [$refundFixture['line']->id => 1], + ])->assertCreated()->assertJsonPath('data.amount', 2000)->assertJsonPath('data.status', 'processed'); + + $bank = paidOrderFixture(); + DB::table('store_users')->insert([ + 'store_id' => $bank['store']->id, 'user_id' => $context['user']->id, 'role' => 'admin', 'created_at' => now(), + ]); + $bank['order']->update(['payment_method' => 'bank_transfer', 'status' => 'pending', 'financial_status' => 'pending']); + $bank['payment']->update(['method' => 'bank_transfer', 'status' => 'pending']); + $bank['variant']->inventoryItem->update(['quantity_on_hand' => 10, 'quantity_reserved' => 2]); + $bankToken = adminApiToken(['store' => $bank['store'], 'user' => $context['user']], ['write-orders']); + app('auth')->forgetGuards(); + $this->withToken($bankToken)->postJson(adminStoreUrl($bank['store'], "orders/{$bank['order']->id}/confirm-payment")) + ->assertOk()->assertJsonPath('data.financial_status', 'paid'); + expect($bank['variant']->inventoryItem->refresh()->quantity_on_hand)->toBe(8) + ->and($bank['variant']->inventoryItem->quantity_reserved)->toBe(0); + }); + + it('rejects read-only order mutations and over-refunds as API errors', function () { + $fixture = paidOrderFixture(); + $context = ['store' => $fixture['store'], 'user' => $fixture['store']->users()->firstOrFail()]; + $reader = adminApiToken($context, ['read-orders']); + + $this->withToken($reader)->postJson(adminStoreUrl($fixture['store'], "orders/{$fixture['order']->id}/refunds"), ['amount' => 100]) + ->assertForbidden(); + + $writerUser = User::factory()->create(); + DB::table('store_users')->insert([ + 'store_id' => $fixture['store']->id, 'user_id' => $writerUser->id, 'role' => 'admin', 'created_at' => now(), + ]); + $writer = $writerUser->createToken('writer', [ + 'write-orders', + 'store:'.$fixture['store']->id, + ])->plainTextToken; + app('auth')->forgetGuards(); + $this->withToken($writer)->postJson(adminStoreUrl($fixture['store'], "orders/{$fixture['order']->id}/refunds"), ['amount' => 999999]) + ->assertUnprocessable()->assertJsonValidationErrors('amount'); + }); + + it('summarizes analytics and streams a tenant-only order CSV', function () { + $context = createStoreContext(); + $token = adminApiToken($context, ['read-analytics', 'read-orders']); + DB::table('analytics_daily')->insert([ + [ + 'store_id' => $context['store']->id, 'date' => '2026-07-10', 'orders_count' => 2, + 'revenue_amount' => 5000, 'aov_amount' => 2500, 'visits_count' => 10, + 'add_to_cart_count' => 4, 'checkout_started_count' => 3, 'checkout_completed_count' => 2, + ], + [ + 'store_id' => $context['store']->id, 'date' => '2026-07-11', 'orders_count' => 1, + 'revenue_amount' => 4000, 'aov_amount' => 4000, 'visits_count' => 8, + 'add_to_cart_count' => 3, 'checkout_started_count' => 2, 'checkout_completed_count' => 1, + ], + ]); + Order::factory()->for($context['store'])->create(['customer_id' => null, 'order_number' => '#CSV1001']); + + $this->withToken($token)->getJson(adminStoreUrl($context['store'], 'analytics/summary?from=2026-07-10&to=2026-07-11')) + ->assertOk() + ->assertJsonPath('data.revenue_amount', 9000) + ->assertJsonPath('data.orders_count', 3) + ->assertJsonPath('data.visits_count', 18) + ->assertJsonCount(2, 'data.series'); + + $queued = $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'exports/orders'), ['format' => 'csv']) + ->assertStatus(202) + ->assertJsonPath('status', 'queued'); + $exportId = $queued->json('export_id'); + $ready = $this->withToken($token)->getJson(adminStoreUrl($context['store'], "exports/{$exportId}")) + ->assertOk() + ->assertJsonPath('data.status', 'completed') + ->assertJsonPath('data.row_count', 1); + $downloadUrl = $ready->json('data.download_url'); + expect($downloadUrl)->toBeString()->not->toBeEmpty(); + $download = $this->get($downloadUrl); + $download->assertOk()->assertHeader('content-type', 'text/csv; charset=UTF-8'); + expect($download->streamedContent())->toContain('order_number,created_at,status,financial_status') + ->and($download->streamedContent())->toContain('#CSV1001'); + }); + + it('creates platform records invites members and reports token permissions', function () { + $context = createStoreContext(); + $token = adminApiToken($context, ['manage-platform']); + + $organization = $this->withToken($token)->postJson('/api/admin/v1/platform/organizations', [ + 'name' => 'API Organization', 'billing_email' => 'billing@api.example', + ])->assertCreated()->json('data'); + $newStore = $this->withToken($token)->postJson('/api/admin/v1/platform/stores', [ + 'organization_id' => $organization['id'], + 'name' => 'API Store', + 'handle' => 'api-store', + 'default_currency' => 'EUR', + 'default_locale' => 'en', + 'timezone' => 'Europe/Berlin', + ])->assertCreated()->assertJsonPath('data.status', 'active'); + + $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'invites'), [ + 'email' => 'new-staff@example.test', 'role' => 'staff', + ])->assertCreated()->assertJsonPath('data.role', 'staff'); + $this->withToken($token)->postJson(adminStoreUrl($context['store'], 'invites'), [ + 'email' => 'new-staff@example.test', 'role' => 'staff', + ])->assertConflict(); + $this->withToken($token)->getJson(adminStoreUrl($context['store'], 'me')) + ->assertOk() + ->assertJsonPath('data.role', 'owner') + ->assertJsonPath('data.permissions.0', 'manage-platform'); + }); +}); diff --git a/tests/Feature/Shop/AdminUiTest.php b/tests/Feature/Shop/AdminUiTest.php new file mode 100644 index 00000000..ab6a56b9 --- /dev/null +++ b/tests/Feature/Shop/AdminUiTest.php @@ -0,0 +1,547 @@ +get('/admin/login')->assertOk()->assertSee('Sign in to your store'); + $this->get('/admin/forgot-password')->assertOk()->assertSee('Reset your password'); + $this->get('/admin/reset-password/example-token')->assertOk()->assertSee('Choose a new password'); + }); + + it('redirects guests and unverified users and rejects users without a store', function () { + $this->get('/admin')->assertRedirect('/admin/login'); + + $unverified = createStoreContext(userAttributes: ['email_verified_at' => null]); + actingAsAdmin($unverified['user'], $unverified['store']); + $this->get('/admin')->assertRedirect(route('verification.notice')); + + $this->actingAs(User::factory()->create(), 'web') + ->withSession(['current_store_id' => null]) + ->get('/admin') + ->assertForbidden(); + }); + + it('renders the complete admin surface for an owner', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $product = Product::factory()->for($context['store'])->create(['title' => 'Route Product']); + $collection = Collection::factory()->for($context['store'])->create(['title' => 'Route Collection']); + $order = Order::factory()->for($context['store'])->create(['customer_id' => null, 'order_number' => '#ROUTE-1']); + $customer = Customer::factory()->for($context['store'])->create(['name' => 'Route Customer']); + $discount = Discount::factory()->for($context['store'])->create(['code' => 'ROUTE20']); + $theme = Theme::factory()->for($context['store'])->create(['name' => 'Route Theme']); + $page = Page::factory()->for($context['store'])->create(['title' => 'Route Page']); + $app = AppModel::query()->create(['name' => 'Route App', 'status' => 'active']); + $installation = AppInstallation::withoutGlobalScopes()->create([ + 'store_id' => $context['store']->id, + 'app_id' => $app->id, + 'scopes_json' => ['read_products'], + 'status' => 'active', + 'installed_at' => now(), + ]); + + $pages = [ + '/admin' => 'Dashboard', + '/admin/products' => 'Products', + '/admin/products/create' => 'Add product', + "/admin/products/{$product->id}/edit" => 'Route Product', + '/admin/inventory' => 'Inventory', + '/admin/collections' => 'Collections', + '/admin/collections/create' => 'Add collection', + "/admin/collections/{$collection->id}/edit" => 'Route Collection', + '/admin/orders' => 'Orders', + "/admin/orders/{$order->id}" => '#ROUTE-1', + '/admin/customers' => 'Customers', + "/admin/customers/{$customer->id}" => 'Route Customer', + '/admin/discounts' => 'Discounts', + '/admin/discounts/create' => 'Create discount', + "/admin/discounts/{$discount->id}/edit" => 'ROUTE20', + '/admin/settings' => 'Store Settings', + '/admin/settings/shipping' => 'Shipping', + '/admin/settings/taxes' => 'Tax Settings', + '/admin/themes' => 'Themes', + "/admin/themes/{$theme->id}/editor" => 'Customize Route Theme', + '/admin/pages' => 'Pages', + '/admin/pages/create' => 'Create page', + "/admin/pages/{$page->id}/edit" => 'Route Page', + '/admin/navigation' => 'Navigation', + '/admin/apps' => 'Apps', + "/admin/apps/{$installation->id}" => 'Route App', + '/admin/developers' => 'Developers', + '/admin/analytics' => 'Analytics', + '/admin/search/settings' => 'Search Settings', + ]; + + foreach ($pages as $url => $heading) { + $this->get($url)->assertOk()->assertSee($heading); + } + }); + + it('enforces tenant binding before resolving admin route models', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $foreign = createStoreContext(); + $product = Product::factory()->for($foreign['store'])->create(); + $order = Order::factory()->for($foreign['store'])->create(['customer_id' => null]); + $customer = Customer::factory()->for($foreign['store'])->create(); + $theme = Theme::factory()->for($foreign['store'])->create(); + + session(['current_store_id' => $context['store']->id]); + bindStore($context['store']); + + $this->get("/admin/products/{$product->id}/edit")->assertNotFound(); + $this->get("/admin/orders/{$order->id}")->assertNotFound(); + $this->get("/admin/customers/{$customer->id}")->assertNotFound(); + $this->get("/admin/themes/{$theme->id}/editor")->assertNotFound(); + }); + + it('gives staff operational access but keeps owner settings private', function () { + $context = createStoreContext('staff'); + actingAsAdmin($context['user'], $context['store']); + + foreach (['/admin', '/admin/products', '/admin/products/create', '/admin/inventory', '/admin/orders', '/admin/customers', '/admin/pages', '/admin/analytics'] as $url) { + $this->get($url)->assertOk(); + } + + foreach (['/admin/settings', '/admin/settings/shipping', '/admin/settings/taxes', '/admin/themes', '/admin/navigation', '/admin/apps', '/admin/developers', '/admin/search/settings'] as $url) { + $this->get($url)->assertForbidden(); + } + }); + + it('keeps support read only and denies privileged reporting and configuration', function () { + $context = createStoreContext('support'); + actingAsAdmin($context['user'], $context['store']); + $product = Product::factory()->for($context['store'])->create(['title' => 'Support Product']); + $customer = Customer::factory()->for($context['store'])->create(); + $order = Order::factory()->for($context['store'])->create(['customer_id' => $customer->id]); + + $this->get('/admin/products')->assertOk(); + $this->get("/admin/products/{$product->id}/edit") + ->assertOk() + ->assertSee('disabled', false) + ->assertDontSee('Save changes'); + $this->get("/admin/orders/{$order->id}") + ->assertOk() + ->assertDontSee('wire:click="openFulfillmentModal"', false) + ->assertDontSee('wire:click="openRefundModal"', false); + $this->get("/admin/customers/{$customer->id}") + ->assertOk() + ->assertDontSee('wire:click="openAddressForm"', false); + + foreach (['/admin/products/create', '/admin/pages', '/admin/analytics', '/admin/settings', '/admin/themes', '/admin/navigation', '/admin/apps', '/admin/developers', '/admin/search/settings'] as $url) { + $this->get($url)->assertForbidden(); + } + + $this->get('/admin')->assertForbidden(); + }); +}); + +describe('admin catalog workflows', function () { + it('creates a published product matrix with collections and inventory', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $collection = Collection::factory()->for($context['store'])->create(); + + Livewire::test(ProductForm::class) + ->set('title', 'Trail Runner') + ->set('handle', 'trail-runner') + ->set('descriptionHtml', '

      Built for long days.

      ') + ->set('status', 'active') + ->set('vendor', 'Northwind') + ->set('productType', 'Shoes') + ->set('tags', 'running, outdoor') + ->set('collectionIds', [$collection->id]) + ->set('options', [ + ['name' => 'Size', 'values' => ['S', 'M']], + ['name' => 'Color', 'values' => ['Black', 'Blue']], + ]) + ->set('variants', [ + ['title' => 'S / Black', 'sku' => 'TR-S-BLK', 'price' => 7900, 'compareAtPrice' => 9900, 'quantity' => 5, 'requiresShipping' => true], + ['title' => 'S / Blue', 'sku' => 'TR-S-BLU', 'price' => 7900, 'compareAtPrice' => null, 'quantity' => 6, 'requiresShipping' => true], + ['title' => 'M / Black', 'sku' => 'TR-M-BLK', 'price' => 8100, 'compareAtPrice' => null, 'quantity' => 7, 'requiresShipping' => true], + ['title' => 'M / Blue', 'sku' => 'TR-M-BLU', 'price' => 8100, 'compareAtPrice' => null, 'quantity' => 8, 'requiresShipping' => true], + ]) + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $product = Product::query()->where('handle', 'trail-runner')->firstOrFail(); + + expect($product->status->value)->toBe('active') + ->and($product->published_at)->not->toBeNull() + ->and($product->options()->count())->toBe(2) + ->and($product->variants()->count())->toBe(4) + ->and($product->collections()->pluck('collections.id')->all())->toBe([$collection->id]) + ->and($product->variants()->orderBy('position')->pluck('sku')->all())->toBe([ + 'TR-S-BLK', 'TR-S-BLU', 'TR-M-BLK', 'TR-M-BLU', + ]); + + expect(InventoryItem::query() + ->whereIn('variant_id', $product->variants()->pluck('id')) + ->orderBy('quantity_on_hand') + ->pluck('quantity_on_hand')->all())->toBe([5, 6, 7, 8]); + }); + + it('creates an ordered manual collection and a targeted discount', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $first = Product::factory()->for($context['store'])->create(['title' => 'First']); + $second = Product::factory()->for($context['store'])->create(['title' => 'Second']); + + Livewire::test(CollectionForm::class) + ->set('title', 'Summer Edit') + ->set('handle', 'summer-edit') + ->set('status', 'active') + ->set('assignedProductIds', [$second->id, $first->id]) + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $collection = Collection::query()->where('handle', 'summer-edit')->firstOrFail(); + + expect($collection->products()->orderByPivot('position')->pluck('products.id')->all()) + ->toBe([$second->id, $first->id]); + + Livewire::test(DiscountForm::class) + ->set('type', 'code') + ->set('code', 'summer25') + ->set('valueType', 'percent') + ->set('valueAmount', 25) + ->set('minimumPurchaseAmount', 5000) + ->set('specificProductIds', [$first->id]) + ->set('specificCollectionIds', [$collection->id]) + ->set('usageLimit', 50) + ->set('onePerCustomer', true) + ->set('startsAt', now()->subHour()->format('Y-m-d\TH:i')) + ->set('endsAt', now()->addMonth()->format('Y-m-d\TH:i')) + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $discount = Discount::query()->where('code', 'SUMMER25')->firstOrFail(); + + expect($discount->value_amount)->toBe(25) + ->and($discount->usage_limit)->toBe(50) + ->and(data_get($discount->rules_json, 'min_purchase_amount'))->toBe(5000) + ->and(data_get($discount->rules_json, 'applicable_product_ids'))->toBe([$first->id]) + ->and(data_get($discount->rules_json, 'applicable_collection_ids'))->toBe([$collection->id]) + ->and(data_get($discount->rules_json, 'one_per_customer'))->toBeTrue(); + }); + + it('edits inventory through the admin component and preserves reservations', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $sku = makeSellableVariant($context['store'], inventoryAttributes: [ + 'quantity_on_hand' => 12, + 'quantity_reserved' => 3, + 'policy' => 'deny', + ]); + + Livewire::test(InventoryIndex::class) + ->call('updateQuantity', $sku['inventory']->id, 25) + ->call('updatePolicy', $sku['inventory']->id, 'continue') + ->assertDispatched('toast'); + + expect($sku['inventory']->refresh()) + ->quantity_on_hand->toBe(25) + ->quantity_reserved->toBe(3) + ->and($sku['inventory']->policy->value)->toBe('continue'); + }); + + it('authorizes and queues a valid direct product media upload', function () { + Storage::fake('public'); + Queue::fake(); + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $product = Product::factory()->for($context['store'])->create(['title' => 'Upload Product']); + + Livewire::test(ProductForm::class, ['product' => $product]) + ->set('newMedia', [UploadedFile::fake()->image('product.png', 800, 600)->size(200)]) + ->call('uploadMedia') + ->assertHasNoErrors(); + + $media = $product->media()->firstOrFail(); + expect($media->alt_text)->toBe('Upload Product') + ->and($media->mime_type)->toBe('image/png') + ->and($media->status->value)->toBe('processing'); + Storage::disk('public')->assertExists($media->storage_key); + Queue::assertPushed(ProcessMediaUpload::class, fn (ProcessMediaUpload $job): bool => $job->media->is($media)); + }); + + it('rejects unauthorized and invalid direct product media uploads', function () { + Storage::fake('public'); + Queue::fake(); + $support = createStoreContext('support'); + actingAsAdmin($support['user'], $support['store']); + $product = Product::factory()->for($support['store'])->create(); + + Livewire::test(ProductForm::class, ['product' => $product]) + ->set('newMedia', [UploadedFile::fake()->image('forbidden.png')]) + ->call('uploadMedia') + ->assertForbidden(); + + expect($product->media()->count())->toBe(0); + Queue::assertNothingPushed(); + + $owner = createStoreContext(); + actingAsAdmin($owner['user'], $owner['store']); + $ownerProduct = Product::factory()->for($owner['store'])->create(); + + Livewire::test(ProductForm::class, ['product' => $ownerProduct]) + ->set('newMedia', [UploadedFile::fake()->create('payload.php', 20, 'application/x-php')]) + ->call('uploadMedia') + ->assertHasErrors(['newMedia.0']); + + Livewire::test(ProductForm::class, ['product' => $ownerProduct]) + ->set('newMedia', [UploadedFile::fake()->image('oversized.jpg')->size(5121)]) + ->call('uploadMedia') + ->assertHasErrors(['newMedia.0']); + + expect($ownerProduct->media()->count())->toBe(0); + Queue::assertNothingPushed(); + }); +}); + +describe('admin customer and order workflows', function () { + it('creates edits defaults and deletes customer addresses', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $customer = Customer::factory()->for($context['store'])->create(); + + $component = Livewire::test(CustomerShow::class, ['customer' => $customer]) + ->call('openAddressForm') + ->set('addressLabel', 'Home') + ->set('addressJson', [ + 'first_name' => 'Ada', 'last_name' => 'Lovelace', 'company' => '', + 'address1' => 'Example Street 1', 'address2' => '', 'city' => 'Berlin', + 'province' => 'Berlin', 'province_code' => 'be', 'country' => 'Germany', + 'country_code' => 'de', 'zip' => '10115', 'phone' => '+49 30 1234', + ]) + ->set('addressIsDefault', true) + ->call('saveAddress') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $home = $customer->addresses()->firstOrFail(); + expect($home->is_default)->toBeTrue() + ->and(data_get($home->address_json, 'country_code'))->toBe('DE') + ->and(data_get($home->address_json, 'province_code'))->toBe('BE'); + + $work = $customer->addresses()->create([ + 'label' => 'Work', + 'address_json' => $home->address_json, + 'is_default' => false, + ]); + + $component->call('setDefaultAddress', $work->id)->assertDispatched('toast'); + expect($work->refresh()->is_default)->toBeTrue() + ->and($home->refresh()->is_default)->toBeFalse(); + + $component->call('deleteAddress', $work->id)->assertDispatched('toast'); + expect($work->fresh())->toBeNull() + ->and($home->refresh()->is_default)->toBeTrue(); + }); + + it('fulfills ships delivers and refunds an order through the detail component', function () { + $fixture = paidOrderFixture(quantity: 2); + $owner = $fixture['store']->users()->firstOrFail(); + actingAsAdmin($owner, $fixture['store']); + + $component = Livewire::test(OrderShow::class, ['order' => $fixture['order']]) + ->set('fulfillmentLines', [$fixture['line']->id => 2]) + ->set('trackingCompany', 'DHL') + ->set('trackingNumber', 'TRACK-100') + ->call('createFulfillment') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $fulfillment = $fixture['order']->fulfillments()->firstOrFail(); + $component->call('markAsShipped', $fulfillment->id) + ->call('markAsDelivered', $fulfillment->id) + ->assertDispatched('toast'); + + expect($fulfillment->refresh()->status->value)->toBe('delivered') + ->and($fixture['order']->refresh()->fulfillment_status->value)->toBe('fulfilled'); + + $component->set('refundAmount', 2500) + ->set('refundReason', 'Customer return') + ->set('refundLines', [$fixture['line']->id => 1]) + ->call('createRefund') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + expect($fixture['order']->refunds()->count())->toBe(1) + ->and($fixture['payment']->refunds()->where('status', 'processed')->sum('amount'))->toBe(2500); + }); + + it('confirms a pending bank transfer through the order detail component', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $order = Order::factory()->for($context['store'])->create([ + 'customer_id' => null, + 'payment_method' => 'bank_transfer', + 'status' => 'pending', + 'financial_status' => 'pending', + ]); + Payment::factory()->for($order)->create([ + 'method' => 'bank_transfer', + 'status' => 'pending', + 'amount' => $order->total_amount, + ]); + + Livewire::test(OrderShow::class, ['order' => $order]) + ->call('confirmPayment') + ->assertDispatched('toast'); + + expect($order->refresh()->status->value)->toBe('paid') + ->and($order->financial_status->value)->toBe('paid') + ->and($order->payments()->firstOrFail()->status->value)->toBe('captured'); + }); +}); + +describe('admin settings workflows', function () { + it('updates store settings and safely manages tenant domains', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + + $component = Livewire::test(SettingsIndex::class) + ->set('storeName', 'Northwind Outdoor') + ->set('defaultCurrency', 'CHF') + ->set('defaultLocale', 'de') + ->set('timezone', 'Europe/Zurich') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast') + ->set('newHostname', 'admin.northwind.test') + ->set('newType', 'admin') + ->call('addDomain') + ->assertHasNoErrors(); + + expect($context['store']->refresh()) + ->name->toBe('Northwind Outdoor') + ->default_currency->toBe('CHF') + ->default_locale->toBe('de') + ->timezone->toBe('Europe/Zurich'); + + $domain = StoreDomain::withoutGlobalScopes()->where('hostname', 'admin.northwind.test')->firstOrFail(); + expect($domain->store_id)->toBe($context['store']->id) + ->and($domain->type->value)->toBe('admin') + ->and($domain->is_primary)->toBeTrue(); + + $component->call('removeDomain', $domain->id)->assertStatus(422); + }); + + it('creates shipping zones and rates and tests address matching', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + + $component = Livewire::test(ShippingSettings::class) + ->call('openZoneModal') + ->set('zoneName', 'DACH') + ->set('zoneCountries', ['DE', 'AT', 'CH']) + ->call('saveZone') + ->assertHasNoErrors(); + + $zone = ShippingZone::query()->where('name', 'DACH')->firstOrFail(); + + $component->call('openRateModal', $zone->id) + ->set('rateName', 'Tracked parcel') + ->set('rateType', 'flat') + ->set('rateAmount', '6.90') + ->set('rateActive', true) + ->call('saveRate') + ->assertHasNoErrors() + ->set('testAddress', ['country' => 'DE', 'province' => 'BE', 'city' => 'Berlin', 'postal_code' => '10115']) + ->call('testShippingAddress') + ->assertSet('testResult.zone', 'DACH') + ->assertSet('testResult.rates.0.amount', 690); + + expect(ShippingRate::query()->where('zone_id', $zone->id)->firstOrFail()) + ->name->toBe('Tracked parcel') + ->and(data_get(ShippingRate::query()->where('zone_id', $zone->id)->firstOrFail()->config_json, 'amount'))->toBe(690); + }); + + it('persists manual and provider tax settings with validation', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + + Livewire::test(TaxSettings::class) + ->set('mode', 'manual') + ->set('pricesIncludeTax', true) + ->set('provider', 'none') + ->set('manualRates', [ + ['zone_name' => 'Germany', 'rate_percentage' => '19'], + ['zone_name' => 'Reduced', 'rate_percentage' => '7'], + ]) + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('toast'); + + $settings = TaxSettingsModel::withoutGlobalScopes()->where('store_id', $context['store']->id)->firstOrFail(); + expect($settings->mode->value)->toBe('manual') + ->and($settings->prices_include_tax)->toBeTrue() + ->and(data_get($settings->config_json, 'manual_rates'))->toBe([ + ['zone_name' => 'Germany', 'rate_percentage' => 19], + ['zone_name' => 'Reduced', 'rate_percentage' => 7], + ]); + }); +}); + +describe('admin role boundaries in Livewire', function () { + it('allows support to inspect products orders and customers but blocks mutations', function () { + $context = createStoreContext('support'); + actingAsAdmin($context['user'], $context['store']); + $sku = makeSellableVariant($context['store']); + $customer = Customer::factory()->for($context['store'])->create(); + $order = Order::factory()->for($context['store'])->create(['customer_id' => $customer->id]); + + Livewire::test(InventoryIndex::class)->assertOk(); + Livewire::test(OrderShow::class, ['order' => $order])->assertOk(); + Livewire::test(CustomerShow::class, ['customer' => $customer])->assertOk(); + + Livewire::test(InventoryIndex::class) + ->call('updateQuantity', $sku['inventory']->id, 999) + ->assertForbidden(); + + expect($sku['inventory']->refresh()->quantity_on_hand)->toBe(20); + }); + + it('blocks staff from owner-only settings', function () { + $context = createStoreContext('staff'); + actingAsAdmin($context['user'], $context['store']); + + Livewire::test(SettingsIndex::class)->assertForbidden(); + Livewire::test(ShippingSettings::class)->assertForbidden(); + Livewire::test(TaxSettings::class)->assertForbidden(); + }); +}); diff --git a/tests/Feature/Shop/AuthExportAuthorizationRequirementsTest.php b/tests/Feature/Shop/AuthExportAuthorizationRequirementsTest.php new file mode 100644 index 00000000..cda95ffe --- /dev/null +++ b/tests/Feature/Shop/AuthExportAuthorizationRequirementsTest.php @@ -0,0 +1,301 @@ + $requestClass */ +function securityFormRequest(string $requestClass, User $user, ?Route $route = null): FormRequest +{ + $request = $requestClass::create('/authorization-check', 'POST'); + $request->setUserResolver(fn (): User => $user); + if ($route !== null) { + $request->setRouteResolver(fn (): Route => $route); + } + + return $request; +} + +function securityRoute(string $uri, string $parameter, int $value): Route +{ + $request = Request::create(str_replace('{'.$parameter.'}', (string) $value, $uri), 'POST'); + $route = new Route(['POST'], $uri, fn () => null); + $route->bind($request); + + return $route; +} + +afterEach(function (): void { + foreach (['login-ip:127.0.0.1', 'admin-login-ip:127.0.0.1', 'customer-login-ip:127.0.0.1'] as $key) { + RateLimiter::clear($key); + } +}); + +describe('global per-IP login throttling', function () { + it('blocks the sixth admin attempt even when every attempt uses a different identity', function () { + RateLimiter::clear('admin-login-ip:127.0.0.1'); + + foreach (range(1, 5) as $attempt) { + Livewire::test(AdminLogin::class) + ->set('email', "missing-admin-{$attempt}@example.test") + ->set('password', 'invalid-password') + ->call('authenticate') + ->assertHasErrors(['email' => 'Invalid credentials']); + } + + Livewire::test(AdminLogin::class) + ->set('email', 'another-admin@example.test') + ->set('password', 'invalid-password') + ->call('authenticate') + ->assertHasErrors(['email' => function (array $rules, array $messages): bool { + return str_starts_with($messages[0] ?? '', 'Too many attempts. Try again in '); + }]); + }); + + it('blocks the sixth customer attempt even when every attempt uses a different identity', function () { + $context = createStoreContext(); + bindStore($context['store']); + RateLimiter::clear('customer-login-ip:127.0.0.1'); + + foreach (range(1, 5) as $attempt) { + Livewire::test(CustomerLogin::class) + ->set('email', "missing-customer-{$attempt}@example.test") + ->set('password', 'invalid-password') + ->call('login') + ->assertHasErrors(['credentials' => 'Invalid credentials']); + } + + Livewire::test(CustomerLogin::class) + ->set('email', 'another-customer@example.test') + ->set('password', 'invalid-password') + ->call('login') + ->assertHasErrors(['email' => function (array $rules, array $messages): bool { + return str_starts_with($messages[0] ?? '', 'Too many attempts. Try again in '); + }]) + ->assertHasNoErrors('credentials'); + }); + + it('shares one IP budget across the admin and customer login surfaces', function () { + $context = createStoreContext(); + bindStore($context['store']); + foreach (['login-ip:127.0.0.1', 'admin-login-ip:127.0.0.1', 'customer-login-ip:127.0.0.1'] as $key) { + RateLimiter::clear($key); + } + + foreach (range(1, 3) as $attempt) { + Livewire::test(AdminLogin::class) + ->set('email', "shared-admin-{$attempt}@example.test") + ->set('password', 'invalid-password') + ->call('authenticate') + ->assertHasErrors(['email' => 'Invalid credentials']); + } + foreach (range(1, 2) as $attempt) { + Livewire::test(CustomerLogin::class) + ->set('email', "shared-customer-{$attempt}@example.test") + ->set('password', 'invalid-password') + ->call('login') + ->assertHasErrors(['credentials' => 'Invalid credentials']); + } + + Livewire::test(CustomerLogin::class) + ->set('email', 'shared-customer-final@example.test') + ->set('password', 'invalid-password') + ->call('login') + ->assertHasErrors(['email' => function (array $rules, array $messages): bool { + return str_starts_with($messages[0] ?? '', 'Too many attempts. Try again in '); + }]) + ->assertHasNoErrors('credentials'); + }); +}); + +describe('explicit API policy and FormRequest authorization', function () { + it('requires every admin resource controller action to invoke authorization explicitly', function () { + $actions = [ + ProductController::class => ['index', 'store', 'show', 'update', 'destroy'], + CollectionController::class => ['index', 'store', 'update', 'destroy'], + DiscountController::class => ['index', 'store', 'update', 'destroy'], + OrderController::class => ['index', 'show', 'fulfill', 'refund', 'confirmPayment'], + SettingsController::class => ['zones', 'storeZone', 'updateZone', 'storeRate', 'tax', 'updateTax'], + ContentController::class => ['pages', 'storePage', 'updatePage', 'destroyPage', 'storeTheme', 'publishTheme', 'updateThemeSettings', 'reindex', 'searchStatus'], + MediaController::class => ['presign'], + AnalyticsController::class => ['summary', 'exportOrders', 'export'], + ]; + $missing = []; + + foreach ($actions as $controller => $methods) { + $lines = file((new ReflectionClass($controller))->getFileName()); + foreach ($methods as $method) { + $reflection = new ReflectionMethod($controller, $method); + $source = implode('', array_slice($lines, $reflection->getStartLine() - 1, $reflection->getEndLine() - $reflection->getStartLine() + 1)); + if (! str_contains($source, 'authorize(')) { + $missing[] = class_basename($controller).'::'.$method; + } + } + } + + expect($missing)->toBe([]); + }); + + it('allows privileged create requests and rejects the same requests for support users', function () { + $owner = createStoreContext('owner'); + bindStore($owner['store']); + $requestClasses = [ + StoreProductRequest::class, + StoreCollectionRequest::class, + StoreDiscountRequest::class, + StorePageRequest::class, + StoreShippingRateRequest::class, + StoreShippingZoneRequest::class, + UpdateStoreSettingsRequest::class, + UpdateTaxSettingsRequest::class, + InviteStaffRequest::class, + ]; + + foreach ($requestClasses as $requestClass) { + expect(securityFormRequest($requestClass, $owner['user'])->authorize()) + ->toBeTrue("{$requestClass} should authorize the owner role"); + } + + $support = createStoreContext('support'); + bindStore($support['store']); + foreach ($requestClasses as $requestClass) { + expect(securityFormRequest($requestClass, $support['user'])->authorize()) + ->toBeFalse("{$requestClass} must reject the support role"); + } + }); + + it('uses tenant-bound model policies for update fulfillment and refund requests', function () { + $owner = createStoreContext('owner'); + bindStore($owner['store']); + $product = Product::factory()->for($owner['store'])->create(); + $order = Order::factory()->for($owner['store'])->create(); + + $productRoute = securityRoute('/products/{productId}', 'productId', $product->id); + $orderRoute = securityRoute('/orders/{orderId}', 'orderId', $order->id); + expect(securityFormRequest(UpdateProductRequest::class, $owner['user'], $productRoute)->authorize())->toBeTrue() + ->and(securityFormRequest(CreateFulfillmentRequest::class, $owner['user'], $orderRoute)->authorize())->toBeTrue() + ->and(securityFormRequest(CreateRefundRequest::class, $owner['user'], $orderRoute)->authorize())->toBeTrue(); + + $support = createStoreContext('support'); + bindStore($support['store']); + $supportProduct = Product::factory()->for($support['store'])->create(); + $supportOrder = Order::factory()->for($support['store'])->create(); + $supportProductRoute = securityRoute('/products/{productId}', 'productId', $supportProduct->id); + $supportOrderRoute = securityRoute('/orders/{orderId}', 'orderId', $supportOrder->id); + expect(securityFormRequest(UpdateProductRequest::class, $support['user'], $supportProductRoute)->authorize())->toBeFalse() + ->and(securityFormRequest(CreateFulfillmentRequest::class, $support['user'], $supportOrderRoute)->authorize())->toBeFalse() + ->and(securityFormRequest(CreateRefundRequest::class, $support['user'], $supportOrderRoute)->authorize())->toBeFalse(); + + bindStore($owner['store']); + expect(securityFormRequest(UpdateProductRequest::class, $owner['user'], $supportProductRoute)->authorize())->toBeFalse(); + }); +}); + +it('exports populated customer_name and shipping_method CSV columns', function () { + Storage::fake('local'); + $context = createStoreContext(); + $customer = Customer::factory()->for($context['store'])->create(['name' => 'Ada Buyer']); + $order = Order::factory()->for($context['store'])->for($customer)->create([ + 'order_number' => '#EXPORT-1', + 'email' => 'ada@example.test', + ]); + $zone = ShippingZone::factory()->for($context['store'])->create(); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(['name' => 'Express Courier']); + $cart = Cart::factory()->for($context['store'])->create(['customer_id' => $customer->id]); + Checkout::factory()->completed()->for($context['store'])->for($cart)->create([ + 'customer_id' => $customer->id, + 'shipping_method_id' => $rate->id, + 'totals_json' => ['order_id' => $order->id], + ]); + $guestOrder = Order::factory()->for($context['store'])->create([ + 'customer_id' => null, + 'order_number' => '#EXPORT-GUEST', + 'email' => 'guest@example.test', + 'shipping_address_json' => [ + 'first_name' => 'Grace', + 'last_name' => 'Guest', + 'address1' => '2 Example Street', + 'city' => 'Berlin', + 'country_code' => 'DE', + 'postal_code' => '10115', + ], + ]); + $guestCart = Cart::factory()->for($context['store'])->create(['customer_id' => null]); + Checkout::factory()->completed()->for($context['store'])->for($guestCart)->create([ + 'customer_id' => null, + 'shipping_method_id' => $rate->id, + 'totals_json' => ['order_id' => $guestOrder->id], + ]); + $export = OrderExport::withoutGlobalScopes()->create([ + 'store_id' => $context['store']->id, + 'user_id' => $context['user']->id, + 'format' => 'csv', + 'filters_json' => [], + 'status' => 'queued', + ]); + + (new GenerateOrderExport($export))->handle(); + + $export->refresh(); + Storage::disk('local')->assertExists($export->storage_key); + $rows = array_map('str_getcsv', preg_split('/\R/', trim(Storage::disk('local')->get($export->storage_key)))); + $records = collect(array_slice($rows, 1)) + ->map(fn (array $row): array => array_combine($rows[0], $row)) + ->keyBy('order_number'); + $record = $records->get('#EXPORT-1'); + $guestRecord = $records->get('#EXPORT-GUEST'); + + expect($record)->toBeArray() + ->and($record['order_number'])->toBe('#EXPORT-1') + ->and($record['customer_email'])->toBe('ada@example.test') + ->and($record['customer_name'])->toBe('Ada Buyer') + ->and($record['shipping_method'])->toBe('Express Courier') + ->and($guestRecord)->toBeArray() + ->and($guestRecord['customer_email'])->toBe('guest@example.test') + ->and($guestRecord['customer_name'])->toBe('Grace Guest') + ->and($guestRecord['shipping_method'])->toBe('Express Courier'); +}); + +it('denies support users access to the admin dashboard', function () { + $support = createStoreContext('support'); + actingAsAdmin($support['user'], $support['store']); + + $this->get('/admin')->assertForbidden(); + Livewire::test(AdminDashboard::class)->assertForbidden(); +}); diff --git a/tests/Feature/Shop/CatalogCartPricingTest.php b/tests/Feature/Shop/CatalogCartPricingTest.php new file mode 100644 index 00000000..ff7af52c --- /dev/null +++ b/tests/Feature/Shop/CatalogCartPricingTest.php @@ -0,0 +1,435 @@ +for($storeA)->create(['title' => 'T-Shirt', 'handle' => 't-shirt']); + Product::factory()->for($storeA)->create(['title' => 'T-Shirt Again', 'handle' => 't-shirt-1']); + $editable = Product::factory()->for($storeA)->create(['title' => 'Editable', 'handle' => 'editable']); + $handles = app(HandleGenerator::class); + + expect($handles->generate('T-Shirt', 'products', $storeA->id))->toBe('t-shirt-2') + ->and($handles->generate('T-Shirt', 'products', $storeB->id))->toBe('t-shirt') + ->and($handles->generate('Editable', 'products', $storeA->id, $editable->id))->toBe('editable') + ->and($handles->generate("Loewe's Fall/Winter 2026", 'products', $storeA->id))->toBe('loewes-fallwinter-2026'); + }); + + it('creates a draft product with one default variant and inventory', function () { + $store = createStoreContext()['store']; + $product = app(ProductService::class)->create($store, [ + 'title' => 'Summer T-Shirt', + 'description_html' => '

      Soft cotton.

      ', + 'variant' => ['price_amount' => 2500, 'quantity_on_hand' => 12, 'sku' => 'SUMMER-1'], + ]); + + expect($product->status->value)->toBe('draft') + ->and($product->handle)->toBe('summer-t-shirt') + ->and($product->variants)->toHaveCount(1) + ->and($product->variants->first()->is_default)->toBeTrue() + ->and($product->variants->first()->inventoryItem->quantity_on_hand)->toBe(12); + }); + + it('publishes archives and emits the exact product transition event', function () { + Event::fake([ProductStatusChanged::class]); + $store = createStoreContext()['store']; + $product = app(ProductService::class)->create($store, [ + 'title' => 'Publishable', + 'variant' => ['price_amount' => 1200], + ]); + $service = app(ProductService::class); + + $service->transitionStatus($product, ProductStatus::Active); + expect($product->refresh()->status->value)->toBe('active') + ->and($product->published_at)->not->toBeNull(); + Event::assertDispatched(ProductStatusChanged::class, fn ($event): bool => $event->product->is($product) && $event->from === 'draft' && $event->to === 'active'); + + $service->transitionStatus($product, ProductStatus::Archived); + expect($product->refresh()->status->value)->toBe('archived'); + }); + + it('blocks publication without a positive-price variant', function () { + $store = createStoreContext()['store']; + $product = app(ProductService::class)->create($store, [ + 'title' => 'Free Draft', + 'variant' => ['price_amount' => 0], + ]); + + app(ProductService::class)->transitionStatus($product, ProductStatus::Active); + })->throws(InvalidProductTransitionException::class); + + it('blocks return to draft and deletion once order history exists', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store); + $order = Order::factory()->for($store)->create(['customer_id' => null]); + OrderLine::factory()->for($order)->create([ + 'product_id' => $sku['product']->id, + 'variant_id' => $sku['variant']->id, + 'quantity' => 1, + ]); + $service = app(ProductService::class); + + expect(fn () => $service->transitionStatus($sku['product'], ProductStatus::Draft)) + ->toThrow(InvalidProductTransitionException::class) + ->and(fn () => $service->delete($sku['product'])) + ->toThrow(InvalidProductTransitionException::class); + }); + + it('hard deletes only an unreferenced draft product', function () { + $store = createStoreContext()['store']; + $product = app(ProductService::class)->create($store, ['title' => 'Disposable Draft']); + + app(ProductService::class)->delete($product); + + expect(Product::withoutGlobalScopes()->find($product->id))->toBeNull(); + }); +}); + +describe('variant matrix SKU and inventory invariants', function () { + it('creates the full cartesian matrix for two options', function () { + $store = createStoreContext()['store']; + $product = app(ProductService::class)->create($store, [ + 'title' => 'Matrix Shirt', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M', 'L']], + ['name' => 'Color', 'values' => ['Red', 'Blue']], + ], + ]); + + expect($product->variants)->toHaveCount(6) + ->and($product->variants->every(fn ($variant): bool => $variant->optionValues()->count() === 2))->toBeTrue() + ->and($product->variants->every(fn ($variant): bool => $variant->inventoryItem()->exists()))->toBeTrue(); + }); + + it('preserves existing variant identity and price when one value is added', function () { + $store = createStoreContext()['store']; + $service = app(ProductService::class); + $product = $service->create($store, [ + 'title' => 'Expandable Shirt', + 'options' => [['name' => 'Size', 'values' => ['S', 'M']]], + ]); + $originals = $product->variants->sortBy('id')->values(); + $originals[0]->update(['price_amount' => 1111]); + $originals[1]->update(['price_amount' => 2222]); + + $updated = $service->update($product, [ + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L']]], + ]); + + expect($updated->variants)->toHaveCount(3) + ->and($updated->variants->pluck('id')->all())->toContain($originals[0]->id, $originals[1]->id) + ->and($updated->variants->whereIn('id', $originals->modelKeys())->sortBy('id')->pluck('price_amount')->all())->toBe([1111, 2222]); + }); + + it('archives an orphaned ordered variant and deletes an unreferenced one', function () { + $store = createStoreContext()['store']; + $product = app(ProductService::class)->create($store, [ + 'title' => 'Shrinking Shirt', + 'options' => [['name' => 'Size', 'values' => ['S', 'M', 'L']]], + ]); + [$ordered, $unreferenced] = $product->variants->take(2)->values()->all(); + $order = Order::factory()->for($store)->create(['customer_id' => null]); + OrderLine::factory()->for($order)->create([ + 'product_id' => $product->id, + 'variant_id' => $ordered->id, + ]); + $option = $product->options->first(); + $orderedValue = $ordered->optionValues->first(); + $unreferencedValue = $unreferenced->optionValues->first(); + $option->values()->whereKey([$orderedValue->id, $unreferencedValue->id])->delete(); + + app(VariantMatrixService::class)->rebuildMatrix($product); + + expect(ProductVariant::query()->find($ordered->id)?->status->value)->toBe('archived') + ->and(ProductVariant::query()->find($unreferenced->id))->toBeNull(); + }); + + it('creates exactly one inventory item whenever a variant is created', function () { + $store = createStoreContext()['store']; + $product = Product::factory()->for($store)->create(); + $variant = ProductVariant::factory()->for($product)->create(); + + expect($variant->inventoryItem()->count())->toBe(1) + ->and($variant->inventoryItem->quantity_on_hand)->toBe(0) + ->and($variant->inventoryItem->quantity_reserved)->toBe(0); + }); + + it('rejects a duplicate non-empty SKU inside one store', function () { + $store = createStoreContext()['store']; + $service = app(ProductService::class); + $service->create($store, ['title' => 'First SKU', 'variant' => ['sku' => 'UNIQUE-1', 'price_amount' => 1000]]); + + $service->create($store, ['title' => 'Second SKU', 'variant' => ['sku' => 'UNIQUE-1', 'price_amount' => 1000]]); + })->throws(ValidationException::class); + + it('allows duplicate SKUs in separate stores and multiple null SKUs', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $service = app(ProductService::class); + + $service->create($storeA, ['title' => 'Store A SKU', 'variant' => ['sku' => 'SHARED-1']]); + $service->create($storeB, ['title' => 'Store B SKU', 'variant' => ['sku' => 'SHARED-1']]); + $service->create($storeA, ['title' => 'Null SKU One', 'variant' => ['sku' => null]]); + $service->create($storeA, ['title' => 'Null SKU Two', 'variant' => ['sku' => null]]); + + expect(ProductVariant::query()->whereNull('sku')->count())->toBe(2) + ->and(ProductVariant::withoutGlobalScopes()->where('sku', 'SHARED-1')->count())->toBe(2); + }); + + it('reserves releases commits and restocks integer inventory', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store, inventoryAttributes: ['quantity_on_hand' => 10, 'quantity_reserved' => 0]); + $service = app(InventoryService::class); + + expect($service->available($sku['inventory']))->toBe(10) + ->and($service->checkAvailability($sku['inventory'], 10))->toBeTrue(); + $service->reserve($sku['inventory'], 3); + expect($sku['inventory']->quantity_reserved)->toBe(3) + ->and($service->available($sku['inventory']))->toBe(7); + $service->release($sku['inventory'], 1); + expect($sku['inventory']->quantity_reserved)->toBe(2); + $service->commit($sku['inventory'], 2); + expect($sku['inventory']->quantity_on_hand)->toBe(8) + ->and($sku['inventory']->quantity_reserved)->toBe(0); + $service->restock($sku['inventory'], 4); + expect($sku['inventory']->quantity_on_hand)->toBe(12); + }); + + it('blocks insufficient deny-policy inventory but permits backorders', function () { + $store = createStoreContext()['store']; + $deny = makeSellableVariant($store, inventoryAttributes: ['quantity_on_hand' => 5, 'quantity_reserved' => 3]); + $continue = makeSellableVariant($store, inventoryAttributes: ['quantity_on_hand' => 2, 'policy' => 'continue']); + $service = app(InventoryService::class); + + expect(fn () => $service->reserve($deny['inventory'], 3))->toThrow(InsufficientInventoryException::class); + $service->reserve($continue['inventory'], 5); + expect($continue['inventory']->quantity_reserved)->toBe(5); + }); +}); + +describe('cart mutations and optimistic concurrency', function () { + it('creates prices combines updates removes and versions cart lines', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store, variantAttributes: ['price_amount' => 2500]); + $service = app(CartService::class); + $cart = $service->create($store); + + expect($cart->cart_version)->toBe(1) + ->and($cart->status->value)->toBe('active') + ->and($cart->currency)->toBe($store->default_currency); + + $line = $service->addLine($cart, $sku['variant'], 2, 1); + expect($line->quantity)->toBe(2) + ->and($line->unit_price_amount)->toBe(2500) + ->and($line->line_subtotal_amount)->toBe(5000) + ->and($line->line_total_amount)->toBe(5000) + ->and($cart->cart_version)->toBe(2); + + $combined = $service->addLine($cart, $sku['variant'], 1, 2); + expect($combined->id)->toBe($line->id) + ->and($combined->quantity)->toBe(3) + ->and($combined->line_subtotal_amount)->toBe(7500) + ->and($cart->cart_version)->toBe(3); + + $updated = $service->updateLineQuantity($cart, $line->id, 4, 3); + expect($updated->quantity)->toBe(4) + ->and($updated->line_total_amount)->toBe(10000) + ->and($cart->cart_version)->toBe(4); + + $service->removeLine($cart, $line->id, 4); + expect($cart->cart_version)->toBe(5) + ->and($cart->lines()->count())->toBe(0); + }); + + it('removes a line when its quantity is changed to zero exactly once', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store); + ['cart' => $cart, 'line' => $line] = makeCartWithLine($store, $sku['variant'], 2); + + app(CartService::class)->updateLineQuantity($cart, $line->id, 0, 2); + + expect($cart->lines()->count())->toBe(0) + ->and($cart->cart_version)->toBe(3); + }); + + it('returns current state through a cart version conflict', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store); + ['cart' => $cart, 'line' => $line] = makeCartWithLine($store, $sku['variant']); + + try { + app(CartService::class)->updateLineQuantity($cart, $line->id, 2, 1); + $this->fail('A stale cart mutation was accepted.'); + } catch (CartVersionMismatchException $exception) { + expect($exception->cart->cart_version)->toBe(2) + ->and($exception->cart->lines)->toHaveCount(1); + } + }); + + it('rejects foreign inactive and unavailable variants', function () { + $store = createStoreContext()['store']; + $other = createStoreContext()['store']; + $cart = app(CartService::class)->create($store); + $foreign = makeSellableVariant($other); + $draft = makeSellableVariant($store, productAttributes: ['status' => 'draft', 'published_at' => null]); + $empty = makeSellableVariant($store, inventoryAttributes: ['quantity_on_hand' => 1]); + $service = app(CartService::class); + + expect(fn () => $service->addLine($cart, $foreign['variant']))->toThrow(DomainException::class) + ->and(fn () => $service->addLine($cart, $draft['variant']))->toThrow(DomainException::class) + ->and(fn () => $service->addLine($cart, $empty['variant'], 2))->toThrow(InsufficientInventoryException::class); + }); + + it('allows continue-policy overselling and merges guest quantities', function () { + $store = createStoreContext()['store']; + $first = makeSellableVariant($store, inventoryAttributes: ['quantity_on_hand' => 0, 'policy' => 'continue']); + $second = makeSellableVariant($store); + $service = app(CartService::class); + $guest = $service->create($store); + $customer = $service->create($store); + $service->addLine($guest, $first['variant'], 2); + $service->addLine($customer, $first['variant'], 1); + $service->addLine($customer, $second['variant'], 3); + + $merged = $service->mergeOnLogin($guest, $customer); + + expect($merged->lines)->toHaveCount(2) + ->and($merged->lines->firstWhere('variant_id', $first['variant']->id)->quantity)->toBe(2) + ->and($merged->lines->firstWhere('variant_id', $second['variant']->id)->quantity)->toBe(3) + ->and($guest->refresh()->status->value)->toBe('abandoned'); + }); +}); + +describe('discount shipping tax and pricing integration', function () { + it('validates codes case-insensitively and returns precise rejection reasons', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store, variantAttributes: ['price_amount' => 3000]); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant']); + Discount::factory()->for($store)->create(['code' => 'SUMMER20']); + $service = app(DiscountService::class); + + expect($service->validate('summer20', $store, $cart)->code)->toBe('SUMMER20'); + + foreach ([ + 'MISSING' => 'not_found', + 'EXPIRED' => 'expired', + 'FUTURE' => 'not_yet_active', + 'MAXED' => 'usage_limit_reached', + 'MINIMUM' => 'minimum_not_met', + ] as $code => $reason) { + match ($code) { + 'EXPIRED' => Discount::factory()->for($store)->expired()->create(['code' => $code]), + 'FUTURE' => Discount::factory()->for($store)->create(['code' => $code, 'starts_at' => now()->addDay()]), + 'MAXED' => Discount::factory()->for($store)->maxedOut()->create(['code' => $code]), + 'MINIMUM' => Discount::factory()->for($store)->create(['code' => $code, 'rules_json' => ['min_purchase_amount' => 5000]]), + default => null, + }; + + try { + $service->validate($code, $store, $cart); + $this->fail("{$code} should have been rejected."); + } catch (InvalidDiscountException $exception) { + expect($exception->reason)->toBe($reason); + } + } + }); + + it('selects the most specific shipping zone and filters unavailable rates', function () { + $store = createStoreContext()['store']; + $country = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE'], 'regions_json' => []]); + $region = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE'], 'regions_json' => ['BE']]); + ShippingRate::factory()->for($region, 'zone')->create(['name' => 'Berlin', 'config_json' => ['amount' => 499]]); + ShippingRate::factory()->for($region, 'zone')->inactive()->create(['name' => 'Hidden']); + ShippingRate::factory()->for($country, 'zone')->create(['name' => 'Germany', 'config_json' => ['amount' => 799]]); + $sku = makeSellableVariant($store); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant']); + $service = app(ShippingCalculator::class); + + expect($service->matchingZone($store, ['country_code' => 'DE', 'province_code' => 'BE'])->is($region))->toBeTrue() + ->and($service->getAvailableRates($store, ['country_code' => 'DE', 'province_code' => 'BE'], $cart))->toHaveCount(1) + ->and($service->matchingZone($store, ['country_code' => 'FR']))->toBeNull(); + }); + + it('calculates and snapshots discount shipping and per-line exclusive tax', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store, variantAttributes: ['price_amount' => 2500]); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant'], 2); + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(['config_json' => ['amount' => 499]]); + TaxSettings::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['rate_bps' => 1900, 'label' => 'VAT'], + ]); + Discount::factory()->for($store)->create(['code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10]); + $checkouts = app(CheckoutService::class); + $checkout = $checkouts->create($cart); + $checkout = $checkouts->setAddress($checkout, validCheckoutAddress()); + $checkout = $checkouts->setShippingMethod($checkout, $rate->id); + $checkout = $checkouts->applyDiscount($checkout, 'save10'); + $totals = $checkout->totals_json; + + expect($totals['subtotal'])->toBe(5000) + ->and($totals['discount'])->toBe(500) + ->and($totals['discounted_subtotal'])->toBe(4500) + ->and($totals['shipping'])->toBe(499) + ->and($totals['tax_total'])->toBe(950) + ->and($totals['total'])->toBe(5949) + ->and($totals['tax_lines'][0])->toMatchArray(['name' => 'VAT', 'rate' => 1900, 'amount' => 950]) + ->and($cart->lines()->first()->line_discount_amount)->toBe(500); + }); + + it('sets shipping to zero for digital carts and free-shipping discounts', function () { + $store = createStoreContext()['store']; + $digital = makeSellableVariant($store, variantAttributes: ['requires_shipping' => false, 'weight_g' => 0]); + ['cart' => $cart] = makeCartWithLine($store, $digital['variant']); + $checkout = app(CheckoutService::class)->create($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, validCheckoutAddress()); + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, null); + + expect($checkout->status->value)->toBe('shipping_selected') + ->and($checkout->shipping_method_id)->toBeNull() + ->and($checkout->totals_json['shipping'])->toBe(0); + + $physical = makeSellableVariant($store); + ['cart' => $physicalCart] = makeCartWithLine($store, $physical['variant']); + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(['config_json' => ['amount' => 999]]); + Discount::factory()->for($store)->freeShipping()->create(['code' => 'SHIPFREE']); + $physicalCheckout = app(CheckoutService::class)->create($physicalCart); + $physicalCheckout = app(CheckoutService::class)->setAddress($physicalCheckout, validCheckoutAddress()); + $physicalCheckout = app(CheckoutService::class)->setShippingMethod($physicalCheckout, $rate->id); + $physicalCheckout = app(CheckoutService::class)->applyDiscount($physicalCheckout, 'SHIPFREE'); + + expect($physicalCheckout->totals_json['shipping'])->toBe(0) + ->and($physicalCheckout->shipping_method_id)->toBe($rate->id); + }); +}); diff --git a/tests/Feature/Shop/CheckoutOrderLifecycleTest.php b/tests/Feature/Shop/CheckoutOrderLifecycleTest.php new file mode 100644 index 00000000..262ae7af --- /dev/null +++ b/tests/Feature/Shop/CheckoutOrderLifecycleTest.php @@ -0,0 +1,390 @@ +create($store); + + expect(fn () => app(CheckoutService::class)->create($empty)) + ->toThrow(InvalidCheckoutTransitionException::class); + + $sku = makeSellableVariant($store); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant']); + $started = app(CheckoutService::class)->create($cart); + + expect(fn () => app(CheckoutService::class)->completeCheckout($started)) + ->toThrow(InvalidCheckoutTransitionException::class); + }); + + it('validates address fields and remains started on failure', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant']); + $checkout = app(CheckoutService::class)->create($cart); + + try { + app(CheckoutService::class)->setAddress($checkout, [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Ada', 'last_name' => 'Lovelace', 'address1' => 'Street 1', + 'country_code' => 'DE', 'postal_code' => '10115', + ], + ]); + $this->fail('An address without a city was accepted.'); + } catch (ValidationException $exception) { + expect($exception->errors())->toHaveKey('city') + ->and($checkout->refresh()->status->value)->toBe('started'); + } + }); + + it('moves through address shipping and payment while reserving stock', function () { + $flow = checkoutReadyForPayment(); + + expect($flow['checkout']->status->value)->toBe('payment_selected') + ->and($flow['checkout']->shipping_method_id)->toBe($flow['rate']->id) + ->and($flow['checkout']->payment_method)->toBe(PaymentMethod::CreditCard) + ->and($flow['checkout']->expires_at->isFuture())->toBeTrue() + ->and($flow['variant']->inventoryItem->refresh()->quantity_reserved)->toBe(2) + ->and($flow['checkout']->totals_json)->toMatchArray([ + 'subtotal' => 5000, + 'shipping' => 499, + 'total' => 5499, + 'currency' => $flow['store']->default_currency, + ]); + }); + + it('rejects a shipping rate from a nonmatching zone', function () { + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant']); + $us = ShippingZone::factory()->for($store)->create(['countries_json' => ['US']]); + $usRate = ShippingRate::factory()->for($us, 'zone')->create(); + $checkout = app(CheckoutService::class)->create($cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, validCheckoutAddress('DE')); + + app(CheckoutService::class)->setShippingMethod($checkout, $usRate->id); + })->throws(ShippingUnavailableException::class); + + it('expires selected checkouts idempotently and releases reservations', function () { + Event::fake([CheckoutExpired::class]); + $flow = checkoutReadyForPayment(); + $flow['checkout']->update(['expires_at' => now()->subMinute()]); + + (new ExpireAbandonedCheckouts)->handle(app(CheckoutService::class)); + + expect($flow['checkout']->refresh()->status->value)->toBe('expired') + ->and($flow['variant']->inventoryItem->refresh()->quantity_reserved)->toBe(0); + Event::assertDispatched(CheckoutExpired::class, 1); + + app(CheckoutService::class)->expireCheckout($flow['checkout']); + Event::assertDispatched(CheckoutExpired::class, 1); + }); +}); + +describe('payment outcomes and atomic order creation', function () { + it('captures a credit card order with snapshots and commits inventory', function () { + Event::fake([OrderCreated::class, OrderPaid::class]); + $flow = checkoutReadyForPayment(); + + $order = app(CheckoutService::class)->completeCheckout($flow['checkout'], ['card_number' => '4242 4242 4242 4242']); + + expect($order->status->value)->toBe('paid') + ->and($order->financial_status->value)->toBe('paid') + ->and($order->payment_method)->toBe(PaymentMethod::CreditCard) + ->and($order->total_amount)->toBe(5499) + ->and($order->email)->toBe('buyer@example.test') + ->and($order->lines)->toHaveCount(1) + ->and($order->lines->first()->title_snapshot)->toContain('Physical Guide') + ->and($order->lines->first()->sku_snapshot)->toBe('PHYSICAL-GUIDE') + ->and($order->payments->first()->status->value)->toBe('captured') + ->and($order->payments->first()->method)->toBe(PaymentMethod::CreditCard) + ->and($order->payments->first()->provider_payment_id)->toStartWith('mock_') + ->and($flow['variant']->inventoryItem->refresh()->quantity_on_hand)->toBe(8) + ->and($flow['variant']->inventoryItem->quantity_reserved)->toBe(0) + ->and($flow['cart']->refresh()->status->value)->toBe('converted') + ->and($flow['checkout']->refresh()->status->value)->toBe('completed'); + + Event::assertDispatched(OrderCreated::class, fn ($event): bool => $event->order->is($order)); + Event::assertDispatched(OrderPaid::class, fn ($event): bool => $event->order->is($order)); + }); + + it('encrypts the mock PSP snapshot at rest', function () { + $flow = checkoutReadyForPayment(); + $order = app(CheckoutService::class)->completeCheckout($flow['checkout'], ['card_number' => '4242424242424242']); + $payment = $order->payments->first(); + $rawDatabaseValue = Payment::query()->whereKey($payment->id)->toBase()->value('raw_json_encrypted'); + + expect($rawDatabaseValue)->not->toContain('credit_card') + ->and($payment->raw_json_encrypted)->toMatchArray(['provider' => 'mock', 'method' => 'credit_card']); + }); + + it('is idempotent when a payment completion is submitted twice', function () { + $flow = checkoutReadyForPayment(); + $first = app(CheckoutService::class)->completeCheckout($flow['checkout'], ['card_number' => '4242424242424242']); + $second = app(CheckoutService::class)->completeCheckout($flow['checkout']->refresh(), ['card_number' => '4242424242424242']); + + expect($second->id)->toBe($first->id) + ->and(Order::withoutGlobalScopes()->where('store_id', $flow['store']->id)->count())->toBe(1) + ->and(Payment::query()->where('order_id', $first->id)->count())->toBe(1); + }); + + it('releases inventory and creates no order for each declined card', function (string $number, string $code) { + $flow = checkoutReadyForPayment(); + + try { + app(CheckoutService::class)->completeCheckout($flow['checkout'], ['card_number' => $number]); + $this->fail('A rejected mock card created an order.'); + } catch (PaymentFailedException $exception) { + expect($exception->errorCode)->toBe($code) + ->and($flow['variant']->inventoryItem->refresh()->quantity_reserved)->toBe(0) + ->and($flow['checkout']->refresh()->status->value)->toBe('shipping_selected') + ->and($flow['checkout']->payment_method)->toBeNull() + ->and(Order::withoutGlobalScopes()->where('store_id', $flow['store']->id)->count())->toBe(0); + } + })->with([ + 'declined' => ['4000 0000 0000 0002', 'card_declined'], + 'insufficient funds' => ['4000 0000 0000 9995', 'insufficient_funds'], + ]); + + it('captures PayPal through the same paid-order path', function () { + $flow = checkoutReadyForPayment(PaymentMethod::Paypal); + $order = app(CheckoutService::class)->completeCheckout($flow['checkout']); + + expect($order->payment_method)->toBe(PaymentMethod::Paypal) + ->and($order->financial_status->value)->toBe('paid') + ->and($order->payments->first()->status->value)->toBe('captured') + ->and($flow['variant']->inventoryItem->refresh()->quantity_on_hand)->toBe(8); + }); + + it('numbers orders sequentially and independently per store', function () { + $firstFlow = checkoutReadyForPayment(PaymentMethod::CreditCard, digital: true, quantity: 1); + $first = app(CheckoutService::class)->completeCheckout($firstFlow['checkout'], ['card_number' => '4242424242424242']); + + bindStore($firstFlow['store']); + $sku = makeSellableVariant($firstFlow['store'], variantAttributes: ['requires_shipping' => false, 'weight_g' => 0]); + ['cart' => $cart] = makeCartWithLine($firstFlow['store'], $sku['variant']); + $service = app(CheckoutService::class); + $checkout = $service->create($cart); + $checkout = $service->setAddress($checkout, validCheckoutAddress()); + $checkout = $service->setShippingMethod($checkout, null); + $checkout = $service->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $second = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']); + + $otherFlow = checkoutReadyForPayment(PaymentMethod::CreditCard, digital: true, quantity: 1); + $other = app(CheckoutService::class)->completeCheckout($otherFlow['checkout'], ['card_number' => '4242424242424242']); + + expect([$first->order_number, $second->order_number])->toBe(['#1001', '#1002']) + ->and($other->order_number)->toBe('#1001'); + }); + + it('links guest orders to a per-store customer and increments discount use once', function () { + $flow = checkoutReadyForPayment(); + Discount::factory()->for($flow['store'])->create(['code' => 'ORDER10', 'usage_count' => 5]); + $checkout = $flow['checkout']; + // Applying a code happens before payment selection, so return safely to shipping-selected for this fixture. + $flow['variant']->inventoryItem->update(['quantity_reserved' => 0]); + $checkout->update(['status' => 'shipping_selected', 'payment_method' => null]); + $checkout = app(CheckoutService::class)->applyDiscount($checkout, 'ORDER10'); + $checkout = app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']); + $same = app(CheckoutService::class)->completeCheckout($checkout->refresh(), ['card_number' => '4242424242424242']); + + expect($order->customer)->not->toBeNull() + ->and($order->customer->email)->toBe('buyer@example.test') + ->and($same->id)->toBe($order->id) + ->and(Discount::query()->where('code', 'ORDER10')->value('usage_count'))->toBe(6); + }); + + it('auto-fulfills an all-digital paid order', function () { + $flow = checkoutReadyForPayment(PaymentMethod::CreditCard, digital: true); + $order = app(CheckoutService::class)->completeCheckout($flow['checkout'], ['card_number' => '4242424242424242']); + + expect($order->refresh()->status->value)->toBe('fulfilled') + ->and($order->fulfillment_status->value)->toBe('fulfilled') + ->and($order->fulfillments)->toHaveCount(1) + ->and($order->fulfillments->first()->status->value)->toBe('delivered') + ->and($order->fulfillments->first()->lines->first()->quantity)->toBe(2); + }); +}); + +describe('bank transfers and unpaid cancellation', function () { + it('keeps stock reserved until an admin confirms bank transfer', function () { + Event::fake([OrderPaid::class]); + $flow = checkoutReadyForPayment(PaymentMethod::BankTransfer); + $order = app(CheckoutService::class)->completeCheckout($flow['checkout']); + + expect($order->status->value)->toBe('pending') + ->and($order->financial_status->value)->toBe('pending') + ->and($order->payments->first()->status->value)->toBe('pending') + ->and($flow['variant']->inventoryItem->refresh()->quantity_on_hand)->toBe(10) + ->and($flow['variant']->inventoryItem->quantity_reserved)->toBe(2); + + app(OrderService::class)->confirmBankTransfer($order); + + expect($order->refresh()->status->value)->toBe('paid') + ->and($order->financial_status->value)->toBe('paid') + ->and($order->payments()->first()->status->value)->toBe('captured') + ->and($flow['variant']->inventoryItem->refresh()->quantity_on_hand)->toBe(8) + ->and($flow['variant']->inventoryItem->quantity_reserved)->toBe(0); + Event::assertDispatched(OrderPaid::class, 1); + }); + + it('rejects confirmation for non-bank and already confirmed orders', function () { + $card = paidOrderFixture()['order']; + expect(fn () => app(OrderService::class)->confirmBankTransfer($card))->toThrow(DomainException::class); + + $flow = checkoutReadyForPayment(PaymentMethod::BankTransfer); + $bank = app(CheckoutService::class)->completeCheckout($flow['checkout']); + app(OrderService::class)->confirmBankTransfer($bank); + + expect(fn () => app(OrderService::class)->confirmBankTransfer($bank->refresh()))->toThrow(DomainException::class); + }); + + it('cancels only overdue pending transfers and releases their inventory', function () { + $oldFlow = checkoutReadyForPayment(PaymentMethod::BankTransfer); + $old = app(CheckoutService::class)->completeCheckout($oldFlow['checkout']); + $old->update(['placed_at' => now()->subDays(8)]); + + $recentFlow = checkoutReadyForPayment(PaymentMethod::BankTransfer); + $recent = app(CheckoutService::class)->completeCheckout($recentFlow['checkout']); + $recent->update(['placed_at' => now()->subDays(2)]); + + (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class)); + + expect($old->refresh()->status->value)->toBe('cancelled') + ->and($old->financial_status->value)->toBe('voided') + ->and($old->payments()->first()->status->value)->toBe('failed') + ->and($oldFlow['variant']->inventoryItem->refresh()->quantity_reserved)->toBe(0) + ->and($recent->refresh()->status->value)->toBe('pending') + ->and($recentFlow['variant']->inventoryItem->refresh()->quantity_reserved)->toBe(2); + }); + + it('auto-fulfills digital stock when bank payment is confirmed', function () { + $flow = checkoutReadyForPayment(PaymentMethod::BankTransfer, digital: true); + $order = app(CheckoutService::class)->completeCheckout($flow['checkout']); + + app(OrderService::class)->confirmBankTransfer($order); + + expect($order->refresh()->status->value)->toBe('fulfilled') + ->and($order->fulfillment_status->value)->toBe('fulfilled') + ->and($order->fulfillments()->first()->status->value)->toBe('delivered'); + }); +}); + +describe('refunds and fulfillment', function () { + it('processes partial then full refunds and records reasons', function () { + Event::fake([OrderRefunded::class]); + $fixture = paidOrderFixture(); + $service = app(RefundService::class); + + $partial = $service->create($fixture['order'], $fixture['payment'], 2000, 'Customer requested'); + expect($partial->status->value)->toBe('processed') + ->and($partial->reason)->toBe('Customer requested') + ->and($fixture['order']->refresh()->financial_status->value)->toBe('partially_refunded'); + + $full = $service->create($fixture['order'], $fixture['payment'], 3000, 'Remaining balance'); + expect($full->status->value)->toBe('processed') + ->and($fixture['order']->refresh()->financial_status->value)->toBe('refunded') + ->and($fixture['order']->status->value)->toBe('refunded') + ->and($fixture['payment']->refresh()->status->value)->toBe('refunded'); + Event::assertDispatched(OrderRefunded::class, 2); + }); + + it('rejects over-refunds and conditionally restocks line quantities', function () { + $fixture = paidOrderFixture(); + $service = app(RefundService::class); + + expect(fn () => $service->create($fixture['order'], $fixture['payment'], 6000)) + ->toThrow(DomainException::class); + + $before = $fixture['variant']->inventoryItem->quantity_on_hand; + $service->create($fixture['order'], $fixture['payment'], 2000, restock: true, lines: [$fixture['line']->id => 1]); + expect($fixture['variant']->inventoryItem->refresh()->quantity_on_hand)->toBe($before + 1); + + $service->create($fixture['order'], $fixture['payment'], 1000, restock: false, lines: [$fixture['line']->id => 1]); + expect($fixture['variant']->inventoryItem->refresh()->quantity_on_hand)->toBe($before + 1); + }); + + it('guards fulfillment by financial status', function (string $financialStatus, bool $allowed) { + $fixture = paidOrderFixture(); + $fixture['order']->update(['financial_status' => $financialStatus]); + $action = fn () => app(FulfillmentService::class)->create($fixture['order']->refresh(), [$fixture['line']->id => 1]); + + if ($allowed) { + expect($action())->toBeInstanceOf(Fulfillment::class); + } else { + expect($action)->toThrow(FulfillmentGuardException::class); + } + })->with([ + 'pending blocked' => ['pending', false], + 'authorized blocked' => ['authorized', false], + 'paid allowed' => ['paid', true], + 'partially refunded allowed' => ['partially_refunded', true], + 'refunded blocked' => ['refunded', false], + 'voided blocked' => ['voided', false], + ]); + + it('tracks partial and complete fulfillment without over-shipping', function () { + $fixture = paidOrderFixture(quantity: 3); + $service = app(FulfillmentService::class); + + $first = $service->create($fixture['order'], [$fixture['line']->id => 1]); + expect($fixture['order']->refresh()->fulfillment_status->value)->toBe('partial') + ->and($first->lines->first()->quantity)->toBe(1); + + expect(fn () => $service->create($fixture['order'], [$fixture['line']->id => 3])) + ->toThrow(ValidationException::class); + + $second = $service->create($fixture['order'], [$fixture['line']->id => 2]); + expect($fixture['order']->refresh()->fulfillment_status->value)->toBe('fulfilled') + ->and($fixture['order']->status->value)->toBe('fulfilled') + ->and($second->lines->first()->quantity)->toBe(2); + }); + + it('moves fulfillment through shipped and delivered with tracking', function () { + $fixture = paidOrderFixture(); + $service = app(FulfillmentService::class); + $fulfillment = $service->create($fixture['order'], [$fixture['line']->id => 2]); + + $service->markAsShipped($fulfillment, [ + 'tracking_company' => 'DHL', + 'tracking_number' => '123456', + 'tracking_url' => 'https://tracking.example/123456', + ]); + expect($fulfillment->refresh()->status->value)->toBe('shipped') + ->and($fulfillment->tracking_company)->toBe('DHL') + ->and($fulfillment->tracking_number)->toBe('123456') + ->and($fulfillment->shipped_at)->not->toBeNull(); + + $service->markAsDelivered($fulfillment); + expect($fulfillment->refresh()->status->value)->toBe('delivered') + ->and(fn () => $service->markAsDelivered($fulfillment))->toThrow(FulfillmentGuardException::class); + }); +}); diff --git a/tests/Feature/Shop/CommerceIntegrityTest.php b/tests/Feature/Shop/CommerceIntegrityTest.php new file mode 100644 index 00000000..8ce439bd --- /dev/null +++ b/tests/Feature/Shop/CommerceIntegrityTest.php @@ -0,0 +1,258 @@ + 10000, + 'requires_shipping' => false, + 'weight_g' => 0, + ]); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant']); + $code = Discount::factory()->for($store)->create(['code' => 'STACK10', 'value_type' => 'percent', 'value_amount' => 10]); + $fixed = Discount::factory()->for($store)->automatic()->fixed(1000)->create(); + $automaticPercent = Discount::factory()->for($store)->automatic()->create(['value_type' => 'percent', 'value_amount' => 10]); + TaxSettings::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['rate_bps' => 1900, 'label' => 'VAT'], + ]); + + $service = app(CheckoutService::class); + $checkout = $service->create($cart); + $checkout = $service->setAddress($checkout, validCheckoutAddress()); + $checkout = $service->setShippingMethod($checkout, null); + $checkout = $service->applyDiscount($checkout, 'STACK10'); + + expect($checkout->totals_json['discount'])->toBe(2800) + ->and($checkout->totals_json['tax_total'])->toBe(1368) + ->and($checkout->totals_json['total'])->toBe(8568) + ->and($checkout->totals_json['applied_discounts'])->toHaveCount(3) + ->and($checkout->tax_provider_snapshot_json)->toMatchArray([ + 'provider' => 'manual', + 'shipping_tax_amount' => 0, + 'shipping_tax_rate' => 1900, + ]) + ->and($checkout->tax_provider_snapshot_json['lines'][0])->toMatchArray([ + 'variant_id' => $sku['variant']->id, + 'tax_amount' => 1368, + 'rate' => 1900, + 'jurisdiction' => 'DE', + ]); + + $checkout = $service->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $order = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']); + $line = $order->lines->first(); + + expect($line->total_amount)->toBe(7200) + ->and($line->tax_lines_json)->toBe([['name' => 'VAT', 'rate' => 1900, 'amount' => 1368]]) + ->and(collect($line->discount_allocations_json)->pluck('discount_id')->all())->toBe([$code->id, $fixed->id, $automaticPercent->id]) + ->and(collect($line->discount_allocations_json)->sum('amount'))->toBe(2800) + ->and($code->refresh()->usage_count)->toBe(1) + ->and($fixed->refresh()->usage_count)->toBe(1) + ->and($automaticPercent->refresh()->usage_count)->toBe(1); +}); + +it('enforces once per customer and never increments usage on rejected reuse', function () { + $store = createStoreContext()['store']; + $customer = Customer::factory()->for($store)->create(); + $discount = Discount::factory()->for($store)->create([ + 'code' => 'ONCEONLY', + 'rules_json' => ['once_per_customer' => true], + ]); + $service = app(CheckoutService::class); + + $makeCheckout = function () use ($store, $customer, $service) { + $sku = makeSellableVariant($store, variantAttributes: ['requires_shipping' => false, 'weight_g' => 0]); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant']); + $cart->update(['customer_id' => $customer->id]); + $checkout = $service->create($cart); + $checkout = $service->setAddress($checkout, validCheckoutAddress()); + $checkout = $service->setShippingMethod($checkout, null); + + return [$checkout, $sku]; + }; + + [$first] = $makeCheckout(); + $first = $service->applyDiscount($first, 'ONCEONLY'); + $first = $service->selectPaymentMethod($first, PaymentMethod::CreditCard); + $service->completeCheckout($first, ['card_number' => '4242424242424242']); + + [$second] = $makeCheckout(); + expect(fn () => $service->applyDiscount($second, 'ONCEONLY')) + ->toThrow(InvalidDiscountException::class) + ->and($discount->refresh()->usage_count)->toBe(1); +}); + +it('requires refund line quantities for restocking and cannot restock a line twice beyond its quantity', function () { + $flow = checkoutReadyForPayment(PaymentMethod::CreditCard, digital: false, quantity: 2); + $order = app(CheckoutService::class)->completeCheckout($flow['checkout'], ['card_number' => '4242424242424242']); + $payment = $order->payments->first(); + $line = $order->lines->first(); + $inventory = $flow['variant']->inventoryItem->refresh(); + $before = $inventory->quantity_on_hand; + $refunds = app(RefundService::class); + + expect(fn () => $refunds->create($order, $payment, 1000, restock: true)) + ->toThrow(DomainException::class) + ->and($inventory->refresh()->quantity_on_hand)->toBe($before); + + $refunds->create($order, $payment, 1000, restock: true, lines: [$line->id => 1]); + $refunds->create($order, $payment, 1000, restock: true, lines: [$line->id => 1]); + + expect($inventory->refresh()->quantity_on_hand)->toBe($before + 2) + ->and(fn () => $refunds->create($order, $payment, 1000, restock: true, lines: [$line->id => 1])) + ->toThrow(DomainException::class) + ->and($inventory->refresh()->quantity_on_hand)->toBe($before + 2); +}); + +it('keeps exactly one default variant and rejects option matrices over one hundred combinations', function () { + $store = createStoreContext()['store']; + $service = app(ProductService::class); + $product = $service->create($store, [ + 'title' => 'Matrix Product', + 'options' => [ + ['name' => 'Size', 'values' => ['S', 'M']], + ['name' => 'Color', 'values' => ['Blue', 'Red']], + ], + ]); + + expect($product->variants)->toHaveCount(4) + ->and($product->variants->where('is_default', true))->toHaveCount(1) + ->and($product->variants->where('is_default', true)->first()->position)->toBe(0); + + $requestedDefault = $product->variants->last(); + $product = $service->update($product, [ + 'variants' => $product->variants->values()->map(fn ($variant): array => [ + 'id' => $variant->id, + 'price_amount' => $variant->price_amount, + 'is_default' => $variant->is($requestedDefault), + ])->all(), + ]); + expect($product->variants->where('is_default', true))->toHaveCount(1) + ->and($product->variants->firstWhere('is_default', true)->id)->toBe($requestedDefault->id); + + expect(fn () => $service->create($store, [ + 'title' => 'Too Many Variants', + 'options' => [ + ['name' => 'One', 'values' => range(1, 5)], + ['name' => 'Two', 'values' => range(1, 5)], + ['name' => 'Three', 'values' => range(1, 5)], + ], + ]))->toThrow(InvalidArgumentException::class) + ->and(Product::withoutGlobalScopes()->where('store_id', $store->id)->where('title', 'Too Many Variants')->exists())->toBeFalse(); +}); + +it('rejects cross-store collection product associations in services and Livewire', function () { + $contextA = createStoreContext(); + $foreign = createStoreContext(); + $foreignProduct = Product::factory()->for($foreign['store'])->create(); + $foreignCollection = Collection::factory()->for($foreign['store'])->create(); + bindStore($contextA['store']); + actingAsAdmin($contextA['user'], $contextA['store']); + + expect(fn () => app(ProductService::class)->create($contextA['store'], [ + 'title' => 'Scoped Product', + 'collections' => [$foreignCollection->id], + ]))->toThrow(InvalidArgumentException::class); + + Livewire::test(CollectionForm::class) + ->call('addProduct', $foreignProduct->id) + ->assertNotFound(); +}); + +it('supports documented incremental collection membership while rejecting foreign products', function () { + $context = createStoreContext(); + $foreign = createStoreContext(); + $product = Product::factory()->for($context['store'])->create(); + $foreignProduct = Product::factory()->for($foreign['store'])->create(); + $collection = Collection::factory()->for($context['store'])->create(); + $token = adminApiToken($context, ['write-collections']); + + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "collections/{$collection->id}"), [ + 'add_product_ids' => [$foreignProduct->id], + ])->assertUnprocessable(); + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "collections/{$collection->id}"), [ + 'add_product_ids' => [$product->id], + ])->assertOk()->assertJsonPath('data.products.0.id', $product->id); + $this->withToken($token)->putJson(adminStoreUrl($context['store'], "collections/{$collection->id}"), [ + 'remove_product_ids' => [$product->id], + ])->assertOk()->assertJsonCount(0, 'data.products'); +}); + +it('maps paid fulfilled and archived events without firing fulfilled webhooks for partial fulfillment', function () { + Notification::fake(); + Queue::fake(); + $fixture = paidOrderFixture(); + foreach (['order.paid', 'order.fulfilled', 'product.deleted'] as $eventType) { + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $fixture['store']->id, + 'event_type' => $eventType, + 'target_url' => 'https://hooks.example/'.$eventType, + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + } + + event(new OrderPaid($fixture['order'])); + expect(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'order.paid')->first()->deliveries)->toHaveCount(1); + + $fulfillments = app(FulfillmentService::class); + $partial = $fulfillments->create($fixture['order'], [$fixture['line']->id => 1]); + $fulfillments->markAsShipped($partial); + expect(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'order.fulfilled')->first()->deliveries)->toHaveCount(0); + + $fulfillments->create($fixture['order']->refresh(), [$fixture['line']->id => 1]); + expect(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'order.fulfilled')->first()->deliveries)->toHaveCount(1); + + app(ProductService::class)->transitionStatus($fixture['variant']->product, ProductStatus::Archived); + expect(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'product.deleted')->first()->deliveries)->toHaveCount(1); +}); + +it('persists checkout and notification settings in tenant store settings', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + + Livewire::test(CheckoutSettings::class) + ->set('allowGuestCheckout', false) + ->set('requirePhone', true) + ->set('checkoutExpiryHours', 48) + ->call('save') + ->assertHasNoErrors(); + Livewire::test(NotificationSettings::class) + ->set('refundConfirmation', false) + ->set('notifyStaffOfNewOrders', false) + ->call('save') + ->assertHasNoErrors(); + + $settings = StoreSettings::withoutGlobalScopes()->where('store_id', $context['store']->id)->value('settings_json'); + expect(data_get($settings, 'checkout.allow_guest_checkout'))->toBeFalse() + ->and(data_get($settings, 'checkout.require_phone'))->toBeTrue() + ->and(data_get($settings, 'checkout.expiry_hours'))->toBe(48) + ->and(data_get($settings, 'notifications.refund_confirmation'))->toBeFalse() + ->and(data_get($settings, 'notifications.staff_new_order'))->toBeFalse(); +}); diff --git a/tests/Feature/Shop/CustomerAccountsTest.php b/tests/Feature/Shop/CustomerAccountsTest.php new file mode 100644 index 00000000..d7ab368c --- /dev/null +++ b/tests/Feature/Shop/CustomerAccountsTest.php @@ -0,0 +1,225 @@ +register($store, [ + 'name' => 'Ada Buyer', + 'email' => 'ADA@EXAMPLE.TEST', + 'password' => 'correct-horse', + 'marketing_opt_in' => true, + ]); + + expect($customer->email)->toBe('ada@example.test') + ->and($customer->marketing_opt_in)->toBeTrue() + ->and(Hash::check('correct-horse', $customer->password_hash))->toBeTrue() + ->and($customer->password_hash)->not->toBe('correct-horse'); + }); + + it('rejects a duplicate account but permits the same email in another store', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $service = app(CustomerService::class); + $service->register($storeA, ['name' => 'A', 'email' => 'buyer@example.test', 'password' => 'password-one']); + $service->register($storeB, ['name' => 'B', 'email' => 'buyer@example.test', 'password' => 'password-two']); + + expect(Customer::withoutGlobalScopes()->where('email', 'buyer@example.test')->count())->toBe(2); + + $service->register($storeA, ['name' => 'Duplicate', 'email' => 'buyer@example.test', 'password' => 'password-three']); + })->throws(ValidationException::class); + + it('authenticates only against the bound store', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $customer = Customer::factory()->for($storeA)->create([ + 'email' => 'scoped@example.test', + 'password_hash' => Hash::make('right-password'), + ]); + + bindStore($storeB); + expect(Auth::guard('customer')->attempt(['email' => $customer->email, 'password' => 'right-password']))->toBeFalse(); + + bindStore($storeA); + expect(Auth::guard('customer')->attempt(['email' => $customer->email, 'password' => 'right-password']))->toBeTrue() + ->and(Auth::guard('customer')->id())->toBe($customer->id); + }); + + it('renders tenant auth pages and validates registration input', function () { + $context = createStoreContext(); + + $this->get("http://{$context['domain']->hostname}/account/login")->assertOk(); + $this->get("http://{$context['domain']->hostname}/account/register")->assertOk(); + + Livewire::test(Register::class) + ->set('name', 'Livewire Buyer') + ->set('email', 'not-an-email') + ->set('password', 'secret-pass') + ->set('passwordConfirmation', 'different-pass') + ->call('register') + ->assertHasErrors(['email', 'password']); + }); + + it('returns one generic credential error and applies the five-attempt limit', function () { + createStoreContext(); + + for ($attempt = 1; $attempt <= 5; $attempt++) { + Livewire::test(Login::class) + ->set('email', 'unknown@example.test') + ->set('password', 'wrong-password') + ->call('login') + ->assertHasErrors(['credentials']); + } + + Livewire::test(Login::class) + ->set('email', 'unknown@example.test') + ->set('password', 'wrong-password') + ->call('login') + ->assertHasErrors(['email']) + ->assertHasNoErrors(['credentials']); + }); +}); + +describe('tenant customer password reset', function () { + it('sends a reset token and resets only the matching store customer', function () { + Notification::fake(); + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $customerA = Customer::factory()->for($storeA)->create(['email' => 'same@example.test']); + $customerB = Customer::factory()->for($storeB)->create(['email' => 'same@example.test']); + bindStore($storeA); + $broker = app(CustomerPasswordBroker::class); + + expect($broker->sendResetLink(['email' => $customerA->email, 'store_id' => $storeA->id]))->toBe(Password::RESET_LINK_SENT); + + $token = null; + Notification::assertSentTo($customerA, ResetPassword::class, function (ResetPassword $notification) use (&$token): bool { + $token = $notification->token; + + return true; + }); + Notification::assertNotSentTo($customerB, ResetPassword::class); + expect($token)->toBeString()->not->toBeEmpty(); + + $status = $broker->reset([ + 'email' => $customerA->email, + 'store_id' => $storeA->id, + 'password' => 'changed-password', + 'password_confirmation' => 'changed-password', + 'token' => $token, + ], function (Customer $customer): void { + $customer->forceFill(['password_hash' => Hash::make('changed-password')])->save(); + }); + + expect($status)->toBe(Password::PASSWORD_RESET) + ->and(Hash::check('changed-password', $customerA->refresh()->password_hash))->toBeTrue() + ->and(Hash::check('changed-password', $customerB->refresh()->password_hash))->toBeFalse(); + }); + + it('does not accept a reset token in another store', function () { + Notification::fake(); + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $customerA = Customer::factory()->for($storeA)->create(['email' => 'shared@example.test']); + Customer::factory()->for($storeB)->create(['email' => 'shared@example.test']); + bindStore($storeA); + $broker = app(CustomerPasswordBroker::class); + $broker->sendResetLink(['email' => $customerA->email, 'store_id' => $storeA->id]); + $token = null; + Notification::assertSentTo($customerA, ResetPassword::class, function (ResetPassword $notification) use (&$token): bool { + $token = $notification->token; + + return true; + }); + + bindStore($storeB); + $status = $broker->reset([ + 'email' => 'shared@example.test', + 'store_id' => $storeB->id, + 'password' => 'changed-password', + 'password_confirmation' => 'changed-password', + 'token' => $token, + ], fn () => null); + + expect($status)->toBe(Password::INVALID_TOKEN); + }); +}); + +describe('customer-owned data', function () { + it('creates updates defaults and deletes only owned addresses', function () { + $store = createStoreContext()['store']; + $customer = Customer::factory()->for($store)->create(); + actingAsCustomer($customer); + $component = Livewire::test(AddressBook::class); + + $component->call('createAddress') + ->set('label', 'Home') + ->set('address.first_name', 'Ada') + ->set('address.last_name', 'Lovelace') + ->set('address.address1', 'Home Street 1') + ->set('address.city', 'Berlin') + ->set('address.postal_code', '10115') + ->set('address.country', 'DE') + ->call('saveAddress') + ->assertHasNoErrors(); + + $home = $customer->addresses()->firstOrFail(); + expect($home->is_default)->toBeTrue(); + + $component->call('createAddress') + ->set('label', 'Work') + ->set('address.first_name', 'Ada') + ->set('address.last_name', 'Lovelace') + ->set('address.address1', 'Work Street 2') + ->set('address.city', 'Berlin') + ->set('address.postal_code', '10117') + ->set('address.country', 'DE') + ->call('saveAddress'); + $work = $customer->addresses()->where('label', 'Work')->firstOrFail(); + + $component->call('setDefault', $work->id); + expect($work->refresh()->is_default)->toBeTrue() + ->and($home->refresh()->is_default)->toBeFalse(); + + $component->call('editAddress', $work->id) + ->set('address.city', 'Potsdam') + ->call('saveAddress'); + expect($work->refresh()->address_json['city'])->toBe('Potsdam'); + + $component->call('deleteAddress', $work->id); + expect(CustomerAddress::query()->find($work->id))->toBeNull() + ->and($home->refresh()->is_default)->toBeTrue(); + }); + + it('rejects another customers address and order identifiers', function () { + $store = createStoreContext()['store']; + $customerA = Customer::factory()->for($store)->create(); + $customerB = Customer::factory()->for($store)->create(); + $foreignAddress = CustomerAddress::factory()->for($customerB)->create(); + $foreignOrder = Order::factory()->for($store)->for($customerB)->create(['order_number' => '#FOREIGN']); + actingAsCustomer($customerA); + + expect(fn () => Livewire::test(AddressBook::class)->call('editAddress', $foreignAddress->id)) + ->toThrow(ModelNotFoundException::class) + ->and(fn () => Livewire::test(CustomerOrderShow::class, ['orderNumber' => $foreignOrder->order_number])) + ->toThrow(ModelNotFoundException::class); + }); +}); diff --git a/tests/Feature/Shop/ReliabilitySecurityTest.php b/tests/Feature/Shop/ReliabilitySecurityTest.php new file mode 100644 index 00000000..2c33b1b3 --- /dev/null +++ b/tests/Feature/Shop/ReliabilitySecurityTest.php @@ -0,0 +1,303 @@ +instance(DnsResolver::class, new class implements DnsResolver + { + public function resolve(string $hostname): array + { + return match ($hostname) { + 'safe.example.com' => ['8.8.8.8', '2606:4700:4700::1111'], + 'mixed.example.com' => ['8.8.8.8', '169.254.169.254'], + default => [], + }; + } + }); + $validator = app(WebhookTargetValidator::class); + + expect($validator->validate('https://safe.example.com/hooks')->url)->toBe('https://safe.example.com/hooks'); + + foreach ([ + 'http://safe.example.com/hooks', + 'https://localhost/hooks', + 'https://127.0.0.1/hooks', + 'https://169.254.169.254/latest/meta-data', + 'https://192.0.2.1/hooks', + 'https://mixed.example.com/hooks', + 'https://unresolved.example.com/hooks', + 'https://user:pass@safe.example.com/hooks', + ] as $url) { + expect(fn () => $validator->validate($url))->toThrow(UnsafeWebhookTargetException::class); + } + }); + + it('rejects unsafe targets in the admin save flow', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + + Livewire::test(Developers::class) + ->set('webhookEventType', 'order.created') + ->set('webhookUrl', 'https://127.0.0.1/internal') + ->call('saveWebhook') + ->assertHasErrors('webhookUrl'); + + expect(WebhookSubscription::withoutGlobalScopes()->count())->toBe(0); + }); + + it('revalidates before delivery and never sends to a private address', function () { + Http::fake(); + $store = createStoreContext()['store']; + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://127.0.0.1/internal', + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + $delivery = $subscription->deliveries()->create([ + 'event_id' => (string) Str::uuid(), 'attempt_count' => 0, 'status' => 'pending', + ]); + + expect(fn () => (new DeliverWebhook($delivery, 'order.created', ['order_id' => 1])) + ->handle(app(WebhookService::class)))->toThrow(UnsafeWebhookTargetException::class); + expect($delivery->refresh()->status->value)->toBe('failed') + ->and($delivery->attempt_count)->toBe(1); + Http::assertNothingSent(); + }); + + it('does not follow webhook redirects', function () { + app()->instance(DnsResolver::class, new class implements DnsResolver + { + public function resolve(string $hostname): array + { + return ['8.8.8.8']; + } + }); + Http::fake(['https://hooks.example.com/*' => Http::response('', 302, ['Location' => 'https://127.0.0.1/private'])]); + $store = createStoreContext()['store']; + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example.com/orders', + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + $delivery = $subscription->deliveries()->create([ + 'event_id' => (string) Str::uuid(), 'attempt_count' => 0, 'status' => 'pending', + ]); + + expect(fn () => (new DeliverWebhook($delivery, 'order.created', [])) + ->handle(app(WebhookService::class)))->toThrow(RequestException::class); + expect($delivery->refresh()->response_code)->toBe(302) + ->and($delivery->status->value)->toBe('failed'); + Http::assertSentCount(1); + }); +}); + +describe('post-commit outbound isolation', function () { + it('runs callbacks only after commit and swallows outbound failures', function () { + Log::spy(); + $originalEnvironment = app()->environment(); + $originalConnection = config('database.default'); + app()->detectEnvironment(fn (): string => 'local'); + config([ + 'database.default' => 'outbound_testing', + 'database.connections.outbound_testing' => [ + 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', + ], + ]); + DB::purge('outbound_testing'); + $ran = false; + + try { + DB::beginTransaction(); + (new OutboundDispatcher)->afterCommit(function () use (&$ran): void { + $ran = true; + throw new RuntimeException('mail transport unavailable'); + }); + expect($ran)->toBeFalse(); + DB::commit(); + expect($ran)->toBeTrue(); + } finally { + DB::purge('outbound_testing'); + config(['database.default' => $originalConnection]); + app()->detectEnvironment(fn (): string => $originalEnvironment); + } + }); + + it('isolates a synchronous webhook delivery failure from its caller', function () { + config(['queue.default' => 'sync']); + Http::fake(); + $store = createStoreContext()['store']; + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://127.0.0.1/internal', + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + + expect(fn () => app(WebhookService::class)->dispatch($store, 'order.created', ['order_id' => 1])) + ->not->toThrow(Throwable::class); + expect($store->refresh())->not->toBeNull() + ->and($store->webhookSubscriptions()->firstOrFail()->deliveries()->firstOrFail()->status->value)->toBe('failed'); + Http::assertNothingSent(); + }); +}); + +describe('optional customer lifecycle notifications', function () { + it('suppresses fulfillment and refund emails while retaining their webhooks', function () { + Notification::fake(); + Queue::fake(); + $fixture = paidOrderFixture(quantity: 2); + $store = $fixture['store']; + $user = $store->users()->firstOrFail(); + $token = $user->createToken('notification-choice-test', [ + 'write-orders', + 'store:'.$store->id, + ])->plainTextToken; + + foreach (['fulfillment.created', 'order.updated', 'order.refunded', 'refund.created'] as $eventType) { + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => $eventType, + 'target_url' => 'https://hooks.example/'.$eventType, + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + } + + $base = "/api/admin/v1/stores/{$store->id}/orders/{$fixture['order']->id}"; + $this->withToken($token)->postJson($base.'/fulfillments', [ + 'lines' => [$fixture['line']->id => 2], + 'tracking_company' => 'DHL', + 'tracking_number' => 'NO-MAIL-1', + 'notify_customer' => false, + ])->assertCreated(); + + Notification::assertNothingSent(); + expect(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'fulfillment.created')->firstOrFail()->deliveries)->toHaveCount(1) + ->and(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'order.updated')->firstOrFail()->deliveries->count())->toBeGreaterThanOrEqual(1); + + $this->withToken($token)->postJson($base.'/refunds', [ + 'amount' => 1000, + 'reason' => 'No customer email requested', + 'notify_customer' => false, + ])->assertCreated(); + + Notification::assertNothingSent(); + expect(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'order.refunded')->firstOrFail()->deliveries)->toHaveCount(1) + ->and(WebhookSubscription::withoutGlobalScopes()->where('event_type', 'refund.created')->firstOrFail()->deliveries)->toHaveCount(1); + }); +}); + +describe('tenant context cleanup', function () { + it('restores the previous tenant after each recurring cleanup job', function () { + $previous = createStoreContext()['store']; + $tenant = createStoreContext()['store']; + + $expiredCart = Cart::factory()->for($tenant)->create(); + Checkout::factory()->for($tenant)->for($expiredCart)->create([ + 'status' => 'started', 'expires_at' => now()->subHour(), + ]); + $staleCart = Cart::factory()->for($tenant)->create(['updated_at' => now()->subDays(15)]); + Order::factory()->pendingBankTransfer()->for($tenant)->create([ + 'customer_id' => null, 'placed_at' => now()->subDays(8), + ]); + bindStore($previous); + + (new ExpireAbandonedCheckouts)->handle(app(CheckoutService::class)); + expect(app('current_store')->is($previous))->toBeTrue(); + (new CleanupAbandonedCarts)->handle(app(CheckoutService::class)); + expect(app('current_store')->is($previous))->toBeTrue() + ->and($staleCart->refresh()->status->value)->toBe('abandoned'); + (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class)); + expect(app('current_store')->is($previous))->toBeTrue(); + }); + + it('restores or forgets tenant state even when work throws', function () { + $first = createStoreContext()['store']; + $second = createStoreContext()['store']; + $probe = new class + { + use RestoresCurrentStore; + + public function fail(mixed $store): void + { + $this->restoringCurrentStore(function () use ($store): void { + app()->instance('current_store', $store); + throw new RuntimeException('failed work'); + }); + } + }; + bindStore($first); + + expect(fn () => $probe->fail($second))->toThrow(RuntimeException::class, 'failed work'); + expect(app('current_store')->is($first))->toBeTrue(); + app()->forgetInstance('current_store'); + expect(fn () => $probe->fail($second))->toThrow(RuntimeException::class, 'failed work'); + expect(app()->bound('current_store'))->toBeFalse(); + }); +}); + +describe('order status links', function () { + it('generates a reusable tenant URL accepted by the status API and notification', function () { + $context = createStoreContext(); + $order = Order::factory()->for($context['store'])->create([ + 'customer_id' => null, 'order_number' => '#LINK1001', 'email' => 'guest@example.test', + ]); + $links = app(OrderStatusLink::class); + $url = $links->url($order); + + expect($links->verify($order, $links->token($order)))->toBeTrue() + ->and($links->verify($order, 'wrong'))->toBeFalse() + ->and($url)->toStartWith('http://'.$context['domain']->hostname.'/api/storefront/v1/orders/%23LINK1001?token='); + $this->getJson($url)->assertOk()->assertJsonPath('order_number', '#LINK1001'); + expect((new OrderLifecycleNotification($order, 'confirmed'))->toMail((object) [])->actionUrl)->toBe($url); + }); + + it('exposes the signed status URL on checkout confirmation', function () { + Queue::fake(); + $context = createStoreContext(); + $cart = Cart::factory()->for($context['store'])->create(); + $order = Order::factory()->for($context['store'])->create(['customer_id' => null]); + $checkout = Checkout::factory()->completed()->for($context['store'])->for($cart)->create([ + 'email' => $order->email, 'totals_json' => ['order_id' => $order->id], + ]); + session(['checkout_access.'.$checkout->id => true]); + $url = app(OrderStatusLink::class)->url($order); + + Livewire::test(Confirmation::class, ['checkoutId' => $checkout->id]) + ->assertSet('orderStatusUrl', $url) + ->assertSee('Track order'); + }); +}); diff --git a/tests/Feature/Shop/SecurityAcceptanceTest.php b/tests/Feature/Shop/SecurityAcceptanceTest.php new file mode 100644 index 00000000..a69413b8 --- /dev/null +++ b/tests/Feature/Shop/SecurityAcceptanceTest.php @@ -0,0 +1,99 @@ +Safe copy

      + +Unsafe link +Safe link +bad +Safe photo + +HTML; + + $product = Product::factory()->for($context['store'])->create([ + 'description_html' => $malicious, + ]); + $page = Page::factory()->for($context['store'])->create([ + 'body_html' => $malicious, + ]); + + foreach ([$product->getRawOriginal('description_html'), $page->getRawOriginal('body_html')] as $persisted) { + expect($persisted) + ->toContain('

      Safe copy

      ') + ->toContain('Safe link') + ->toContain('Safe photo') + ->not->toContain('script') + ->not->toContain('iframe') + ->not->toContain('javascript') + ->not->toContain('data:image') + ->not->toContain('onclick') + ->not->toContain('onerror') + ->not->toContain('style=') + ->not->toContain('target=') + ->not->toContain('rel=') + ->not->toContain('width='); + } + + $product->update(['description_html' => '

      Updated

      ']); + $page->update(['body_html' => '
      Quoted
      ']); + + expect($product->refresh()->getRawOriginal('description_html'))->toBe('

      Updated

      ') + ->and($page->refresh()->getRawOriginal('body_html'))->toBe('
      Quoted
      '); + }); +}); + +describe('theme preview isolation', function () { + it('only exposes a draft preview to an authorized admin of that storefront tenant', function () { + $storeA = createStoreContext(); + $published = Theme::factory()->for($storeA['store'])->create([ + 'name' => 'Published A', + 'status' => 'published', + ]); + $published->settings()->create(['settings_json' => [ + 'colors' => ['primary' => '#112233'], + ]]); + $draft = Theme::factory()->draft()->for($storeA['store'])->create(['name' => 'Draft A']); + $draft->settings()->create(['settings_json' => [ + 'colors' => ['primary' => '#abcdef'], + ]]); + + $storeB = createStoreContext(); + $foreignDraft = Theme::factory()->draft()->for($storeB['store'])->create(['name' => 'Draft B']); + $foreignDraft->settings()->create(['settings_json' => [ + 'colors' => ['primary' => '#fedcba'], + ]]); + + $previewUrl = "http://{$storeA['domain']->hostname}/?theme_preview={$draft->id}"; + + $this->withSession(["theme_preview.{$draft->id}" => ['colors' => ['primary' => '#ff0000']]]) + ->get($previewUrl) + ->assertOk() + ->assertSee('--storefront-primary: #112233', false) + ->assertDontSee('#abcdef', false) + ->assertDontSee('#ff0000', false); + + $this->actingAs($storeB['user'], 'web') + ->get($previewUrl) + ->assertOk() + ->assertSee('--storefront-primary: #112233', false) + ->assertDontSee('#abcdef', false); + + $this->actingAs($storeA['user'], 'web') + ->get($previewUrl) + ->assertOk() + ->assertSee('--storefront-primary: #abcdef', false) + ->assertDontSee('--storefront-primary: #112233', false); + + $this->get("http://{$storeA['domain']->hostname}/?theme_preview={$foreignDraft->id}") + ->assertOk() + ->assertSee('--storefront-primary: #112233', false) + ->assertDontSee('#fedcba', false); + }); +}); diff --git a/tests/Feature/Shop/SecurityHardeningTest.php b/tests/Feature/Shop/SecurityHardeningTest.php new file mode 100644 index 00000000..f091a558 --- /dev/null +++ b/tests/Feature/Shop/SecurityHardeningTest.php @@ -0,0 +1,161 @@ +disabled()->create(); + + $this->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertSessionHasErrors('email'); + $this->assertGuest('web'); + + $this->actingAs($user)->get('/dashboard')->assertRedirect('/login'); + $this->assertGuest('web'); + }); + + it('rejects and revokes a disabled users Sanctum token', function () { + $context = createStoreContext(); + $token = $context['user']->createToken('disabled-user', [ + 'store:'.$context['store']->id, + ]); + $context['user']->update(['status' => 'disabled']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/admin/v1/stores/'.$context['store']->id.'/me') + ->assertUnauthorized(); + + expect(DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->exists())->toBeFalse(); + }); + + it('builds reset links from the canonical application URL regardless of Host', function () { + Notification::fake(); + config(['app.url' => 'https://canonical.shop.test']); + request()->headers->set('host', 'attacker.example'); + $user = User::factory()->create(); + + expect(Password::broker('users')->sendResetLink(['email' => $user->email])) + ->toBe(Password::RESET_LINK_SENT); + + Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($user): bool { + $url = $notification->toMail($user)->actionUrl; + + return str_starts_with($url, 'https://canonical.shop.test/admin/reset-password/') + && ! str_contains($url, 'attacker.example'); + }); + }); +}); + +describe('customer identity hardening', function () { + it('creates a fresh account without claiming a guest customer or historical orders', function () { + $store = createStoreContext()['store']; + $guest = Customer::factory()->guest()->for($store)->create(['email' => 'buyer@example.test']); + $historical = Order::factory()->for($store)->for($guest)->create(['email' => 'buyer@example.test']); + + $account = app(CustomerService::class)->register($store, [ + 'name' => 'Buyer', + 'email' => 'buyer@example.test', + 'password' => 'secure-password', + ]); + + expect($account->id)->not->toBe($guest->id) + ->and($account->email)->toBe('buyer@example.test') + ->and($guest->refresh()->email)->toEndWith('@unclaimed.invalid') + ->and($historical->refresh()->customer_id)->toBe($guest->id) + ->and($account->orders()->count())->toBe(0); + }); + + it('does not restore a customer session or remember token in another store', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $customer = Customer::factory()->for($storeA)->create(); + bindStore($storeB); + + $provider = Auth::createUserProvider('customers'); + + expect($provider?->retrieveById($customer->id))->toBeNull() + ->and($provider?->retrieveByToken($customer->id, 'remember-me'))->toBeNull(); + }); +}); + +describe('untrusted persisted content hardening', function () { + it('permits relative and HTTP links but drops executable URL schemes', function () { + $store = createStoreContext()['store']; + $menu = NavigationMenu::factory()->for($store)->create(); + $unsafeItem = NavigationItem::factory()->for($menu, 'menu')->create(['url' => 'javascript:alert(1)']); + $safeItem = NavigationItem::factory()->for($menu, 'menu')->create(['url' => '/collections/new']); + $theme = Theme::factory()->for($store)->create(); + $settings = $theme->settings()->create(['settings_json' => [ + 'announcement' => ['url' => 'javascript:alert(1)'], + 'home' => ['hero' => ['cta_url' => 'https://example.test/sale']], + ]]); + $order = Order::factory()->for($store)->create(['customer_id' => null]); + $fulfillment = Fulfillment::factory()->for($order)->create(['tracking_url' => 'data:text/html,pwned']); + + expect($unsafeItem->url)->toBeNull() + ->and(DB::table('navigation_items')->where('id', $unsafeItem->id)->value('url'))->toBeNull() + ->and($safeItem->url)->toBe('/collections/new') + ->and(data_get($settings->settings_json, 'announcement.url'))->toBeNull() + ->and(data_get($settings->settings_json, 'home.hero.cta_url'))->toBe('https://example.test/sale') + ->and($fulfillment->tracking_url)->toBeNull() + ->and(DB::table('fulfillments')->where('id', $fulfillment->id)->value('tracking_url'))->toBeNull(); + }); + + it('never serializes encrypted provider payment payloads', function () { + $payment = Payment::factory()->create(['raw_json_encrypted' => ['secret' => 'provider-data']]); + + expect($payment->toArray())->not->toHaveKey('raw_json_encrypted') + ->and($payment->toJson())->not->toContain('provider-data'); + }); +}); + +it('resolves checkout confirmation only from the immutable order id snapshot', function () { + $store = createStoreContext()['store']; + $cart = Cart::factory()->for($store)->create(); + $expected = Order::factory()->for($store)->create(['customer_id' => null, 'email' => 'guest@example.test']); + $other = Order::factory()->for($store)->create(['customer_id' => null, 'email' => 'guest@example.test']); + $checkout = Checkout::factory()->completed()->for($store)->for($cart)->create([ + 'email' => 'guest@example.test', + 'totals_json' => ['order_id' => $expected->id], + ]); + session([ + 'checkout_access.'.$checkout->id => true, + 'last_order_id' => $other->id, + ]); + + Livewire::test(Confirmation::class, ['checkoutId' => $checkout->id]) + ->assertSet('order.id', $expected->id); +}); + +it('blocks Livewire mutations after a store is suspended', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $context['store']->update(['status' => 'suspended']); + bindStore($context['store']->refresh()); + app()->instance('request', Request::create('/livewire/update', 'POST')); + + expect(fn () => (new AdminSettings)->boot()) + ->toThrow(HttpException::class, 'This store is suspended.'); +}); diff --git a/tests/Feature/Shop/SideEffectsSearchAnalyticsJobsTest.php b/tests/Feature/Shop/SideEffectsSearchAnalyticsJobsTest.php new file mode 100644 index 00000000..85dca711 --- /dev/null +++ b/tests/Feature/Shop/SideEffectsSearchAnalyticsJobsTest.php @@ -0,0 +1,451 @@ +instance(DnsResolver::class, new class implements DnsResolver + { + public function resolve(string $hostname): array + { + return ['8.8.8.8']; + } + }); +}); + +describe('search indexing and discovery', function () { + it('finds active published products by prefix and isolates tenants', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + Product::factory()->for($storeA)->create([ + 'title' => 'Blue Cotton T-Shirt', + 'handle' => 'blue-cotton-shirt', + 'description_html' => '

      Organic everyday essential

      ', + 'vendor' => 'Acme Basics', + ]); + Product::factory()->for($storeA)->create(['title' => 'Red Wool Sweater', 'handle' => 'red-wool-sweater']); + Product::factory()->for($storeA)->draft()->create(['title' => 'Cotton Draft', 'handle' => 'cotton-draft']); + Product::factory()->for($storeA)->archived()->create(['title' => 'Cotton Archive', 'handle' => 'cotton-archive']); + Product::factory()->for($storeB)->create(['title' => 'Cotton From Other Store', 'handle' => 'other-cotton']); + bindStore($storeA); + + $results = app(SearchService::class)->search($storeA, 'cott'); + + expect($results->total())->toBe(1) + ->and($results->items()[0]->title)->toBe('Blue Cotton T-Shirt') + ->and(SearchQuery::query()->latest('id')->first()->query)->toBe('cott') + ->and(SearchQuery::query()->latest('id')->first()->results_count)->toBe(1); + }); + + it('updates and removes FTS records through product observation', function () { + $store = createStoreContext()['store']; + $product = Product::factory()->for($store)->create(['title' => 'Original Search Phrase', 'handle' => 'search-observer']); + $search = app(SearchService::class); + + expect($search->search($store, 'Original')->total())->toBe(1); + $product->update(['title' => 'Replacement Discovery Phrase']); + expect($search->search($store, 'Original')->total())->toBe(0) + ->and($search->search($store, 'Replace')->total())->toBe(1); + $product->delete(); + expect($search->search($store, 'Replace')->total())->toBe(0); + }); + + it('paginates results and limits autocomplete suggestions', function () { + $store = createStoreContext()['store']; + foreach (range(1, 25) as $number) { + Product::factory()->for($store)->create([ + 'title' => "Summer Item {$number}", + 'handle' => "summer-item-{$number}", + ]); + } + $search = app(SearchService::class); + LengthAwarePaginator::currentPageResolver(fn (): int => 2); + $page = $search->search($store, 'Summer', perPage: 12); + LengthAwarePaginator::currentPageResolver(fn (): int => 1); + $suggestions = $search->autocomplete($store, 'Sum', 5); + + expect($page->total())->toBe(25) + ->and($page->currentPage())->toBe(2) + ->and($page->items())->toHaveCount(12) + ->and($suggestions)->toHaveCount(5); + }); + + it('sanitizes FTS operators instead of exposing query syntax errors', function () { + $store = createStoreContext()['store']; + Product::factory()->for($store)->create(['title' => 'Safe Cotton Product', 'handle' => 'safe-cotton']); + + $results = app(SearchService::class)->search($store, 'cotton" OR *'); + + expect($results)->toBeInstanceOf(LengthAwarePaginator::class); + }); +}); + +describe('analytics ingestion and aggregation', function () { + it('deduplicates client events inside a store but not across stores', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $service = app(AnalyticsService::class); + $first = $service->track($storeA, 'page_view', ['path' => '/'], 'session-a', clientEventId: 'client-1'); + $duplicate = $service->track($storeA, 'page_view', ['path' => '/again'], 'session-b', clientEventId: 'client-1'); + $other = $service->track($storeB, 'page_view', ['path' => '/'], 'session-c', clientEventId: 'client-1'); + + expect($duplicate->id)->toBe($first->id) + ->and($other->id)->not->toBe($first->id) + ->and(AnalyticsEvent::withoutGlobalScopes()->where('client_event_id', 'client-1')->count())->toBe(2); + }); + + it('rejects unsupported event types', function () { + $store = createStoreContext()['store']; + + app(AnalyticsService::class)->track($store, 'execute_script'); + })->throws(InvalidArgumentException::class); + + it('aggregates visits conversions revenue and AOV idempotently', function () { + $store = createStoreContext()['store']; + $date = CarbonImmutable::parse('2026-07-10 12:00:00', 'UTC'); + $analytics = app(AnalyticsService::class); + foreach (['visitor-a', 'visitor-a', 'visitor-b'] as $index => $session) { + $analytics->track($store, 'page_view', [], $session, clientEventId: "page-{$index}", occurredAt: $date); + } + foreach (range(1, 3) as $index) { + $analytics->track($store, 'add_to_cart', [], 'visitor-a', clientEventId: "cart-{$index}", occurredAt: $date); + } + foreach ([1000, 2000, 3000] as $index => $total) { + $analytics->track($store, 'checkout_completed', ['total_amount' => $total], 'buyer', clientEventId: "order-{$index}", occurredAt: $date); + } + $analytics->track($store, 'checkout_started', [], 'buyer', clientEventId: 'checkout-start', occurredAt: $date); + + $first = $analytics->aggregate($store, '2026-07-10'); + $second = $analytics->aggregate($store, '2026-07-10'); + + expect($first->orders_count)->toBe(3) + ->and($first->revenue_amount)->toBe(6000) + ->and($first->aov_amount)->toBe(2000) + ->and($first->visits_count)->toBe(2) + ->and($first->add_to_cart_count)->toBe(3) + ->and($first->checkout_started_count)->toBe(1) + ->and($second->getAttributes())->toMatchArray($first->getAttributes()) + ->and(AnalyticsDaily::withoutGlobalScopes()->where('store_id', $store->id)->count())->toBe(1); + }); + + it('aggregates every active store through the scheduled job', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $service = app(AnalyticsService::class); + $date = now()->subDay()->startOfDay()->addHour(); + $service->track($storeA, 'checkout_completed', ['total_amount' => 1000], 'a', clientEventId: 'a-order', occurredAt: $date); + $service->track($storeB, 'checkout_completed', ['total_amount' => 2500], 'b', clientEventId: 'b-order', occurredAt: $date); + + (new AggregateAnalytics($date->toDateString()))->handle($service); + + expect(AnalyticsDaily::withoutGlobalScopes()->where('date', $date->toDateString())->count())->toBe(2) + ->and(AnalyticsDaily::withoutGlobalScopes()->where('store_id', $storeA->id)->value('revenue_amount'))->toBe(1000) + ->and(AnalyticsDaily::withoutGlobalScopes()->where('store_id', $storeB->id)->value('revenue_amount'))->toBe(2500); + }); +}); + +describe('webhook dispatch delivery and circuit breaking', function () { + it('queues one delivery for each matching active subscription', function () { + Queue::fake(); + $store = createStoreContext()['store']; + $matching = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example/orders', + 'signing_secret_encrypted' => 'top-secret', + 'status' => 'active', + ]); + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'product.created', + 'target_url' => 'https://hooks.example/products', + 'signing_secret_encrypted' => 'top-secret', + 'status' => 'active', + ]); + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example/paused', + 'signing_secret_encrypted' => 'top-secret', + 'status' => 'paused', + ]); + + app(WebhookService::class)->dispatch($store, 'order.created', ['order_id' => 42]); + + expect($matching->deliveries)->toHaveCount(1) + ->and($matching->deliveries->first()->status->value)->toBe('pending') + ->and($matching->deliveries->first()->attempt_count)->toBe(0); + Queue::assertPushed(DeliverWebhook::class, 1); + Queue::assertPushed(DeliverWebhook::class, fn (DeliverWebhook $job): bool => $job->eventType === 'order.created' && $job->payload === ['order_id' => 42]); + }); + + it('posts signed JSON and records a successful response', function () { + Http::fake(['https://hooks.example/*' => Http::response('accepted', 202)]); + $store = createStoreContext()['store']; + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example/orders', + 'signing_secret_encrypted' => 'test-secret', + 'status' => 'active', + ]); + $delivery = $subscription->deliveries()->create(['event_id' => (string) Str::uuid(), 'attempt_count' => 0, 'status' => 'pending']); + $payload = ['order_id' => 42, 'total_amount' => 5000]; + + (new DeliverWebhook($delivery, 'order.created', $payload))->handle(app(WebhookService::class)); + + expect($delivery->refresh()->status->value)->toBe('success') + ->and($delivery->attempt_count)->toBe(1) + ->and($delivery->response_code)->toBe(202) + ->and($delivery->response_body_snippet)->toBe('accepted'); + Http::assertSent(function ($request) use ($delivery, $payload): bool { + $body = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $timestamp = (string) ($request->header('X-Platform-Timestamp')[0] ?? ''); + + return $request->url() === 'https://hooks.example/orders' + && $request->body() === $body + && ctype_digit($timestamp) + && $request->hasHeader('X-Platform-Event', 'order.created') + && $request->hasHeader('X-Platform-Delivery-Id', $delivery->event_id) + && $request->hasHeader('X-Platform-Signature', hash_hmac('sha256', $timestamp.'.'.$body, 'test-secret')); + }); + }); + + it('records failures and exposes the required retry schedule', function () { + Http::fake(['https://hooks.example/*' => Http::response('broken', 500)]); + $store = createStoreContext()['store']; + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example/orders', + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + $delivery = $subscription->deliveries()->create(['event_id' => (string) Str::uuid(), 'attempt_count' => 0, 'status' => 'pending']); + $job = new DeliverWebhook($delivery, 'order.created', ['order_id' => 1]); + + expect(fn () => $job->handle(app(WebhookService::class)))->toThrow(RequestException::class) + ->and($delivery->refresh()->status->value)->toBe('failed') + ->and($delivery->attempt_count)->toBe(1) + ->and($delivery->response_code)->toBe(500) + ->and($job->tries)->toBe(6) + ->and($job->backoff)->toBe([60, 300, 1800, 7200, 43200]); + }); + + it('pauses a subscription after five consecutive delivery failures', function () { + $store = createStoreContext()['store']; + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example/orders', + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + foreach (range(1, 5) as $attempt) { + $delivery = $subscription->deliveries()->create([ + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 6, + 'status' => 'failed', + 'last_attempt_at' => now(), + ]); + } + + app(WebhookService::class)->recordFailure($delivery); + + expect($subscription->refresh()->status->value)->toBe('paused'); + }); + + it('encrypts subscription secrets at rest', function () { + $store = createStoreContext()['store']; + $subscription = WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $store->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example/orders', + 'signing_secret_encrypted' => 'plain-secret', + 'status' => 'active', + ]); + $raw = DB::table('webhook_subscriptions')->where('id', $subscription->id)->value('signing_secret_encrypted'); + + expect($raw)->not->toContain('plain-secret') + ->and($subscription->signing_secret_encrypted)->toBe('plain-secret'); + }); +}); + +describe('domain side effects audit and media', function () { + it('turns order creation into notification webhook and deduplicated analytics', function () { + Notification::fake(); + Queue::fake(); + $fixture = paidOrderFixture(); + WebhookSubscription::withoutGlobalScopes()->create([ + 'store_id' => $fixture['store']->id, + 'event_type' => 'order.created', + 'target_url' => 'https://hooks.example/orders', + 'signing_secret_encrypted' => 'secret', + 'status' => 'active', + ]); + + event(new OrderCreated($fixture['order'])); + event(new OrderCreated($fixture['order'])); + + Notification::assertSentOnDemand(OrderLifecycleNotification::class, function (OrderLifecycleNotification $notification, array $channels, object $notifiable) use ($fixture): bool { + return $notification->type === 'confirmed' + && $notification->order->is($fixture['order']) + && ($notifiable->routes['mail'] ?? null) === $fixture['order']->email; + }); + expect(AnalyticsEvent::withoutGlobalScopes() + ->where('store_id', $fixture['store']->id) + ->where('client_event_id', "order:{$fixture['order']->id}:completed") + ->count())->toBe(1) + ->and(WebhookDelivery::query()->count())->toBe(2); + Queue::assertPushed(DeliverWebhook::class, 2); + }); + + it('writes structured audit changes with actor tenant and resource', function () { + $context = createStoreContext(); + actingAsAdmin($context['user'], $context['store']); + $product = Product::factory()->for($context['store'])->create(['title' => 'Before Audit', 'handle' => 'audit-product']); + /** @var LoggerInterface&MockInterface $auditChannel */ + $auditChannel = Mockery::mock(LoggerInterface::class); + $auditChannel->shouldReceive('info')->once()->with('product.updated', Mockery::on(function (array $context) use ($product): bool { + return $context['event'] === 'product.updated' + && $context['user_id'] !== null + && $context['store_id'] === $product->store_id + && $context['resource_type'] === 'product' + && $context['resource_id'] === $product->id + && $context['changes']['title'] === ['Before Audit', 'After Audit']; + })); + Log::shouldReceive('channel')->once()->with('audit')->andReturn($auditChannel); + + $product->update(['title' => 'After Audit']); + }); + + it('generates every media rendition and deletes all files with the record', function () { + Storage::fake('public'); + $store = createStoreContext()['store']; + $product = Product::factory()->for($store)->create(); + $file = UploadedFile::fake()->image('catalog.png', 1200, 800); + $source = $file->storeAs('media/originals', 'catalog.png', 'public'); + $media = $product->media()->create([ + 'type' => 'image', + 'storage_key' => $source, + 'alt_text' => 'Catalog image', + 'position' => 0, + 'status' => 'processing', + ]); + + (new ProcessMediaUpload($media))->handle(); + + expect($media->refresh()->status->value)->toBe('ready') + ->and($media->width)->toBe(1200) + ->and($media->height)->toBe(800) + ->and($media->mime_type)->toBe('image/png') + ->and($media->byte_size)->toBeGreaterThan(0); + foreach (['thumbnail', 'small', 'medium', 'large'] as $size) { + Storage::disk('public')->assertExists("media/{$product->id}/{$media->id}/{$size}.png"); + Storage::disk('public')->assertExists("media/{$product->id}/{$media->id}/{$size}.webp"); + } + + $media->delete(); + Storage::disk('public')->assertMissing($source); + foreach (['thumbnail', 'small', 'medium', 'large'] as $size) { + Storage::disk('public')->assertMissing("media/{$product->id}/{$media->id}/{$size}.png"); + Storage::disk('public')->assertMissing("media/{$product->id}/{$media->id}/{$size}.webp"); + } + }); + + it('marks invalid media failed and preserves the original for diagnosis', function () { + Storage::fake('public'); + $store = createStoreContext()['store']; + $product = Product::factory()->for($store)->create(); + $file = UploadedFile::fake()->createWithContent('invalid.png', 'not an image'); + $source = $file->storeAs('media/originals', 'invalid.png', 'public'); + $media = $product->media()->create(['type' => 'image', 'storage_key' => $source, 'position' => 0, 'status' => 'processing']); + + expect(fn () => (new ProcessMediaUpload($media))->handle())->toThrow(RuntimeException::class) + ->and($media->refresh()->status->value)->toBe('failed'); + Storage::disk('public')->assertExists($source); + }); +}); + +describe('maintenance scheduling and seeded acceptance data', function () { + it('abandons stale carts releases checkout reservations and leaves fresh carts active', function () { + $stale = checkoutReadyForPayment(); + DB::table('carts')->where('id', $stale['cart']->id)->update(['updated_at' => now()->subDays(15)]); + $freshStore = createStoreContext()['store']; + $fresh = app(CartService::class)->create($freshStore); + + (new CleanupAbandonedCarts)->handle(app(CheckoutService::class)); + + expect($stale['cart']->refresh()->status->value)->toBe('abandoned') + ->and($stale['checkout']->refresh()->status->value)->toBe('expired') + ->and($stale['variant']->inventoryItem->refresh()->quantity_reserved)->toBe(0) + ->and($fresh->refresh()->status->value)->toBe('active'); + }); + + it('registers all four required recurring jobs', function () { + Artisan::call('schedule:list'); + $output = Artisan::output(); + + expect($output)->toContain('ExpireAbandonedCheckouts') + ->and($output)->toContain('CleanupAbandonedCarts') + ->and($output)->toContain('AggregateAnalytics') + ->and($output)->toContain('CancelUnpaidBankTransferOrders') + ->and($output)->toContain('*/15 * * * *') + ->and($output)->toContain('30 0 * * *') + ->and($output)->toContain('0 1 * * *') + ->and($output)->toContain('0 2 * * *'); + }); + + it('seeds the exact demo tenants credentials catalog and isolation markers', function () { + $this->seed(); + $fashion = Store::query()->where('handle', 'acme-fashion')->firstOrFail(); + $electronics = Store::query()->where('handle', 'acme-electronics')->firstOrFail(); + $admin = User::query()->where('email', 'admin@acme.test')->firstOrFail(); + $customer = Customer::withoutGlobalScopes()->where('store_id', $fashion->id)->where('email', 'customer@acme.test')->firstOrFail(); + + expect(Store::query()->count())->toBe(2) + ->and($fashion->domains()->where('hostname', 'shop.test')->exists())->toBeTrue() + ->and($fashion->products()->count())->toBe(20) + ->and($electronics->products()->count())->toBe(5) + ->and($fashion->products()->where('handle', 'classic-cotton-t-shirt')->exists())->toBeTrue() + ->and($fashion->products()->where('handle', 'unreleased-winter-jacket')->where('status', 'draft')->exists())->toBeTrue() + ->and($fashion->products()->where('handle', 'gift-card')->whereHas('variants', fn ($query) => $query->where('requires_shipping', false))->exists())->toBeTrue() + ->and($electronics->products()->where('handle', 'pro-laptop-15')->exists())->toBeTrue() + ->and(password_verify('password', $admin->password_hash))->toBeTrue() + ->and(password_verify('password', $customer->password_hash))->toBeTrue() + ->and($admin->roleForStore($fashion)?->value)->toBe('owner'); + }); +}); diff --git a/tests/Feature/Shop/StorefrontApiTest.php b/tests/Feature/Shop/StorefrontApiTest.php new file mode 100644 index 00000000..b6f74e3b --- /dev/null +++ b/tests/Feature/Shop/StorefrontApiTest.php @@ -0,0 +1,539 @@ +hostname.'/api/storefront/v1/'.ltrim($path, '/'); +} + +/** @return array{cart_id: int, email: string} */ +function storefrontCheckoutPayload(int $cartId, string $email = 'buyer@example.test'): array +{ + return ['cart_id' => $cartId, 'email' => $email]; +} + +/** @return array{payment_method: string, card_number: string, card_expiry: string, card_cvc: string, card_holder: string} */ +function storefrontCardPayment(string $number): array +{ + return [ + 'payment_method' => 'credit_card', + 'card_number' => $number, + 'card_expiry' => '12/30', + 'card_cvc' => '123', + 'card_holder' => 'Ada Lovelace', + ]; +} + +describe('storefront cart API', function () { + it('creates retrieves mutates and versions a cart', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store'], variantAttributes: ['price_amount' => 2500]); + + $created = $this->postJson(storefrontApiUrl($context, 'carts')) + ->assertCreated() + ->assertJsonPath('cart_version', 1) + ->assertJsonPath('currency', $context['store']->default_currency) + ->assertJsonCount(0, 'lines'); + $cartId = $created->json('id'); + + $added = $this->postJson(storefrontApiUrl($context, "carts/{$cartId}/lines"), [ + 'variant_id' => $sku['variant']->id, + 'quantity' => 2, + 'expected_version' => 1, + ])->assertCreated() + ->assertJsonPath('cart_version', 2) + ->assertJsonPath('totals.subtotal', 5000) + ->assertJsonPath('lines.0.quantity', 2) + ->assertJsonPath('lines.0.line_total_amount', 5000); + $lineId = $added->json('lines.0.id'); + + $this->getJson(storefrontApiUrl($context, "carts/{$cartId}")) + ->assertOk() + ->assertJsonCount(1, 'lines') + ->assertJsonPath('lines.0.product_title', $sku['product']->title); + + $this->putJson(storefrontApiUrl($context, "carts/{$cartId}/lines/{$lineId}"), [ + 'quantity' => 0, + 'cart_version' => 2, + ])->assertUnprocessable()->assertJsonValidationErrors('quantity'); + + $this->putJson(storefrontApiUrl($context, "carts/{$cartId}/lines/{$lineId}"), [ + 'quantity' => 3, + ])->assertUnprocessable()->assertJsonValidationErrors('cart_version'); + + $this->putJson(storefrontApiUrl($context, "carts/{$cartId}/lines/{$lineId}"), [ + 'quantity' => 3, + 'cart_version' => 2, + ])->assertOk() + ->assertJsonPath('cart_version', 3) + ->assertJsonPath('lines.0.quantity', 3) + ->assertJsonPath('lines.0.line_subtotal_amount', 7500); + + $this->putJson(storefrontApiUrl($context, "carts/{$cartId}/lines/{$lineId}"), [ + 'quantity' => 4, + 'cart_version' => 2, + ])->assertConflict() + ->assertJsonPath('data.cart_version', 3) + ->assertJsonPath('data.lines.0.quantity', 3); + + $this->deleteJson(storefrontApiUrl($context, "carts/{$cartId}/lines/{$lineId}"), [ + 'cart_version' => 3, + ])->assertOk() + ->assertJsonPath('cart_version', 4) + ->assertJsonCount(0, 'lines'); + }); + + it('returns validation responses for invalid quantities variants and inventory', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store'], inventoryAttributes: ['quantity_on_hand' => 1]); + $cart = app(CartService::class)->create($context['store']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + + $this->postJson(storefrontApiUrl($context, "carts/{$cart->id}/lines"), [ + 'variant_id' => 999999, + 'quantity' => 1, + ])->assertUnprocessable()->assertJsonValidationErrors('variant_id'); + + $this->postJson(storefrontApiUrl($context, "carts/{$cart->id}/lines"), [ + 'variant_id' => $sku['variant']->id, + 'quantity' => 0, + ])->assertUnprocessable()->assertJsonValidationErrors('quantity'); + + $this->postJson(storefrontApiUrl($context, "carts/{$cart->id}/lines"), [ + 'variant_id' => $sku['variant']->id, + 'quantity' => 2, + ])->assertUnprocessable()->assertJsonValidationErrors('quantity'); + }); + + it('does not reveal carts or variants from another tenant', function () { + $contextA = createStoreContext(); + $contextB = createStoreContext(); + $foreignSku = makeSellableVariant($contextB['store']); + $foreignCart = app(CartService::class)->create($contextB['store']); + bindStore($contextA['store']); + $cart = app(CartService::class)->create($contextA['store']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + + $this->getJson(storefrontApiUrl($contextA, "carts/{$foreignCart->id}"))->assertNotFound(); + $this->postJson(storefrontApiUrl($contextA, "carts/{$cart->id}/lines"), [ + 'variant_id' => $foreignSku['variant']->id, + 'quantity' => 1, + ])->assertUnprocessable()->assertJsonValidationErrors('variant_id'); + $this->getJson(storefrontApiUrl($contextA, 'carts/999999'))->assertNotFound(); + }); +}); + +describe('storefront cart and checkout ownership', function () { + it('does not let one anonymous session show or mutate another sessions cart or checkout', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store']); + + $cartAId = $this->postJson(storefrontApiUrl($context, 'carts')) + ->assertCreated() + ->json('id'); + $lineAId = $this->postJson(storefrontApiUrl($context, "carts/{$cartAId}/lines"), [ + 'variant_id' => $sku['variant']->id, + 'quantity' => 1, + 'expected_version' => 1, + ])->assertCreated()->json('lines.0.id'); + $checkoutAId = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cartAId, 'session-a@example.test')) + ->assertCreated() + ->assertJsonPath('email', 'session-a@example.test') + ->json('id'); + + $this->flushSession(); + $cartBId = $this->postJson(storefrontApiUrl($context, 'carts')) + ->assertCreated() + ->json('id'); + expect($cartBId)->not->toBe($cartAId); + + $this->getJson(storefrontApiUrl($context, "carts/{$cartAId}"))->assertNotFound(); + $this->postJson(storefrontApiUrl($context, "carts/{$cartAId}/lines"), [ + 'variant_id' => $sku['variant']->id, + 'quantity' => 1, + 'expected_version' => 2, + ])->assertNotFound(); + $this->putJson(storefrontApiUrl($context, "carts/{$cartAId}/lines/{$lineAId}"), [ + 'quantity' => 5, + 'cart_version' => 2, + ])->assertNotFound(); + $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cartAId))->assertNotFound(); + $this->getJson(storefrontApiUrl($context, "checkouts/{$checkoutAId}"))->assertNotFound(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutAId}/address"), validCheckoutAddress()) + ->assertNotFound(); + + $cartA = Cart::withoutGlobalScopes()->with('lines')->findOrFail($cartAId); + $checkoutA = Checkout::withoutGlobalScopes()->findOrFail($checkoutAId); + expect($cartA->cart_version)->toBe(2) + ->and($cartA->lines)->toHaveCount(1) + ->and($cartA->lines->first()->quantity)->toBe(1) + ->and($checkoutA->status->value)->toBe('started') + ->and($checkoutA->email)->toBe('session-a@example.test'); + }); + + it('does not let an authenticated customer access another customers cart or checkout', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store']); + $customerA = Customer::factory()->for($context['store'])->create(); + $customerB = Customer::factory()->for($context['store'])->create(); + $carts = app(CartService::class); + $checkouts = app(CheckoutService::class); + + $cartA = $carts->create($context['store'], $customerA); + $carts->addLine($cartA, $sku['variant'], 1); + $checkoutA = $checkouts->create($cartA); + $cartB = $carts->create($context['store'], $customerB); + $lineB = $carts->addLine($cartB, $sku['variant'], 1); + $checkoutB = $checkouts->create($cartB); + + actingAsCustomer($customerA); + $this->withSession(['cart_id' => $cartB->id]); + + $this->getJson(storefrontApiUrl($context, "carts/{$cartA->id}"))->assertOk(); + $this->getJson(storefrontApiUrl($context, "checkouts/{$checkoutA->id}"))->assertOk(); + $this->getJson(storefrontApiUrl($context, "carts/{$cartB->id}"))->assertNotFound(); + $this->postJson(storefrontApiUrl($context, "carts/{$cartB->id}/lines"), [ + 'variant_id' => $sku['variant']->id, + 'quantity' => 1, + 'expected_version' => 2, + ])->assertNotFound(); + $this->putJson(storefrontApiUrl($context, "carts/{$cartB->id}/lines/{$lineB->id}"), [ + 'quantity' => 5, + 'cart_version' => 2, + ])->assertNotFound(); + $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cartB->id))->assertNotFound(); + $this->getJson(storefrontApiUrl($context, "checkouts/{$checkoutB->id}"))->assertNotFound(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutB->id}/address"), validCheckoutAddress()) + ->assertNotFound(); + + expect($cartB->refresh()->cart_version)->toBe(2) + ->and($lineB->refresh()->quantity)->toBe(1) + ->and($checkoutB->refresh()->status->value)->toBe('started') + ->and($checkoutB->email)->toBeNull(); + }); +}); + +describe('storefront checkout API', function () { + it('keeps one live checkout per cart and refreshes its starting email', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store']); + ['cart' => $cart] = makeCartWithLine($context['store'], $sku['variant']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + + $first = $this->postJson( + storefrontApiUrl($context, 'checkouts'), + storefrontCheckoutPayload($cart->id, 'first@example.test'), + )->assertCreated(); + $second = $this->postJson( + storefrontApiUrl($context, 'checkouts'), + storefrontCheckoutPayload($cart->id, 'second@example.test'), + )->assertCreated(); + + expect($second->json('id'))->toBe($first->json('id')) + ->and($second->json('email'))->toBe('second@example.test') + ->and($cart->checkouts()->whereNotIn('status', ['completed', 'expired'])->count())->toBe(1); + }); + + it('rejects every cart mutation after payment selection', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store']); + ['cart' => $cart, 'line' => $line] = makeCartWithLine($context['store'], $sku['variant']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + $zone = ShippingZone::factory()->for($context['store'])->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(); + $checkoutId = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cart->id)) + ->assertCreated()->json('id'); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), validCheckoutAddress())->assertOk(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/shipping-method"), ['shipping_method_id' => $rate->id])->assertOk(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/payment-method"), ['payment_method' => 'credit_card']) + ->assertOk()->assertJsonPath('status', 'payment_selected'); + + $this->postJson(storefrontApiUrl($context, "carts/{$cart->id}/lines"), [ + 'variant_id' => $sku['variant']->id, + 'quantity' => 1, + 'expected_version' => 2, + ])->assertUnprocessable()->assertJsonValidationErrors('variant_id'); + $this->putJson(storefrontApiUrl($context, "carts/{$cart->id}/lines/{$line->id}"), [ + 'quantity' => 5, + 'cart_version' => 2, + ])->assertUnprocessable()->assertJsonValidationErrors('cart'); + $this->deleteJson(storefrontApiUrl($context, "carts/{$cart->id}/lines/{$line->id}"), [ + 'cart_version' => 2, + ])->assertUnprocessable()->assertJsonValidationErrors('cart'); + + expect($cart->refresh()->cart_version)->toBe(2) + ->and($line->refresh()->quantity)->toBe(1) + ->and($sku['inventory']->refresh()->quantity_reserved)->toBe(1); + }); + + it('returns gone for an expired checkout', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store']); + ['cart' => $cart] = makeCartWithLine($context['store'], $sku['variant']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + $checkoutId = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cart->id)) + ->assertCreated()->json('id'); + Checkout::withoutGlobalScopes()->whereKey($checkoutId)->update(['expires_at' => now()->subMinute()]); + + $this->getJson(storefrontApiUrl($context, "checkouts/{$checkoutId}"))->assertGone(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), validCheckoutAddress())->assertGone(); + }); + + it('requires complete matching payment fields before charging', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store']); + ['cart' => $cart] = makeCartWithLine($context['store'], $sku['variant']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + $zone = ShippingZone::factory()->for($context['store'])->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(); + $checkoutId = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cart->id)) + ->assertCreated()->json('id'); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), validCheckoutAddress())->assertOk(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/shipping-method"), ['shipping_method_id' => $rate->id])->assertOk(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/payment-method"), ['payment_method' => 'credit_card'])->assertOk(); + + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/pay"), []) + ->assertUnprocessable()->assertJsonValidationErrors('payment_method'); + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/pay"), ['payment_method' => 'credit_card']) + ->assertUnprocessable()->assertJsonValidationErrors(['card_number', 'card_expiry', 'card_cvc', 'card_holder']); + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/pay"), ['payment_method' => 'paypal']) + ->assertUnprocessable()->assertJsonValidationErrors('payment_method'); + + expect(Order::withoutGlobalScopes()->where('store_id', $context['store']->id)->count())->toBe(0) + ->and(Payment::query()->count())->toBe(0) + ->and($sku['inventory']->refresh()->quantity_reserved)->toBe(1); + }); + + it('progresses a digital-only checkout without a shipping address or rate', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store'], variantAttributes: [ + 'requires_shipping' => false, + 'weight_g' => 0, + ]); + ['cart' => $cart] = makeCartWithLine($context['store'], $sku['variant']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + $checkoutId = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cart->id, 'reader@example.test')) + ->assertCreated()->json('id'); + + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), []) + ->assertOk() + ->assertJsonPath('status', 'addressed') + ->assertJsonPath('shipping_address_json', null) + ->assertJsonCount(0, 'available_shipping_methods'); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/shipping-method"), ['shipping_method_id' => null]) + ->assertOk() + ->assertJsonPath('status', 'shipping_selected') + ->assertJsonPath('shipping_method_id', null) + ->assertJsonPath('totals.shipping', 0); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/payment-method"), ['payment_method' => 'paypal']) + ->assertOk(); + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/pay"), ['payment_method' => 'paypal']) + ->assertOk() + ->assertJsonPath('order.financial_status', 'paid'); + }); + + it('completes and idempotently repeats a discounted card checkout', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store'], variantAttributes: ['price_amount' => 2500]); + ['cart' => $cart] = makeCartWithLine($context['store'], $sku['variant'], 2); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + $zone = ShippingZone::factory()->for($context['store'])->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(['config_json' => ['amount' => 499]]); + Discount::factory()->for($context['store'])->create(['code' => 'API10', 'value_type' => 'percent', 'value_amount' => 10]); + + $created = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cart->id)) + ->assertCreated() + ->assertJsonPath('status', 'started') + ->assertJsonPath('email', 'buyer@example.test'); + $checkoutId = $created->json('id'); + + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), validCheckoutAddress()) + ->assertOk() + ->assertJsonPath('status', 'addressed') + ->assertJsonPath('available_shipping_methods.0.id', $rate->id) + ->assertJsonPath('available_shipping_methods.0.price_amount', 499); + + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/shipping-method"), ['shipping_method_id' => $rate->id]) + ->assertOk() + ->assertJsonPath('status', 'shipping_selected') + ->assertJsonPath('totals.shipping', 499); + + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/apply-discount"), ['code' => 'api10']) + ->assertOk() + ->assertJsonPath('discount_code', 'api10') + ->assertJsonPath('totals.discount', 500); + + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/payment-method"), ['payment_method' => 'credit_card']) + ->assertOk() + ->assertJsonPath('status', 'payment_selected'); + + $paid = $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/pay"), storefrontCardPayment('4242 4242 4242 4242')) + ->assertOk() + ->assertJsonPath('status', 'completed') + ->assertJsonPath('order.financial_status', 'paid') + ->assertJsonPath('order.total_amount', 4999); + $orderId = $paid->json('order.id'); + + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/pay"), storefrontCardPayment('4242 4242 4242 4242')) + ->assertOk() + ->assertJsonPath('order.id', $orderId); + + expect(Order::withoutGlobalScopes()->where('store_id', $context['store']->id)->count())->toBe(1) + ->and(Payment::query()->where('order_id', $orderId)->count())->toBe(1) + ->and($sku['inventory']->refresh()->quantity_on_hand)->toBe(18) + ->and($sku['inventory']->quantity_reserved)->toBe(0); + }); + + it('returns a stable decline code and releases inventory', function () { + $context = createStoreContext(); + $sku = makeSellableVariant($context['store']); + ['cart' => $cart] = makeCartWithLine($context['store'], $sku['variant']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$cart->id]]); + $zone = ShippingZone::factory()->for($context['store'])->create(['countries_json' => ['DE']]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(); + $checkoutId = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cart->id))->json('id'); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), validCheckoutAddress())->assertOk(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/shipping-method"), ['shipping_method_id' => $rate->id])->assertOk(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/payment-method"), ['payment_method' => 'credit_card'])->assertOk(); + + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/pay"), storefrontCardPayment('4000 0000 0000 0002')) + ->assertUnprocessable() + ->assertJsonPath('error_code', 'card_declined'); + + expect($sku['inventory']->refresh()->quantity_reserved)->toBe(0) + ->and(Order::withoutGlobalScopes()->where('store_id', $context['store']->id)->count())->toBe(0); + }); + + it('translates invalid checkout domain input into 422 responses', function () { + $context = createStoreContext(); + $empty = app(CartService::class)->create($context['store']); + $this->withSession(['cart_id' => $empty->id, 'api_cart_ids' => [$empty->id]]); + + $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($empty->id)) + ->assertUnprocessable() + ->assertJsonValidationErrors('cart_id'); + + $sku = makeSellableVariant($context['store']); + ['cart' => $cart] = makeCartWithLine($context['store'], $sku['variant']); + $this->withSession(['cart_id' => $cart->id, 'api_cart_ids' => [$empty->id, $cart->id]]); + $this->postJson(storefrontApiUrl($context, 'checkouts'), ['cart_id' => $cart->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors('email'); + $checkoutId = $this->postJson(storefrontApiUrl($context, 'checkouts'), storefrontCheckoutPayload($cart->id))->json('id'); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Ada', 'last_name' => 'Lovelace', 'address1' => 'Street 1', + 'country_code' => 'DE', 'postal_code' => '10115', + ], + ])->assertUnprocessable()->assertJsonValidationErrors('shipping_address.city'); + + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), validCheckoutAddress()) + ->assertUnprocessable() + ->assertJsonValidationErrors('shipping_address'); + + $zone = ShippingZone::factory()->for($context['store'])->create(['countries_json' => ['DE']]); + ShippingRate::factory()->for($zone, 'zone')->create(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/address"), validCheckoutAddress())->assertOk(); + $wrongZone = ShippingZone::factory()->for($context['store'])->create(['countries_json' => ['US']]); + $wrongRate = ShippingRate::factory()->for($wrongZone, 'zone')->create(); + $this->putJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/shipping-method"), ['shipping_method_id' => $wrongRate->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors('shipping_method_id'); + + $this->postJson(storefrontApiUrl($context, "checkouts/{$checkoutId}/apply-discount"), ['code' => 'MISSING']) + ->assertUnprocessable() + ->assertJsonPath('error_code', 'not_found'); + }); +}); + +describe('storefront search analytics and order API', function () { + it('searches suggests validates and rate limits queries', function () { + $context = createStoreContext(); + Product::factory()->for($context['store'])->create(['title' => 'Summer Cotton Dress', 'handle' => 'summer-cotton-dress']); + + $this->getJson(storefrontApiUrl($context, 'search?q=cott')) + ->assertOk() + ->assertJsonPath('pagination.total', 1) + ->assertJsonPath('results.0.title', 'Summer Cotton Dress'); + $this->getJson(storefrontApiUrl($context, 'search/suggest?q=sum')) + ->assertOk() + ->assertJsonPath('suggestions.0.title', 'Summer Cotton Dress'); + $this->getJson(storefrontApiUrl($context, 'search/suggest?q=a'))->assertOk(); + $this->getJson(storefrontApiUrl($context, 'search/suggest?q=')) + ->assertUnprocessable()->assertJsonValidationErrors('q'); + + $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.10']); + foreach (range(1, 30) as $attempt) { + $this->getJson(storefrontApiUrl($context, 'search?q=none'))->assertOk(); + } + $this->getJson(storefrontApiUrl($context, 'search?q=none'))->assertTooManyRequests(); + }); + + it('accepts analytics batches deduplicates events and validates shape', function () { + $context = createStoreContext(); + $customer = Customer::factory()->for($context['store'])->create(); + actingAsCustomer($customer); + $event = [ + 'type' => 'add_to_cart', + 'session_id' => 'session-api', + 'client_event_id' => 'event-api-1', + 'occurred_at' => now()->toIso8601String(), + 'properties' => ['variant_id' => 42], + ]; + + $this->postJson(storefrontApiUrl($context, 'analytics/events'), ['events' => [$event, $event]]) + ->assertStatus(202) + ->assertJsonPath('accepted', 1) + ->assertJsonPath('rejected', 1); + + $stored = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $context['store']->id)->firstOrFail(); + expect(AnalyticsEvent::withoutGlobalScopes()->where('store_id', $context['store']->id)->count())->toBe(1) + ->and($stored->customer_id)->toBe($customer->id) + ->and($stored->properties_json)->toBe(['variant_id' => 42]); + + $this->postJson(storefrontApiUrl($context, 'analytics/events'), ['events' => [[ + 'type' => 'not_supported', + 'session_id' => 'x', + 'client_event_id' => 'bad', + 'occurred_at' => 'not-a-date', + ]]])->assertUnprocessable()->assertJsonValidationErrors(['events.0.type', 'events.0.occurred_at']); + }); + + it('requires a signed order token and scopes the order to its tenant', function () { + $context = createStoreContext(); + $order = Order::factory()->for($context['store'])->create([ + 'customer_id' => null, + 'order_number' => '#API1001', + 'email' => 'buyer@example.test', + ]); + OrderLine::factory()->for($order)->create(['product_id' => null, 'variant_id' => null, 'title_snapshot' => 'Historic Item']); + $token = hash_hmac('sha256', $order->order_number.'|'.$order->email, (string) config('app.key')); + $number = rawurlencode($order->order_number); + + $this->getJson(storefrontApiUrl($context, "orders/{$number}"))->assertUnauthorized(); + $this->getJson(storefrontApiUrl($context, "orders/{$number}?token=wrong"))->assertUnauthorized(); + $this->getJson(storefrontApiUrl($context, "orders/{$number}?token={$token}")) + ->assertOk() + ->assertJsonPath('order_number', '#API1001') + ->assertJsonPath('lines.0.title_snapshot', 'Historic Item'); + + $other = createStoreContext(); + $this->getJson(storefrontApiUrl($other, "orders/{$number}?token={$token}"))->assertNotFound(); + }); +}); diff --git a/tests/Feature/Shop/TenancyAndAuthorizationTest.php b/tests/Feature/Shop/TenancyAndAuthorizationTest.php new file mode 100644 index 00000000..6091d190 --- /dev/null +++ b/tests/Feature/Shop/TenancyAndAuthorizationTest.php @@ -0,0 +1,216 @@ +getJson("http://{$context['domain']->hostname}/api/storefront/v1/search?q=unfindable") + ->assertOk() + ->assertJsonPath('pagination.total', 0); + + expect(app('current_store')->is($context['store']))->toBeTrue() + ->and(Cache::get('store_domain:'.$context['domain']->hostname))->toBe($context['store']->id); + }); + + it('rejects unknown and suspended storefront hosts', function () { + $this->getJson('http://missing-shop.test/api/storefront/v1/search?q=anything') + ->assertNotFound(); + + $context = createStoreContext(storeAttributes: ['status' => 'suspended']); + $this->getJson("http://{$context['domain']->hostname}/api/storefront/v1/search?q=anything") + ->assertStatus(503); + }); + + it('scopes reads creates and direct identifiers to the current store', function () { + $storeA = createStoreContext()['store']; + Product::factory()->count(3)->for($storeA)->create(); + Order::factory()->count(2)->for($storeA)->create(['customer_id' => null]); + + $storeB = Store::factory()->create(); + $foreign = Product::factory()->count(5)->for($storeB)->create()->first(); + Order::factory()->count(4)->for($storeB)->create(['customer_id' => null]); + + bindStore($storeA); + $created = Product::query()->create([ + 'title' => 'Scoped Product', + 'handle' => 'scoped-product', + 'status' => 'draft', + 'tags' => [], + ]); + + expect(Product::query()->count())->toBe(4) + ->and(Order::query()->count())->toBe(2) + ->and($created->store_id)->toBe($storeA->id) + ->and(Product::query()->find($foreign->id))->toBeNull() + ->and(Product::withoutGlobalScope(StoreScope::class)->count())->toBe(9); + }); + + it('allows one customer email per store, not globally', function () { + $storeA = createStoreContext()['store']; + $storeB = createStoreContext()['store']; + $service = app(CustomerService::class); + + $customerA = $service->register($storeA, [ + 'name' => 'Same Buyer', + 'email' => 'same@example.test', + 'password' => 'correct-horse', + ]); + $customerB = $service->register($storeB, [ + 'name' => 'Same Buyer', + 'email' => 'same@example.test', + 'password' => 'correct-horse', + ]); + + expect($customerA->store_id)->not->toBe($customerB->store_id) + ->and(Customer::withoutGlobalScopes()->where('email', 'same@example.test')->count())->toBe(2); + + $service->register($storeA, [ + 'name' => 'Duplicate', + 'email' => 'same@example.test', + 'password' => 'correct-horse', + ]); + })->throws(ValidationException::class); +}); + +describe('role policies', function () { + it('returns the typed role for a store and null elsewhere', function () { + $context = createStoreContext(StoreUserRole::Staff); + $unrelated = Store::factory()->create(); + + expect($context['user']->roleForStore($context['store']))->toBe(StoreUserRole::Staff) + ->and($context['user']->roleForStore($unrelated))->toBeNull(); + }); + + it('enforces product permissions for every store role', function (string $role, bool $create, bool $delete) { + $context = createStoreContext($role); + $product = Product::factory()->for($context['store'])->create(); + $policy = new ProductPolicy; + + expect($policy->view($context['user'], $product))->toBeTrue() + ->and($policy->create($context['user']))->toBe($create) + ->and($policy->update($context['user'], $product))->toBe($create) + ->and($policy->delete($context['user'], $product))->toBe($delete); + })->with([ + 'owner' => ['owner', true, true], + 'admin' => ['admin', true, true], + 'staff' => ['staff', true, false], + 'support' => ['support', false, false], + ]); + + it('enforces order refund and fulfillment permissions for every store role', function (string $role, bool $mutate, bool $refund) { + $context = createStoreContext($role); + $order = Order::factory()->for($context['store'])->create(['customer_id' => null]); + $orders = new OrderPolicy; + $refunds = new RefundPolicy; + + expect($orders->view($context['user'], $order))->toBeTrue() + ->and($orders->update($context['user'], $order))->toBe($mutate) + ->and($orders->fulfill($context['user'], $order))->toBe($mutate) + ->and($orders->refund($context['user'], $order))->toBe($refund) + ->and($refunds->create($context['user'], $order))->toBe($refund); + })->with([ + 'owner' => ['owner', true, true], + 'admin' => ['admin', true, true], + 'staff' => ['staff', true, false], + 'support read-only' => ['support', false, false], + ]); + + it('restricts settings staff management and store deletion', function (string $role, bool $settings, bool $delete) { + $context = createStoreContext($role); + $policy = new StorePolicy; + + expect($policy->view($context['user'], $context['store']))->toBeTrue() + ->and($policy->update($context['user'], $context['store']))->toBe($settings) + ->and($policy->manageStaff($context['user'], $context['store']))->toBe($settings) + ->and($policy->delete($context['user'], $context['store']))->toBe($delete); + })->with([ + 'owner' => ['owner', true, true], + 'admin' => ['admin', true, false], + 'staff' => ['staff', false, false], + 'support' => ['support', false, false], + ]); + + it('never authorizes a resource from another tenant', function () { + $context = createStoreContext('owner'); + $other = Store::factory()->create(); + $foreignProduct = Product::factory()->for($other)->create(); + + expect((new ProductPolicy)->update($context['user'], $foreignProduct))->toBeFalse(); + }); +}); + +describe('Sanctum administration tokens', function () { + it('persists abilities and authenticates a bearer token', function () { + $context = createStoreContext(); + $token = $context['user']->createToken('qa', [ + 'read-products', + 'write-products', + 'store:'.$context['store']->id, + ]); + + expect($token->accessToken->abilities)->toBe([ + 'read-products', + 'write-products', + 'store:'.$context['store']->id, + ]) + ->and($token->plainTextToken)->toContain('|'); + + $this->withToken($token->plainTextToken) + ->getJson("/api/admin/v1/stores/{$context['store']->id}/products") + ->assertOk() + ->assertJsonStructure(['data', 'meta' => ['current_page', 'last_page', 'total']]); + }); + + it('rejects missing invalid revoked and cross-store tokens', function () { + $context = createStoreContext(); + $otherStore = Store::factory()->create(); + + $this->getJson("/api/admin/v1/stores/{$context['store']->id}/products")->assertUnauthorized(); + $this->withToken('definitely-invalid')->getJson("/api/admin/v1/stores/{$context['store']->id}/products")->assertUnauthorized(); + + $token = $context['user']->createToken('revoked', [ + 'read-products', + 'store:'.$context['store']->id, + ]); + $plain = $token->plainTextToken; + $token->accessToken->delete(); + $this->withToken($plain)->getJson("/api/admin/v1/stores/{$context['store']->id}/products")->assertUnauthorized(); + + $valid = $context['user']->createToken('valid', [ + 'read-products', + 'store:'.$context['store']->id, + ]); + $this->withToken($valid->plainTextToken) + ->getJson("/api/admin/v1/stores/{$otherStore->id}/products") + ->assertForbidden(); + }); + + it('enforces token abilities on product mutations', function () { + $context = createStoreContext(); + $readOnly = $context['user']->createToken('reader', [ + 'read-products', + 'store:'.$context['store']->id, + ]); + + $this->withToken($readOnly->plainTextToken) + ->postJson("/api/admin/v1/stores/{$context['store']->id}/products", [ + 'title' => 'Forbidden Product', + 'variant' => ['price_amount' => 1000], + ]) + ->assertForbidden(); + }); +}); diff --git a/tests/Feature/Shop/ThemeArchiveAndSearchStatusTest.php b/tests/Feature/Shop/ThemeArchiveAndSearchStatusTest.php new file mode 100644 index 00000000..b414b9ce --- /dev/null +++ b/tests/Feature/Shop/ThemeArchiveAndSearchStatusTest.php @@ -0,0 +1,355 @@ + $entries + */ +function acceptanceThemeArchive(array $entries): UploadedFile +{ + $path = tempnam(sys_get_temp_dir(), 'acceptance-theme-'); + $zip = new ZipArchive; + $result = $zip->open($path, ZipArchive::OVERWRITE); + expect($result)->toBeTrue(); + + foreach ($entries as $name => $contents) { + $zip->addFromString($name, $contents); + } + + $zip->close(); + + return new UploadedFile($path, 'theme.zip', 'application/zip', null, true); +} + +/** @return array */ +function validAcceptanceThemeEntries(): array +{ + return [ + 'theme.json' => json_encode([ + 'name' => 'Acceptance Theme', + 'version' => '2.4.1', + 'required_templates' => ['templates/index.blade.php'], + 'settings' => [ + 'colors' => ['primary' => '#123456'], + 'layout' => ['width' => 'wide'], + ], + ], JSON_THROW_ON_ERROR), + 'templates/index.blade.php' => '
      {{ $slot ?? "Acceptance" }}
      ', + 'assets/theme.css' => 'body { color: #123456; }', + 'assets/theme.js' => 'window.themeReady = true;', + ]; +} + +/** @param list $abilities */ +function themeSearchAdminToken(array $context, array $abilities): string +{ + $abilities[] = 'store:'.$context['store']->id; + + return $context['user']->createToken('theme-search-test', array_values(array_unique($abilities)))->plainTextToken; +} + +function themeSearchAdminUrl(Store $store, string $path): string +{ + return "/api/admin/v1/stores/{$store->id}/".ltrim($path, '/'); +} + +describe('theme archive installation and duplication', function () { + it('validates and installs an archive with confined files and trustworthy metadata', function () { + Storage::fake('local'); + $context = createStoreContext(); + $token = themeSearchAdminToken($context, ['write-themes']); + $entries = validAcceptanceThemeEntries(); + + $response = $this->withToken($token)->post(themeSearchAdminUrl($context['store'], 'themes'), [ + 'file' => acceptanceThemeArchive($entries), + ], ['Accept' => 'application/json']) + ->assertCreated() + ->assertJsonPath('data.store_id', $context['store']->id) + ->assertJsonPath('data.name', 'Acceptance Theme') + ->assertJsonPath('data.version', '2.4.1') + ->assertJsonPath('data.status', 'draft') + ->assertJsonPath('data.settings.settings_json.colors.primary', '#123456') + ->assertJsonCount(count($entries), 'data.files'); + + $theme = Theme::withoutGlobalScopes() + ->with(['files', 'settings']) + ->findOrFail((int) $response->json('data.id')); + $prefix = "themes/{$context['store']->id}/{$theme->id}/"; + + expect($theme->files->pluck('path')->sort()->values()->all()) + ->toBe(collect(array_keys($entries))->sort()->values()->all()) + ->and($theme->settings?->settings_json)->toBe([ + 'colors' => ['primary' => '#123456'], + 'layout' => ['width' => 'wide'], + ]); + + foreach ($theme->files as $file) { + expect($file->storage_key)->toStartWith($prefix) + ->and($file->storage_key)->toBe($prefix.$file->path) + ->and($file->sha256)->toBe(hash('sha256', $entries[$file->path])) + ->and($file->byte_size)->toBe(strlen($entries[$file->path])); + Storage::disk('local')->assertExists($file->storage_key); + expect(Storage::disk('local')->get($file->storage_key))->toBe($entries[$file->path]); + } + }); + + it('rejects malformed and unsafe archives atomically', function () { + Storage::fake('local'); + $context = createStoreContext(); + $token = themeSearchAdminToken($context, ['write-themes']); + $validManifest = validAcceptanceThemeEntries()['theme.json']; + $template = ['templates/index.blade.php' => '
      Safe
      ']; + $archives = [ + 'missing manifest' => [ + ...$template, + 'assets/theme.css' => 'body {}', + ], + 'invalid manifest JSON' => [ + 'theme.json' => '{invalid', + ...$template, + ], + 'non-object manifest' => [ + 'theme.json' => '[]', + ...$template, + ], + 'invalid semantic version' => [ + 'theme.json' => json_encode([ + 'name' => 'Bad Version', + 'version' => 'version two', + ], JSON_THROW_ON_ERROR), + ...$template, + ], + 'missing required template' => [ + 'theme.json' => $validManifest, + 'assets/theme.css' => 'body {}', + ], + 'disallowed executable extension' => [ + 'theme.json' => $validManifest, + ...$template, + 'assets/payload.php' => ' [ + 'theme.json' => $validManifest, + ...$template, + '../escape.css' => 'unsafe', + ], + 'parent traversal with backslash' => [ + 'theme.json' => $validManifest, + ...$template, + '..\\escape.css' => 'unsafe', + ], + 'absolute path' => [ + 'theme.json' => $validManifest, + ...$template, + '/escape.css' => 'unsafe', + ], + ]; + + foreach ($archives as $case => $entries) { + $this->withToken($token)->post(themeSearchAdminUrl($context['store'], 'themes'), [ + 'file' => acceptanceThemeArchive($entries), + ], ['Accept' => 'application/json']) + ->assertUnprocessable($case) + ->assertJsonValidationErrors('file'); + + expect(Theme::withoutGlobalScopes()->where('store_id', $context['store']->id)->count()) + ->toBe(0, $case) + ->and(Storage::disk('local')->allFiles())->toBe([], $case); + } + }); + + it('duplicates the settings and every physical file into an independent draft theme', function () { + Storage::fake('local'); + $context = createStoreContext(); + $token = themeSearchAdminToken($context, ['write-themes']); + $entries = validAcceptanceThemeEntries(); + + $response = $this->withToken($token)->post(themeSearchAdminUrl($context['store'], 'themes'), [ + 'file' => acceptanceThemeArchive($entries), + ], ['Accept' => 'application/json'])->assertCreated(); + $source = Theme::withoutGlobalScopes()->with(['files', 'settings']) + ->findOrFail((int) $response->json('data.id')); + $source->update(['status' => 'published', 'published_at' => now()]); + + actingAsAdmin($context['user'], $context['store']); + Livewire::test(ThemesIndex::class) + ->call('duplicateTheme', $source->id) + ->assertDispatched('toast'); + + $source->refresh()->load(['files', 'settings']); + $copy = Theme::withoutGlobalScopes() + ->where('store_id', $context['store']->id) + ->whereKeyNot($source->id) + ->with(['files', 'settings']) + ->sole(); + + expect($source->status->value)->toBe('published') + ->and($copy->name)->toBe('Acceptance Theme Copy') + ->and($copy->version)->toBe($source->version) + ->and($copy->status->value)->toBe('draft') + ->and($copy->published_at)->toBeNull() + ->and($copy->settings?->settings_json)->toBe($source->settings?->settings_json) + ->and($copy->files->pluck('path')->sort()->values()->all()) + ->toBe($source->files->pluck('path')->sort()->values()->all()); + + $sourceFiles = $source->files->keyBy('path'); + foreach ($copy->files as $copyFile) { + $sourceFile = $sourceFiles->get($copyFile->path); + expect($sourceFile)->not->toBeNull() + ->and($copyFile->storage_key)->not->toBe($sourceFile->storage_key) + ->and($copyFile->storage_key)->toBe("themes/{$context['store']->id}/{$copy->id}/{$copyFile->path}") + ->and($copyFile->sha256)->toBe($sourceFile->sha256) + ->and($copyFile->byte_size)->toBe($sourceFile->byte_size); + Storage::disk('local')->assertExists($copyFile->storage_key); + expect(Storage::disk('local')->get($copyFile->storage_key)) + ->toBe(Storage::disk('local')->get($sourceFile->storage_key)); + } + + $copyCss = $copy->files->firstWhere('path', 'assets/theme.css'); + $sourceCss = $sourceFiles->get('assets/theme.css'); + Storage::disk('local')->put($copyCss->storage_key, 'copy-only change'); + $copy->settings()->update(['settings_json' => ['colors' => ['primary' => '#abcdef']]]); + + expect(Storage::disk('local')->get($sourceCss->storage_key))->toBe($entries['assets/theme.css']) + ->and($source->settings?->refresh()->settings_json)->toBe([ + 'colors' => ['primary' => '#123456'], + 'layout' => ['width' => 'wide'], + ]); + }); +}); + +describe('persistent search reindex status', function () { + it('persists queued status before dispatch and preserves unrelated settings', function () { + Queue::fake(); + $context = createStoreContext(); + $record = StoreSettings::withoutGlobalScopes()->findOrFail($context['store']->id); + $record->update(['settings_json' => [ + ...$record->settings_json, + 'brand' => ['color' => '#112233'], + 'search' => [ + 'status' => 'ready', + 'progress' => 100, + 'documents_count' => 8, + 'last_reindex_at' => '2026-07-01T08:00:00+00:00', + 'last_reindex_duration_seconds' => 12, + 'pending_updates' => 0, + ], + ]]); + $token = themeSearchAdminToken($context, ['read-settings', 'write-settings']); + + $this->withToken($token) + ->postJson(themeSearchAdminUrl($context['store'], 'search/reindex')) + ->assertAccepted(); + + $settings = StoreSettings::withoutGlobalScopes()->findOrFail($context['store']->id)->settings_json; + expect(data_get($settings, 'search.status'))->toBe('queued') + ->and(data_get($settings, 'search.progress'))->toBe(0) + ->and(data_get($settings, 'search.documents_count'))->toBe(8) + ->and(data_get($settings, 'search.last_reindex_at'))->toBe('2026-07-01T08:00:00+00:00') + ->and(data_get($settings, 'brand.color'))->toBe('#112233'); + Queue::assertPushed(ReindexProducts::class, 1); + + $this->withToken($token) + ->getJson(themeSearchAdminUrl($context['store'], 'search/status')) + ->assertOk() + ->assertJsonPath('data.store_id', $context['store']->id) + ->assertJsonPath('data.index_status', 'queued') + ->assertJsonPath('data.documents_count', 8) + ->assertJsonPath('data.last_reindex_at', '2026-07-01T08:00:00+00:00') + ->assertJsonPath('data.pending_updates', 0); + }); + + it('hydrates persisted processing state when the admin screen is reopened', function () { + $context = createStoreContext(); + $record = StoreSettings::withoutGlobalScopes()->findOrFail($context['store']->id); + $record->update(['settings_json' => [ + ...$record->settings_json, + 'search' => [ + 'status' => 'processing', + 'progress' => 45, + 'documents_count' => 12, + 'last_reindex_at' => '2026-07-01T08:00:00+00:00', + ], + ]]); + actingAsAdmin($context['user'], $context['store']); + + Livewire::test(SearchSettingsComponent::class) + ->assertSet('isReindexing', true) + ->assertSet('reindexProgress', 45) + ->assertSet('lastIndexedAt', '2026-07-01T08:00:00+00:00'); + }); + + it('persists completion from the job and restores the previous tenant binding', function () { + $first = createStoreContext(); + Product::factory()->count(2)->for($first['store'])->create(); + $second = createStoreContext(); + Product::factory()->count(3)->for($second['store'])->create(); + bindStore($second['store']); + + (new ReindexProducts($first['store']->id))->handle(app(SearchService::class)); + + expect(app('current_store')->is($second['store']))->toBeTrue(); + $firstSettings = StoreSettings::withoutGlobalScopes()->findOrFail($first['store']->id)->settings_json; + $secondSettings = StoreSettings::withoutGlobalScopes()->findOrFail($second['store']->id)->settings_json; + expect(data_get($firstSettings, 'search.status'))->toBe('ready') + ->and(data_get($firstSettings, 'search.progress'))->toBe(100) + ->and(data_get($firstSettings, 'search.documents_count'))->toBe(2) + ->and(data_get($firstSettings, 'search.last_reindex_at'))->not->toBeNull() + ->and(data_get($firstSettings, 'search.last_reindex_duration_seconds'))->toBeInt() + ->and(data_get($firstSettings, 'search.pending_updates'))->toBe(0) + ->and(data_get($secondSettings, 'search.status'))->toBeNull(); + }); + + it('rejects a second reindex while one is already queued', function () { + Queue::fake(); + $context = createStoreContext(); + $token = themeSearchAdminToken($context, ['write-settings']); + + $this->withToken($token) + ->postJson(themeSearchAdminUrl($context['store'], 'search/reindex')) + ->assertAccepted(); + $this->withToken($token) + ->postJson(themeSearchAdminUrl($context['store'], 'search/reindex')) + ->assertConflict(); + Queue::assertPushed(ReindexProducts::class, 1); + }); + + it('returns the documented persistent status schema', function () { + $context = createStoreContext(); + $record = StoreSettings::withoutGlobalScopes()->findOrFail($context['store']->id); + $record->update(['settings_json' => [ + ...$record->settings_json, + 'search' => [ + 'status' => 'ready', + 'last_reindex_at' => '2026-07-01T08:00:00+00:00', + 'last_reindex_duration_seconds' => 17, + 'documents_count' => 23, + 'pending_updates' => 0, + ], + ]]); + $token = themeSearchAdminToken($context, ['read-settings']); + + $this->withToken($token) + ->getJson(themeSearchAdminUrl($context['store'], 'search/status')) + ->assertOk() + ->assertExactJson(['data' => [ + 'store_id' => $context['store']->id, + 'index_status' => 'ready', + 'last_reindex_at' => '2026-07-01T08:00:00+00:00', + 'last_reindex_duration_seconds' => 17, + 'documents_count' => 23, + 'pending_updates' => 0, + ]]); + }); +}); diff --git a/tests/Fixtures/CodeQuality/CleanService.php b/tests/Fixtures/CodeQuality/CleanService.php new file mode 100644 index 00000000..e676b8a5 --- /dev/null +++ b/tests/Fixtures/CodeQuality/CleanService.php @@ -0,0 +1,11 @@ + router.visit('/app/checks')}>Open; +} diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..d4a9e315 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,47 +1,249 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); - -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ - -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ - -function something() +use App\Enums\PaymentMethod; +use App\Enums\StoreUserRole; +use App\Models\Cart; +use App\Models\CartLine; +use App\Models\Checkout; +use App\Models\Customer; +use App\Models\InventoryItem; +use App\Models\Order; +use App\Models\OrderLine; +use App\Models\Organization; +use App\Models\Payment; +use App\Models\Product; +use App\Models\ProductVariant; +use App\Models\ShippingRate; +use App\Models\ShippingZone; +use App\Models\Store; +use App\Models\StoreDomain; +use App\Models\StoreSettings; +use App\Models\User; +use App\Services\CartService; +use App\Services\CheckoutService; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; +use Tests\TestCase; + +pest()->extend(TestCase::class)->in('Feature'); +pest()->use(RefreshDatabase::class)->in('Feature/Shop'); + +/** + * Bind an explicit tenant for global-scope and policy assertions. + */ +function bindStore(Store $store): Store +{ + app()->forgetInstance('current_store'); + app()->instance('current_store', $store); + + return $store; +} + +/** + * @param array $storeAttributes + * @param array $userAttributes + * @return array{organization: Organization, store: Store, domain: StoreDomain, user: User} + */ +function createStoreContext( + StoreUserRole|string $role = StoreUserRole::Owner, + array $storeAttributes = [], + array $userAttributes = [], +): array { + $organization = Organization::factory()->create(); + $store = Store::factory()->for($organization)->create($storeAttributes); + $domain = StoreDomain::factory()->for($store)->create([ + 'hostname' => $store->handle.'.test', + 'type' => 'storefront', + 'is_primary' => true, + ]); + $user = User::factory()->create($userAttributes); + $role = $role instanceof StoreUserRole ? $role->value : $role; + DB::table('store_users')->insert([ + 'store_id' => $store->id, + 'user_id' => $user->id, + 'role' => $role, + 'created_at' => now(), + ]); + StoreSettings::query()->create([ + 'store_id' => $store->id, + 'settings_json' => [ + 'order_number_prefix' => '#', + 'bank_transfer_cancel_days' => 7, + 'cart_abandon_days' => 14, + ], + ]); + bindStore($store); + + return compact('organization', 'store', 'domain', 'user'); +} + +function actingAsAdmin(User $user, Store $store): User { - // .. + bindStore($store); + test()->actingAs($user, 'web'); + session(['current_store_id' => $store->id]); + + return $user; +} + +function actingAsCustomer(Customer $customer): Customer +{ + $store = Store::query()->findOrFail($customer->store_id); + bindStore($store); + test()->actingAs($customer, 'customer'); + + return $customer; +} + +/** + * Create an active SKU with explicit inventory. Factories intentionally do not + * hide whether inventory records are created by a service or by the caller. + * + * @param array $productAttributes + * @param array $variantAttributes + * @param array $inventoryAttributes + * @return array{product: Product, variant: ProductVariant, inventory: InventoryItem} + */ +function makeSellableVariant( + Store $store, + array $productAttributes = [], + array $variantAttributes = [], + array $inventoryAttributes = [], +): array { + bindStore($store); + $title = (string) ($productAttributes['title'] ?? 'Product '.Str::lower(Str::random(8))); + $product = Product::factory()->for($store)->create([ + 'title' => $title, + 'handle' => $productAttributes['handle'] ?? Str::slug($title).'-'.Str::lower(Str::random(5)), + 'status' => 'active', + 'published_at' => now(), + ...$productAttributes, + ]); + $variant = ProductVariant::factory()->for($product)->create([ + 'sku' => $variantAttributes['sku'] ?? 'SKU-'.Str::upper(Str::random(10)), + 'price_amount' => 2500, + 'currency' => $store->default_currency, + 'requires_shipping' => true, + 'status' => 'active', + ...$variantAttributes, + ]); + $inventory = InventoryItem::withoutGlobalScopes()->firstOrCreate( + ['variant_id' => $variant->id], + ['store_id' => $store->id], + ); + $inventory->fill([ + 'store_id' => $store->id, + 'quantity_on_hand' => 20, + 'quantity_reserved' => 0, + 'policy' => 'deny', + ...$inventoryAttributes, + ])->save(); + + return compact('product', 'variant', 'inventory'); +} + +/** + * @return array{cart: Cart, line: CartLine} + */ +function makeCartWithLine(Store $store, ProductVariant $variant, int $quantity = 1): array +{ + /** @var CartService $service */ + $service = app(CartService::class); + $cart = $service->create($store); + $line = $service->addLine($cart, $variant, $quantity, 1); + + return compact('cart', 'line'); +} + +/** @return array */ +function validCheckoutAddress(string $countryCode = 'DE', string $provinceCode = 'BE'): array +{ + return [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Ada', + 'last_name' => 'Lovelace', + 'address1' => 'Example Street 1', + 'city' => 'Berlin', + 'province_code' => $provinceCode, + 'country' => $countryCode, + 'country_code' => $countryCode, + 'postal_code' => '10115', + ], + ]; +} + +/** + * @return array{store: Store, variant: ProductVariant, cart: Cart, checkout: Checkout, rate: ?ShippingRate} + */ +function checkoutReadyForPayment(PaymentMethod $method = PaymentMethod::CreditCard, bool $digital = false, int $quantity = 2): array +{ + $store = createStoreContext()['store']; + $sku = makeSellableVariant( + $store, + productAttributes: ['title' => $digital ? 'Digital Guide' : 'Physical Guide'], + variantAttributes: [ + 'sku' => $digital ? 'DIGITAL-GUIDE' : 'PHYSICAL-GUIDE', + 'price_amount' => 2500, + 'requires_shipping' => ! $digital, + 'weight_g' => $digital ? 0 : 500, + ], + inventoryAttributes: ['quantity_on_hand' => 10], + ); + ['cart' => $cart] = makeCartWithLine($store, $sku['variant'], $quantity); + $rate = null; + if (! $digital) { + $zone = ShippingZone::factory()->for($store)->create(['countries_json' => ['DE'], 'regions_json' => []]); + $rate = ShippingRate::factory()->for($zone, 'zone')->create(['name' => 'Standard', 'config_json' => ['amount' => 499]]); + } + + $service = app(CheckoutService::class); + $checkout = $service->create($cart); + $checkout = $service->setAddress($checkout, validCheckoutAddress()); + $checkout = $service->setShippingMethod($checkout, $rate?->id); + $checkout = $service->selectPaymentMethod($checkout, $method); + + return ['store' => $store, 'variant' => $sku['variant'], 'cart' => $cart, 'checkout' => $checkout, 'rate' => $rate]; +} + +/** + * @return array{store: Store, variant: ProductVariant, order: Order, line: OrderLine, payment: Payment} + */ +function paidOrderFixture(int $quantity = 2, bool $digital = false): array +{ + $store = createStoreContext()['store']; + $sku = makeSellableVariant($store, variantAttributes: [ + 'price_amount' => 2500, + 'requires_shipping' => ! $digital, + 'weight_g' => $digital ? 0 : 500, + ], inventoryAttributes: ['quantity_on_hand' => 8]); + $order = Order::factory()->for($store)->create([ + 'customer_id' => null, + 'payment_method' => 'credit_card', + 'status' => 'paid', + 'financial_status' => 'paid', + 'fulfillment_status' => 'unfulfilled', + 'subtotal_amount' => 2500 * $quantity, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 2500 * $quantity, + ]); + $line = OrderLine::factory()->for($order)->create([ + 'product_id' => $sku['product']->id, + 'variant_id' => $sku['variant']->id, + 'title_snapshot' => $sku['product']->title, + 'sku_snapshot' => $sku['variant']->sku, + 'quantity' => $quantity, + 'unit_price_amount' => 2500, + 'total_amount' => 2500 * $quantity, + ]); + $payment = Payment::factory()->for($order)->create([ + 'method' => 'credit_card', + 'status' => 'captured', + 'amount' => 2500 * $quantity, + 'currency' => $store->default_currency, + ]); + + return ['store' => $store, 'variant' => $sku['variant'], 'order' => $order, 'line' => $line, 'payment' => $payment]; } diff --git a/tests/Unit/ShopCalculatorsTest.php b/tests/Unit/ShopCalculatorsTest.php new file mode 100644 index 00000000..49678f4c --- /dev/null +++ b/tests/Unit/ShopCalculatorsTest.php @@ -0,0 +1,199 @@ + 'Ada', + 'last_name' => 'Lovelace', + 'address1' => 'Example Street 1', + 'city' => 'Berlin', + 'country' => 'de', + 'zip' => '10115', + 'company' => '', + 'province_code' => 'BE', + ]); + + expect($address->countryCode)->toBe('DE') + ->and($address->postalCode)->toBe('10115') + ->and($address->company)->toBeNull() + ->and($address->toArray())->toMatchArray([ + 'first_name' => 'Ada', + 'country_code' => 'DE', + 'postal_code' => '10115', + 'province_code' => 'BE', + ]); +}); + +it('calculates deterministic integer subtotals', function () { + $lines = [ + ['unit_price_amount' => 2499, 'quantity' => 2], + ['unit_price_amount' => 7999, 'quantity' => 1], + ]; + + expect(unitPricingEngine()->calculateSubtotal($lines))->toBe(12997) + ->and(unitPricingEngine()->calculateSubtotal([]))->toBe(0) + ->and(unitPricingEngine()->calculateSubtotal($lines))->toBe(unitPricingEngine()->calculateSubtotal($lines)); +}); + +it('serializes a complete pricing snapshot in minor units', function () { + $result = new PricingResult(10000, 1000, 499, [new TaxLine('VAT', 1900, 1805)], 1805, 11304, 'EUR'); + + expect($result->toArray())->toBe([ + 'subtotal' => 10000, + 'discount' => 1000, + 'discounted_subtotal' => 9000, + 'shipping' => 499, + 'tax_lines' => [['name' => 'VAT', 'rate' => 1900, 'amount' => 1805]], + 'tax_total' => 1805, + 'tax' => 1805, + 'total' => 11304, + 'currency' => 'EUR', + ]); +}); + +it('calculates tax-exclusive values per line and inclusive extraction', function () { + $exclusive = new TaxSettings([ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => false, + 'config_json' => ['rate_bps' => 1900, 'label' => 'VAT'], + ]); + $inclusive = new TaxSettings([ + 'mode' => 'manual', + 'provider' => 'none', + 'prices_include_tax' => true, + 'config_json' => ['rate_bps' => 1900, 'label' => 'VAT'], + ]); + $calculator = unitTaxCalculator(); + + expect($calculator->calculateLines([5000, 499], 0, $exclusive)->totalAmount)->toBe(1045) + ->and($calculator->calculate(11900, $inclusive)->totalAmount)->toBe(1900) + ->and($calculator->extractInclusive(11900, 1900))->toBe(1900) + ->and($calculator->addExclusive(8999, 700))->toBe(630) + ->and($calculator->calculate(10000, null)->totalAmount)->toBe(0); +}); + +it('calculates discount values and preserves allocation cents', function () { + $service = new DiscountService; + $lines = collect([ + ['id' => 11, 'product_id' => 1, 'line_subtotal_amount' => 7500], + ['id' => 12, 'product_id' => 2, 'line_subtotal_amount' => 2500], + ]); + $percent = new Discount(['value_type' => 'percent', 'value_amount' => 10, 'rules_json' => []]); + $fixed = new Discount(['value_type' => 'fixed', 'value_amount' => 12000, 'rules_json' => []]); + $shipping = new Discount(['value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => []]); + + $percentResult = $service->calculate($percent, 10000, $lines); + $fixedResult = $service->calculate($fixed, 10000, $lines); + $shippingResult = $service->calculate($shipping, 10000, $lines); + + expect($percentResult->amount)->toBe(1000) + ->and($percentResult->allocations)->toBe([11 => 750, 12 => 250]) + ->and(array_sum($percentResult->allocations))->toBe($percentResult->amount) + ->and($fixedResult->amount)->toBe(10000) + ->and($shippingResult->amount)->toBe(0) + ->and($shippingResult->freeShipping)->toBeTrue(); +}); + +it('assigns a discount rounding remainder to the final qualifying line', function () { + $discount = new Discount(['value_type' => 'percent', 'value_amount' => 10, 'rules_json' => []]); + $lines = [ + ['id' => 1, 'line_subtotal_amount' => 333], + ['id' => 2, 'line_subtotal_amount' => 333], + ['id' => 3, 'line_subtotal_amount' => 334], + ]; + + $result = (new DiscountService)->calculate($discount, 1000, $lines); + + expect($result->amount)->toBe(100) + ->and(array_sum($result->allocations))->toBe(100) + ->and($result->allocations)->toBe([1 => 33, 2 => 33, 3 => 34]); +}); + +it('calculates flat weight price and digital shipping', function () { + $physical = new ProductVariant(['requires_shipping' => true, 'weight_g' => 750]); + $digital = new ProductVariant(['requires_shipping' => false, 'weight_g' => 0]); + $physicalLine = new CartLine(['quantity' => 1, 'line_subtotal_amount' => 7500]); + $physicalLine->setRelation('variant', $physical); + $digitalLine = new CartLine(['quantity' => 1, 'line_subtotal_amount' => 2500]); + $digitalLine->setRelation('variant', $digital); + $cart = new Cart; + $cart->setRelation('lines', new Collection([$physicalLine, $digitalLine])); + $digitalCart = new Cart; + $digitalCart->setRelation('lines', new Collection([$digitalLine])); + $calculator = new ShippingCalculator; + + $flat = new ShippingRate(['type' => 'flat', 'config_json' => ['amount' => 499]]); + $weight = new ShippingRate(['type' => 'weight', 'config_json' => ['ranges' => [ + ['min_g' => 0, 'max_g' => 500, 'amount' => 499], + ['min_g' => 501, 'max_g' => 2000, 'amount' => 899], + ]]]); + $price = new ShippingRate(['type' => 'price', 'config_json' => ['ranges' => [ + ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799], + ['min_amount' => 5001, 'amount' => 399], + ]]]); + + expect($calculator->calculate($flat, $cart))->toBe(499) + ->and($calculator->calculate($weight, $cart))->toBe(899) + ->and($calculator->calculate($price, $cart))->toBe(399) + ->and($calculator->calculate($flat, $digitalCart))->toBe(0); +}); + +it('simulates each payment outcome', function (PaymentMethod $method, array $details, bool $success, string $status, ?string $errorCode) { + $result = (new MockPaymentProvider)->charge(new Checkout, $method, $details); + + expect($result->success)->toBe($success) + ->and($result->status)->toBe($status) + ->and($result->errorCode)->toBe($errorCode) + ->and($result->referenceId)->toStartWith('mock_'); +})->with([ + 'credit card captured' => [PaymentMethod::CreditCard, ['card_number' => '4242 4242 4242 4242'], true, 'captured', null], + 'credit card declined' => [PaymentMethod::CreditCard, ['card_number' => '4000 0000 0000 0002'], false, 'failed', 'card_declined'], + 'insufficient funds' => [PaymentMethod::CreditCard, ['card_number' => '4000 0000 0000 9995'], false, 'failed', 'insufficient_funds'], + 'paypal captured' => [PaymentMethod::Paypal, [], true, 'captured', null], + 'bank transfer pending' => [PaymentMethod::BankTransfer, [], true, 'pending', null], +]); + +it('signs and verifies webhook payloads without timing-sensitive comparisons', function () { + $payload = '{"event":"order.created"}'; + $service = new WebhookService; + $signature = $service->sign($payload, 'test-secret'); + + expect($signature)->toBe(hash_hmac('sha256', $payload, 'test-secret')) + ->and($service->verify($payload, $signature, 'test-secret'))->toBeTrue() + ->and($service->verify('{"event":"tampered"}', $signature, 'test-secret'))->toBeFalse() + ->and($service->verify($payload, $signature, 'wrong-secret'))->toBeFalse(); +}); From 66b42d49ebf3ba6c572c86ef1f0c484665b9eb9e Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 23:54:31 +0200 Subject: [PATCH 07/10] fix: remove missing favicon fallback --- .../console-2026-07-12T21-52-37-420Z.log | 2 + .../page-2026-07-12T21-52-37-781Z.yml | 140 ++++++++++++++++++ .../components/storefront/layout.blade.php | 1 - resources/views/partials/head.blade.php | 1 - resources/views/welcome.blade.php | 1 - 5 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 .playwright-mcp/console-2026-07-12T21-52-37-420Z.log create mode 100644 .playwright-mcp/page-2026-07-12T21-52-37-781Z.yml diff --git a/.playwright-mcp/console-2026-07-12T21-52-37-420Z.log b/.playwright-mcp/console-2026-07-12T21-52-37-420Z.log new file mode 100644 index 00000000..0ece0918 --- /dev/null +++ b/.playwright-mcp/console-2026-07-12T21-52-37-420Z.log @@ -0,0 +1,2 @@ +[ 175ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:51 +[ 371ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-07-12T21-52-37-781Z.yml b/.playwright-mcp/page-2026-07-12T21-52-37-781Z.yml new file mode 100644 index 00000000..71606eda --- /dev/null +++ b/.playwright-mcp/page-2026-07-12T21-52-37-781Z.yml @@ -0,0 +1,140 @@ +- generic [active] [ref=e1]: + - link "Skip to main content" [ref=e2] [cursor=pointer]: + - /url: "#main-content" + - generic [ref=e3]: + - text: Free shipping on orders over 50 EUR - Use code FREESHIP + - button "Dismiss announcement" [ref=e4]: Γ— + - banner [ref=e5]: + - navigation "Primary navigation" [ref=e6]: + - link "Acme Fashion" [ref=e7] [cursor=pointer]: + - /url: http://shop.test + - list [ref=e8]: + - listitem [ref=e9]: + - link "Home" [ref=e10] [cursor=pointer]: + - /url: / + - listitem [ref=e11]: + - link "New Arrivals" [ref=e12] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - listitem [ref=e13]: + - link "T-Shirts" [ref=e14] [cursor=pointer]: + - /url: http://shop.test/collections/t-shirts + - listitem [ref=e15]: + - link "Pants & Jeans" [ref=e16] [cursor=pointer]: + - /url: http://shop.test/collections/pants-jeans + - listitem [ref=e17]: + - link "Sale" [ref=e18] [cursor=pointer]: + - /url: http://shop.test/collections/sale + - generic [ref=e19]: + - button "Search" [ref=e20] + - link "Log in" [ref=e24] [cursor=pointer]: + - /url: http://shop.test/account/login + - button "Open cart" [ref=e28] + - main [ref=e31]: + - generic [ref=e32]: + - region [ref=e33]: + - generic [ref=e36]: + - paragraph [ref=e37]: Acme Fashion + - heading "Welcome to Acme Fashion" [level=1] [ref=e38] + - paragraph [ref=e39]: Discover our curated collection of modern essentials + - link "Shop New Arrivals" [ref=e40] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - region [ref=e43]: + - generic [ref=e44]: + - generic [ref=e45]: + - generic [ref=e46]: + - paragraph [ref=e47]: Curated for you + - heading "Shop collections" [level=2] [ref=e48] + - link "View all" [ref=e49] [cursor=pointer]: + - /url: http://shop.test/collections + - text: View all + - generic [ref=e50]: β†’ + - generic [ref=e51]: + - link [ref=e52] [cursor=pointer]: + - /url: http://shop.test/collections/new-arrivals + - generic [ref=e55]: + - heading "New Arrivals" [level=3] [ref=e56] + - generic [ref=e57]: + - text: Shop now + - generic [ref=e58]: β†’ + - link [ref=e59] [cursor=pointer]: + - /url: http://shop.test/collections/t-shirts + - generic [ref=e62]: + - heading "T-Shirts" [level=3] [ref=e63] + - generic [ref=e64]: + - text: Shop now + - generic [ref=e65]: β†’ + - link [ref=e66] [cursor=pointer]: + - /url: http://shop.test/collections/pants-jeans + - generic [ref=e69]: + - heading "Pants & Jeans" [level=3] [ref=e70] + - generic [ref=e71]: + - text: Shop now + - generic [ref=e72]: β†’ + - link [ref=e73] [cursor=pointer]: + - /url: http://shop.test/collections/sale + - generic [ref=e76]: + - heading "Sale" [level=3] [ref=e77] + - generic [ref=e78]: + - text: Shop now + - generic [ref=e79]: β†’ + - region [ref=e80]: + - generic [ref=e81]: + - generic [ref=e83]: + - paragraph [ref=e84]: New and noteworthy + - heading "Featured products" [level=2] [ref=e85] + - generic "Loading products" [ref=e86] + - region [ref=e119]: + - generic [ref=e120]: + - paragraph [ref=e121]: A note from us + - heading "Stay in the loop" [level=2] [ref=e122] + - paragraph [ref=e123]: Subscribe for thoughtful product updates, useful stories, and occasional offers. + - generic [ref=e125]: + - generic [ref=e126]: Email address + - textbox "Email address" [ref=e127]: + - /placeholder: Enter your email + - button "Subscribe" [ref=e128] + - contentinfo "Store footer" [ref=e130]: + - generic [ref=e131]: + - generic [ref=e132]: + - generic [ref=e133]: + - heading "Acme Fashion" [level=2] [ref=e134] + - paragraph + - generic [ref=e135]: + - heading "Explore" [level=2] [ref=e136] + - list [ref=e137]: + - listitem [ref=e138]: + - link "About Us" [ref=e139] [cursor=pointer]: + - /url: http://shop.test/pages/about + - listitem [ref=e140]: + - link "FAQ" [ref=e141] [cursor=pointer]: + - /url: http://shop.test/pages/faq + - listitem [ref=e142]: + - link "Shipping & Returns" [ref=e143] [cursor=pointer]: + - /url: http://shop.test/pages/shipping-returns + - listitem [ref=e144]: + - link "Privacy Policy" [ref=e145] [cursor=pointer]: + - /url: http://shop.test/pages/privacy-policy + - listitem [ref=e146]: + - link "Terms of Service" [ref=e147] [cursor=pointer]: + - /url: http://shop.test/pages/terms + - generic [ref=e148]: + - heading "Customer care" [level=2] [ref=e149] + - list [ref=e150]: + - listitem [ref=e151]: + - link "Your account" [ref=e152] [cursor=pointer]: + - /url: http://shop.test/account + - listitem [ref=e153]: + - link "Your cart" [ref=e154] [cursor=pointer]: + - /url: http://shop.test/cart + - listitem [ref=e155]: + - link "Search" [ref=e156] [cursor=pointer]: + - /url: http://shop.test/search + - generic [ref=e157]: + - heading "Store" [level=2] [ref=e158] + - paragraph [ref=e159]: Secure checkoutMock payments for demonstrationEUR pricing + - generic [ref=e160]: + - paragraph [ref=e161]: Β© 2026 Acme Fashion. All rights reserved. + - generic "Accepted payment methods" [ref=e162]: + - generic [ref=e163]: Visa + - generic [ref=e164]: Mastercard + - generic [ref=e165]: PayPal \ No newline at end of file diff --git a/resources/views/components/storefront/layout.blade.php b/resources/views/components/storefront/layout.blade.php index fc5a0a0f..a811d01b 100644 --- a/resources/views/components/storefront/layout.blade.php +++ b/resources/views/components/storefront/layout.blade.php @@ -20,7 +20,6 @@ {{ $documentTitle }} - diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index dce80588..9f5d9ec7 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -3,7 +3,6 @@ {{ $title ?? config('app.name') }} - diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index a808a399..827943ca 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -6,7 +6,6 @@ Laravel - From 705746898fd2704347f25a154bd2f4dd1401572c Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Sun, 12 Jul 2026 23:54:50 +0200 Subject: [PATCH 08/10] chore: ignore browser review artifacts --- .gitignore | 1 + .../console-2026-07-12T21-52-37-420Z.log | 2 - .../page-2026-07-12T21-52-37-781Z.yml | 140 ------------------ 3 files changed, 1 insertion(+), 142 deletions(-) delete mode 100644 .playwright-mcp/console-2026-07-12T21-52-37-420Z.log delete mode 100644 .playwright-mcp/page-2026-07-12T21-52-37-781Z.yml diff --git a/.gitignore b/.gitignore index c7cf1fa6..4582f38c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.phpunit.cache +/.playwright-mcp/ /node_modules /public/build /public/hot diff --git a/.playwright-mcp/console-2026-07-12T21-52-37-420Z.log b/.playwright-mcp/console-2026-07-12T21-52-37-420Z.log deleted file mode 100644 index 0ece0918..00000000 --- a/.playwright-mcp/console-2026-07-12T21-52-37-420Z.log +++ /dev/null @@ -1,2 +0,0 @@ -[ 175ms] [LOG] πŸ” Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:51 -[ 371ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://shop.test/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-07-12T21-52-37-781Z.yml b/.playwright-mcp/page-2026-07-12T21-52-37-781Z.yml deleted file mode 100644 index 71606eda..00000000 --- a/.playwright-mcp/page-2026-07-12T21-52-37-781Z.yml +++ /dev/null @@ -1,140 +0,0 @@ -- generic [active] [ref=e1]: - - link "Skip to main content" [ref=e2] [cursor=pointer]: - - /url: "#main-content" - - generic [ref=e3]: - - text: Free shipping on orders over 50 EUR - Use code FREESHIP - - button "Dismiss announcement" [ref=e4]: Γ— - - banner [ref=e5]: - - navigation "Primary navigation" [ref=e6]: - - link "Acme Fashion" [ref=e7] [cursor=pointer]: - - /url: http://shop.test - - list [ref=e8]: - - listitem [ref=e9]: - - link "Home" [ref=e10] [cursor=pointer]: - - /url: / - - listitem [ref=e11]: - - link "New Arrivals" [ref=e12] [cursor=pointer]: - - /url: http://shop.test/collections/new-arrivals - - listitem [ref=e13]: - - link "T-Shirts" [ref=e14] [cursor=pointer]: - - /url: http://shop.test/collections/t-shirts - - listitem [ref=e15]: - - link "Pants & Jeans" [ref=e16] [cursor=pointer]: - - /url: http://shop.test/collections/pants-jeans - - listitem [ref=e17]: - - link "Sale" [ref=e18] [cursor=pointer]: - - /url: http://shop.test/collections/sale - - generic [ref=e19]: - - button "Search" [ref=e20] - - link "Log in" [ref=e24] [cursor=pointer]: - - /url: http://shop.test/account/login - - button "Open cart" [ref=e28] - - main [ref=e31]: - - generic [ref=e32]: - - region [ref=e33]: - - generic [ref=e36]: - - paragraph [ref=e37]: Acme Fashion - - heading "Welcome to Acme Fashion" [level=1] [ref=e38] - - paragraph [ref=e39]: Discover our curated collection of modern essentials - - link "Shop New Arrivals" [ref=e40] [cursor=pointer]: - - /url: http://shop.test/collections/new-arrivals - - region [ref=e43]: - - generic [ref=e44]: - - generic [ref=e45]: - - generic [ref=e46]: - - paragraph [ref=e47]: Curated for you - - heading "Shop collections" [level=2] [ref=e48] - - link "View all" [ref=e49] [cursor=pointer]: - - /url: http://shop.test/collections - - text: View all - - generic [ref=e50]: β†’ - - generic [ref=e51]: - - link [ref=e52] [cursor=pointer]: - - /url: http://shop.test/collections/new-arrivals - - generic [ref=e55]: - - heading "New Arrivals" [level=3] [ref=e56] - - generic [ref=e57]: - - text: Shop now - - generic [ref=e58]: β†’ - - link [ref=e59] [cursor=pointer]: - - /url: http://shop.test/collections/t-shirts - - generic [ref=e62]: - - heading "T-Shirts" [level=3] [ref=e63] - - generic [ref=e64]: - - text: Shop now - - generic [ref=e65]: β†’ - - link [ref=e66] [cursor=pointer]: - - /url: http://shop.test/collections/pants-jeans - - generic [ref=e69]: - - heading "Pants & Jeans" [level=3] [ref=e70] - - generic [ref=e71]: - - text: Shop now - - generic [ref=e72]: β†’ - - link [ref=e73] [cursor=pointer]: - - /url: http://shop.test/collections/sale - - generic [ref=e76]: - - heading "Sale" [level=3] [ref=e77] - - generic [ref=e78]: - - text: Shop now - - generic [ref=e79]: β†’ - - region [ref=e80]: - - generic [ref=e81]: - - generic [ref=e83]: - - paragraph [ref=e84]: New and noteworthy - - heading "Featured products" [level=2] [ref=e85] - - generic "Loading products" [ref=e86] - - region [ref=e119]: - - generic [ref=e120]: - - paragraph [ref=e121]: A note from us - - heading "Stay in the loop" [level=2] [ref=e122] - - paragraph [ref=e123]: Subscribe for thoughtful product updates, useful stories, and occasional offers. - - generic [ref=e125]: - - generic [ref=e126]: Email address - - textbox "Email address" [ref=e127]: - - /placeholder: Enter your email - - button "Subscribe" [ref=e128] - - contentinfo "Store footer" [ref=e130]: - - generic [ref=e131]: - - generic [ref=e132]: - - generic [ref=e133]: - - heading "Acme Fashion" [level=2] [ref=e134] - - paragraph - - generic [ref=e135]: - - heading "Explore" [level=2] [ref=e136] - - list [ref=e137]: - - listitem [ref=e138]: - - link "About Us" [ref=e139] [cursor=pointer]: - - /url: http://shop.test/pages/about - - listitem [ref=e140]: - - link "FAQ" [ref=e141] [cursor=pointer]: - - /url: http://shop.test/pages/faq - - listitem [ref=e142]: - - link "Shipping & Returns" [ref=e143] [cursor=pointer]: - - /url: http://shop.test/pages/shipping-returns - - listitem [ref=e144]: - - link "Privacy Policy" [ref=e145] [cursor=pointer]: - - /url: http://shop.test/pages/privacy-policy - - listitem [ref=e146]: - - link "Terms of Service" [ref=e147] [cursor=pointer]: - - /url: http://shop.test/pages/terms - - generic [ref=e148]: - - heading "Customer care" [level=2] [ref=e149] - - list [ref=e150]: - - listitem [ref=e151]: - - link "Your account" [ref=e152] [cursor=pointer]: - - /url: http://shop.test/account - - listitem [ref=e153]: - - link "Your cart" [ref=e154] [cursor=pointer]: - - /url: http://shop.test/cart - - listitem [ref=e155]: - - link "Search" [ref=e156] [cursor=pointer]: - - /url: http://shop.test/search - - generic [ref=e157]: - - heading "Store" [level=2] [ref=e158] - - paragraph [ref=e159]: Secure checkoutMock payments for demonstrationEUR pricing - - generic [ref=e160]: - - paragraph [ref=e161]: Β© 2026 Acme Fashion. All rights reserved. - - generic "Accepted payment methods" [ref=e162]: - - generic [ref=e163]: Visa - - generic [ref=e164]: Mastercard - - generic [ref=e165]: PayPal \ No newline at end of file From e5d567cd7fe9993d029747a9e4f8fe53d008d3b9 Mon Sep 17 00:00:00 2001 From: Fabian Wesner Date: Mon, 13 Jul 2026 00:32:09 +0200 Subject: [PATCH 09/10] fix: harden browser acceptance workflows --- .../Api/Storefront/CheckoutController.php | 10 +++ app/Livewire/Storefront/Checkout/Show.php | 3 + .../Storefront/Concerns/ManagesCart.php | 25 ++---- app/Providers/AppServiceProvider.php | 4 + app/Services/CartService.php | 49 +++++++---- .../views/admin/discounts/form.blade.php | 6 +- .../views/admin/settings/shipping.blade.php | 2 +- .../views/admin/settings/taxes.blade.php | 12 ++- resources/views/admin/themes/editor.blade.php | 2 +- .../views/components/admin/radio.blade.php | 24 ++++++ .../views/components/admin/switch.blade.php | 28 +++++++ .../account/addresses/index.blade.php | 2 +- .../storefront/account/dashboard.blade.php | 2 +- .../storefront/account/orders/index.blade.php | 4 +- .../checkout/confirmation.blade.php | 2 +- .../views/storefront/layouts/app.blade.php | 7 +- specs/progress.md | 23 ++++-- .../Feature/Shop/AdminSwitchRenderingTest.php | 81 +++++++++++++++++++ tests/Feature/Shop/CatalogCartPricingTest.php | 23 ++++++ tests/Feature/Shop/CustomerAccountsTest.php | 31 +++++++ .../Shop/LivewireTenantMiddlewareTest.php | 9 +++ tests/Feature/Shop/StorefrontApiTest.php | 18 ++++- 22 files changed, 309 insertions(+), 58 deletions(-) create mode 100644 resources/views/components/admin/radio.blade.php create mode 100644 resources/views/components/admin/switch.blade.php create mode 100644 tests/Feature/Shop/AdminSwitchRenderingTest.php create mode 100644 tests/Feature/Shop/LivewireTenantMiddlewareTest.php diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php index 14e27a56..ebe59c07 100644 --- a/app/Http/Controllers/Api/Storefront/CheckoutController.php +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Controller; use App\Models\Cart; use App\Models\Checkout; +use App\Services\CartService; use App\Services\CheckoutService; use App\Services\ShippingCalculator; use Illuminate\Http\JsonResponse; @@ -21,6 +22,7 @@ final class CheckoutController extends Controller { public function __construct( private readonly CheckoutService $checkouts, + private readonly CartService $carts, private readonly ShippingCalculator $shipping, ) {} @@ -166,6 +168,14 @@ public function pay(Request $request, int $checkoutId): JsonResponse 'currency' => $order->currency, ], ]; + $nextCart = $this->carts->getOrCreateForSession(app('current_store'), auth('customer')->user()); + $request->session()->put('api_cart_ids', collect($request->session()->get('api_cart_ids', [])) + ->push($nextCart->id)->unique()->take(-20)->values()->all()); + $response['next_cart'] = [ + 'id' => $nextCart->id, + 'status' => $nextCart->status instanceof \BackedEnum ? $nextCart->status->value : $nextCart->status, + 'item_count' => (int) $nextCart->lines()->sum('quantity'), + ]; if ($method === 'bank_transfer') { $response['bank_transfer_instructions'] = [ 'bank_name' => 'Mock Bank AG', diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php index b7abc7af..3ae4ec8f 100644 --- a/app/Livewire/Storefront/Checkout/Show.php +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -6,6 +6,7 @@ use App\Models\Checkout; use App\Models\CustomerAddress; use App\Models\ShippingRate; +use App\Services\CartService; use App\Services\CheckoutService; use App\Services\DiscountService; use App\Services\PricingEngine; @@ -259,6 +260,8 @@ public function pay(): mixed session()->put('last_order_id', $order->getKey()); session()->put('checkout_access.'.$this->checkout->getKey(), true); + app(CartService::class)->getOrCreateForSession($this->currentStore(), Auth::guard('customer')->user()); + session()->forget(['cart_discount_code', 'cart_discount_amount', 'cart_free_shipping']); return $this->redirect(url('/checkout/'.$this->checkout->getRouteKey().'/confirmation'), navigate: true); } catch (\Throwable $exception) { diff --git a/app/Livewire/Storefront/Concerns/ManagesCart.php b/app/Livewire/Storefront/Concerns/ManagesCart.php index ea70ffb7..87d84120 100644 --- a/app/Livewire/Storefront/Concerns/ManagesCart.php +++ b/app/Livewire/Storefront/Concerns/ManagesCart.php @@ -25,26 +25,13 @@ trait ManagesCart public function cart(): ?Cart { $store = $this->currentStore(); - $cartId = session('cart_id'); - - $query = Cart::query() - ->where('store_id', $store->getKey()) - ->where('status', 'active') - ->with([ - 'lines.variant.product.media', - 'lines.variant.optionValues.option', - 'lines.variant.inventoryItem', - ]); - - if ($cartId) { - $query->whereKey($cartId); - } elseif ($customer = Auth::guard('customer')->user()) { - $query->where('customer_id', $customer->getAuthIdentifier())->latest('updated_at'); - } else { - return null; - } + $cart = app(CartService::class)->resolveForSession($store, Auth::guard('customer')->user()); - return $query->first(); + return $cart?->load([ + 'lines.variant.product.media', + 'lines.variant.optionValues.option', + 'lines.variant.inventoryItem', + ]); } #[Computed] diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f9dbfba6..bee61a39 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Contracts\PaymentProvider; use App\Contracts\TaxProvider; use App\Enums\StoreUserRole; +use App\Http\Middleware\ResolveStore; use App\Listeners\ShopEventSubscriber; use App\Models\Collection; use App\Models\Customer; @@ -50,6 +51,7 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { @@ -69,6 +71,8 @@ public function register(): void */ public function boot(): void { + Livewire::addPersistentMiddleware(ResolveStore::class); + $this->configureDefaults(); $this->configureAuthentication(); $this->configureRateLimits(); diff --git a/app/Services/CartService.php b/app/Services/CartService.php index 3909d7ed..3f3395be 100644 --- a/app/Services/CartService.php +++ b/app/Services/CartService.php @@ -30,19 +30,19 @@ public function create(Store $store, ?Customer $customer = null, ?string $curren public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart { - if ($customer !== null) { - $customerCart = Cart::withoutGlobalScopes() - ->where('store_id', $store->id) - ->where('customer_id', $customer->id) - ->where('status', 'active') - ->latest('id') - ->first(); - - if ($customerCart !== null) { - return $customerCart; - } + $cart = $this->resolveForSession($store, $customer); + if ($cart !== null) { + return $cart; } + $cart = $this->create($store, $customer); + session(['cart_id' => $cart->id]); + + return $cart; + } + + public function resolveForSession(Store $store, ?Customer $customer = null): ?Cart + { $cartId = session('cart_id'); if ($cartId !== null) { $cart = Cart::withoutGlobalScopes() @@ -50,13 +50,27 @@ public function getOrCreateForSession(Store $store, ?Customer $customer = null): ->where('status', 'active') ->find($cartId); - if ($cart !== null) { + if ($cart !== null && $this->belongsToCustomer($cart, $customer)) { return $cart; } + + session()->forget('cart_id'); } - $cart = $this->create($store, $customer); - session(['cart_id' => $cart->id]); + if ($customer === null) { + return null; + } + + $cart = Cart::withoutGlobalScopes() + ->where('store_id', $store->id) + ->where('customer_id', $customer->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + if ($cart !== null) { + session(['cart_id' => $cart->id]); + } return $cart; } @@ -239,4 +253,11 @@ private function enumValue(mixed $value): string { return $value instanceof BackedEnum ? (string) $value->value : (string) $value; } + + private function belongsToCustomer(Cart $cart, ?Customer $customer): bool + { + return $customer === null + ? $cart->customer_id === null + : (int) $cart->customer_id === (int) $customer->id; + } } diff --git a/resources/views/admin/discounts/form.blade.php b/resources/views/admin/discounts/form.blade.php index 33d23a5b..075eb5a1 100644 --- a/resources/views/admin/discounts/form.blade.php +++ b/resources/views/admin/discounts/form.blade.php @@ -1,8 +1,8 @@
      -
      +
      Discount type
      @if($type === 'code')
      Generate
      @endif -
      @if($valueType !== 'free_shipping')
      @endif
      +
      Discount value type
      @if($valueType !== 'free_shipping')
      @endif
      @@ -12,6 +12,6 @@
      - + diff --git a/resources/views/admin/settings/shipping.blade.php b/resources/views/admin/settings/shipping.blade.php index 64c87d2b..75d36812 100644 --- a/resources/views/admin/settings/shipping.blade.php +++ b/resources/views/admin/settings/shipping.blade.php @@ -19,5 +19,5 @@
      @foreach($this->countries as $code => $name){{ $name }}@endforeach
      Test
      @if($testResult)
      @if($testResult['zone'])

      Matched zone: {{ $testResult['zone'] }}

        @foreach($testResult['rates'] as $resultRate)
      • {{ $resultRate['name'] }} β€”
      • @endforeach
      @else

      No shipping zone matches this address.

      @endif
      @endif
      {{ $editingZone ? 'Edit shipping zone' : 'Add shipping zone' }}
      Countries
      @foreach($this->countries as $code => $name)@endforeach
      @error('zoneCountries')

      {{ $message }}

      @enderror
      CancelSave zone
      -
      {{ $editingRate ? 'Edit shipping rate' : 'Add shipping rate' }}Flat rateWeight-basedPrice-basedCarrier-calculated@if($rateType === 'weight')
      @elseif($rateType === 'price')
      @elseif($rateType === 'carrier')

      Carrier-calculated rates require a carrier integration to be configured.

      @endif @unless($rateType === 'carrier')@endunless
      CancelSave
      +
      {{ $editingRate ? 'Edit shipping rate' : 'Add shipping rate' }}Flat rateWeight-basedPrice-basedCarrier-calculated@if($rateType === 'weight')
      @elseif($rateType === 'price')
      @elseif($rateType === 'carrier')

      Carrier-calculated rates require a carrier integration to be configured.

      @endif @unless($rateType === 'carrier')@endunless
      CancelSave
      diff --git a/resources/views/admin/settings/taxes.blade.php b/resources/views/admin/settings/taxes.blade.php index 12626313..476fb45a 100644 --- a/resources/views/admin/settings/taxes.blade.php +++ b/resources/views/admin/settings/taxes.blade.php @@ -1,8 +1,16 @@
      -
      + +
      + Calculation mode +
      + + +
      +
      +
      @if($mode === 'manual')
      @foreach($manualRates as $index => $rate)
      @endforeach
      Add rate
      @else
      Stripe TaxNone
      @endif - +
      Save
      diff --git a/resources/views/admin/themes/editor.blade.php b/resources/views/admin/themes/editor.blade.php index a42c7944..5598be64 100644 --- a/resources/views/admin/themes/editor.blade.php +++ b/resources/views/admin/themes/editor.blade.php @@ -5,7 +5,7 @@