From 52485dbf42df827d7f327c9e3152b843c5691e31 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:53 +0330 Subject: [PATCH 01/55] refactor(infra): pin the shared network name and add healthchecks The network was declared as `net` and only became shop_flow_net because the project happened to be named shop_flow. An explicit `name:` removes that coincidence, so admin/ and shop/ can join it as an external network whatever project creates it. The pg_isready and redis-cli healthchecks let the app containers wait for real readiness rather than for the container merely to exist. Also drops a top-level `pgdata` volume declaration that nothing referenced. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- infrastructure/docker/docker-compose.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/infrastructure/docker/docker-compose.yml b/infrastructure/docker/docker-compose.yml index c7f991f3..322fe5d8 100755 --- a/infrastructure/docker/docker-compose.yml +++ b/infrastructure/docker/docker-compose.yml @@ -12,6 +12,12 @@ services: - ./volumes/postgres/data:/var/lib/postgresql/data ports: - 127.0.0.1:${POSTGRES_EXPOSE_PORT}:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"] + interval: 5s + timeout: 5s + retries: 12 + start_period: 30s networks: - net @@ -24,12 +30,17 @@ services: command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD}" volumes: - ./volumes/redis:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"${REDIS_PASSWORD}\" ping | grep -q PONG"] + interval: 5s + timeout: 5s + retries: 12 networks: - net +# The explicit `name` pins the real network to shop_flow_net no matter which +# project creates it, so admin/ and shop/ can join it as an external network. networks: net: + name: shop_flow_net driver: bridge - -volumes: - pgdata: From ca8ac063c4d31e5d6ab9725c9a23b37166dabe89 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 02/55] refactor(shop): give the docker services unique names `app` and `webserver` collide with admin's services once the root compose.yaml merges both files into one project: Compose `include` silently keeps the first definition and drops the second rather than reporting a conflict. Container names are unchanged. They now interpolate SHOP_CONTAINER_PREFIX, because COMPOSE_PROJECT_NAME is reserved and under the root project resolves to shop_flow for this file and admin/ alike, which made both nginx containers claim the same name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/docker-compose.yml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/shop/docker/docker-compose.yml b/shop/docker/docker-compose.yml index 5b99e320..d7976c20 100644 --- a/shop/docker/docker-compose.yml +++ b/shop/docker/docker-compose.yml @@ -1,5 +1,12 @@ +# Service names are prefixed because the root compose.yaml merges this file +# with admin/ and infrastructure/ into one project, where a duplicate service +# name would silently override the other definition. +# +# Container names use SHOP_CONTAINER_PREFIX rather than COMPOSE_PROJECT_NAME: +# the latter is reserved, so under the root project it would resolve to +# shop_flow for this file and admin/ alike and the two would collide. services: - app: + shop_app: build: args: gid: ${GROUP_ID} @@ -7,25 +14,28 @@ services: context: ./ dockerfile: Dockerfile image: shop_website - container_name: "${COMPOSE_PROJECT_NAME}_app" + container_name: "${SHOP_CONTAINER_PREFIX:-shop_flow_shop}_app" restart: unless-stopped volumes: - ../../:/var/www/html networks: - - shop_flow_net + - net - webserver: + shop_nginx: image: nginx:alpine3.19 - container_name: "${COMPOSE_PROJECT_NAME}_nginx" + container_name: "${SHOP_CONTAINER_PREFIX:-shop_flow_shop}_nginx" restart: unless-stopped + depends_on: + - shop_app ports: - "127.0.0.1:${NGINX_EXPOSE_PORT}:80" volumes: - ../../:/var/www/html - ./volumes/nginx:/etc/nginx/conf.d/ networks: - - shop_flow_net + - net networks: - shop_flow_net: + net: + name: shop_flow_net external: true From c61ec97915ec415a76eaac4fddb84ee419e3ba8f Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 03/55] refactor(admin): give the docker services unique names `app` and `webserver` collide with the storefront's services once the root compose.yaml merges both files into one project: Compose `include` silently keeps the first definition and drops the second rather than reporting a conflict. Container names are unchanged. They now interpolate ADMIN_CONTAINER_PREFIX, because COMPOSE_PROJECT_NAME is reserved and under the root project resolves to shop_flow for this file and shop/ alike, which made both nginx containers claim the same name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/docker-compose.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/admin/docker/docker-compose.yml b/admin/docker/docker-compose.yml index 3a493a70..2e1c86d5 100755 --- a/admin/docker/docker-compose.yml +++ b/admin/docker/docker-compose.yml @@ -1,5 +1,12 @@ +# Service names are prefixed because the root compose.yaml merges this file +# with shop/ and infrastructure/ into one project, where a duplicate service +# name would silently override the other definition. +# +# Container names use ADMIN_CONTAINER_PREFIX rather than COMPOSE_PROJECT_NAME: +# the latter is reserved, so under the root project it would resolve to +# shop_flow for this file and shop/ alike and the two would collide. services: - app: + admin_app: build: args: gid: ${GROUP_ID} @@ -7,26 +14,28 @@ services: context: ./ dockerfile: Dockerfile image: website - container_name: "${COMPOSE_PROJECT_NAME}_app" + container_name: "${ADMIN_CONTAINER_PREFIX:-shop_flow_admin}_app" restart: unless-stopped volumes: - ../../:/var/www/html networks: - - shop_flow_net + - net - webserver: + admin_nginx: image: nginx:alpine3.19 - container_name: "${COMPOSE_PROJECT_NAME}_nginx" + container_name: "${ADMIN_CONTAINER_PREFIX:-shop_flow_admin}_nginx" restart: unless-stopped + depends_on: + - admin_app ports: - "127.0.0.1:${NGINX_EXPOSE_PORT}:80" volumes: - ../../:/var/www/html - ./volumes/nginx:/etc/nginx/conf.d/ networks: - - shop_flow_net + - net networks: - shop_flow_net: + net: + name: shop_flow_net external: true - From 3df6ca607ac85701bbe3651bb535fa481787313e Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 04/55] chore(shop): document SHOP_CONTAINER_PREFIX The compose file falls back to the previous value when it is absent, so existing .env files keep working; this only makes it discoverable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/.env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/shop/docker/.env.example b/shop/docker/.env.example index 35cb5120..f12cf6c2 100644 --- a/shop/docker/.env.example +++ b/shop/docker/.env.example @@ -1,4 +1,5 @@ COMPOSE_PROJECT_NAME=shop_flow_shop USER_ID=1000 GROUP_ID=1000 +SHOP_CONTAINER_PREFIX=shop_flow_shop NGINX_EXPOSE_PORT= From 872b1102924719dddb9e03a312f0f192c0d91621 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 05/55] chore(admin): document ADMIN_CONTAINER_PREFIX The compose file falls back to the previous value when it is absent, so existing .env files keep working; this only makes it discoverable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/.env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/admin/docker/.env.example b/admin/docker/.env.example index 0fbd449e..83f034d1 100644 --- a/admin/docker/.env.example +++ b/admin/docker/.env.example @@ -1,4 +1,5 @@ COMPOSE_PROJECT_NAME=shop_flow_admin USER_ID=1000 GROUP_ID=1000 +ADMIN_CONTAINER_PREFIX=shop_flow_admin NGINX_EXPOSE_PORT= From c1541a6cdba6f3ed03c8c53a700bcb879746d16b Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 06/55] fix(shop): point nginx at the renamed php-fpm service fastcgi_pass referenced `app`, which stopped resolving when the service was renamed, and nginx crash-looped on "host not found in upstream". Keeping an `app` network alias instead is not an option: under the root compose project both applications' php-fpm containers would answer to it on the same network, and admin requests would round-robin into the storefront. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/volumes/nginx/app.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shop/docker/volumes/nginx/app.conf b/shop/docker/volumes/nginx/app.conf index c4eb7993..62af9bbf 100644 --- a/shop/docker/volumes/nginx/app.conf +++ b/shop/docker/volumes/nginx/app.conf @@ -9,7 +9,7 @@ server { location ~ .php$ { try_files $uri =404; fastcgi_split_path_info ^(.+.php)(/.+)$; - fastcgi_pass app:9000; + fastcgi_pass shop_app:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; From 223268a0591f0e6a9969f53f58ed3f58633e91eb Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 07/55] fix(admin): point nginx at the renamed php-fpm service fastcgi_pass referenced `app`, which stopped resolving when the service was renamed, and nginx crash-looped on "host not found in upstream". Keeping an `app` network alias instead is not an option: under the root compose project both applications' php-fpm containers would answer to it on the same network, and storefront requests would round-robin into the panel. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/volumes/nginx/app.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/docker/volumes/nginx/app.conf b/admin/docker/volumes/nginx/app.conf index 71e97b5a..8ed7f622 100644 --- a/admin/docker/volumes/nginx/app.conf +++ b/admin/docker/volumes/nginx/app.conf @@ -9,7 +9,7 @@ server { location ~ .php$ { try_files $uri =404; fastcgi_split_path_info ^(.+.php)(/.+)$; - fastcgi_pass app:9000; + fastcgi_pass admin_app:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; From 0e62187e517d5c762a92940619c1d2807703e8aa Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 08/55] feat: start every development container from the repository root `docker compose up -d --build` at the root now brings up the shared Postgres and Redis plus both applications. Each app keeps its own compose file so it can still be started on its own from /docker/. This file only merges the three and adds what cannot live in them: the network ownership, and depends_on gating the apps on healthy db/redis, which is inexpressible in admin/ or shop/ alone because db is not part of those projects. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- compose.yaml | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 compose.yaml diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..4babb6d6 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,50 @@ +# Root entry point for local development. +# +# docker compose up -d --build +# +# brings up the shared Postgres + Redis and both applications as one project. +# Each app keeps its own compose file under /docker/ so it can still be +# started on its own; this file only merges them and adds the wiring that can +# exist solely when all three are in the same project. +name: shop_flow + +include: + - path: infrastructure/docker/docker-compose.yml + project_directory: infrastructure/docker + env_file: infrastructure/docker/.env + + - path: admin/docker/docker-compose.yml + project_directory: admin/docker + env_file: admin/docker/.env + + - path: shop/docker/docker-compose.yml + project_directory: shop/docker + env_file: shop/docker/.env + +# admin/ and shop/ declare this network as external because on their own they +# join a network that infrastructure/ created. Re-declaring it here makes the +# root project the owner, so a fresh clone needs no manual network create. +networks: + net: + name: shop_flow_net + driver: bridge + # Must be explicit: without it the leaf files' `external: true` wins the + # merge and nothing creates the network. + external: false + +# depends_on cannot live in admin/ or shop/: db is not part of those projects +# when they run alone. It is expressible only once all three files are merged. +services: + admin_app: + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + shop_app: + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy From af7b49a54e8dae62b6d1738dc880fa0a8be45742 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 09/55] chore(admin): add a .dockerignore for the production build context vendor/, node_modules/ and the build output are all reproduced inside the image, so shipping the host's copies would only invalidate layers. Also keeps .env files out of the context. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/.dockerignore | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 admin/.dockerignore diff --git a/admin/.dockerignore b/admin/.dockerignore new file mode 100644 index 00000000..9168d04a --- /dev/null +++ b/admin/.dockerignore @@ -0,0 +1,28 @@ +# Build context for docker/Dockerfile.prod. Anything listed here is rebuilt +# inside the image, so shipping the host's copy would only invalidate layers. +.git +.gitignore +.dockerignore +docker/volumes +node_modules +vendor +public/build +public/hot +public/storage +bootstrap/cache/*.php +storage/framework/cache/data/* +storage/framework/sessions/* +storage/framework/views/* +storage/logs/* +.env +.env.* +!.env.example +tests +.phpunit.cache +.phpunit.result.cache +.idea +.vscode +.fleet +.junie +.ai +.claude From bb0dd6875de0d6d7e3689167169b1c05247e0a6e Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 10/55] chore(shop): add a .dockerignore for the production build context vendor/, node_modules/ and the build output are all reproduced inside the image, so shipping the host's copies would only invalidate layers. Also keeps .env files out of the context. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/.dockerignore | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 shop/.dockerignore diff --git a/shop/.dockerignore b/shop/.dockerignore new file mode 100644 index 00000000..0c12e865 --- /dev/null +++ b/shop/.dockerignore @@ -0,0 +1,30 @@ +# Build context for docker/Dockerfile.prod. Anything listed here is rebuilt +# inside the image, so shipping the host's copy would only invalidate layers. +.git +.gitignore +.dockerignore +docker/volumes +node_modules +vendor +public/build +public/hot +public/storage +bootstrap/ssr +bootstrap/cache/*.php +storage/framework/cache/data/* +storage/framework/sessions/* +storage/framework/views/* +storage/logs/* +storage/debugbar +.env +.env.* +!.env.example +tests +.phpunit.cache +.phpunit.result.cache +.idea +.vscode +.fleet +.junie +.ai +.claude From 355b502ae080b704a3b8000addf8599f75fc07e6 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 11/55] feat(admin): add production php.ini with tuned opcache The image is immutable, so opcache timestamp validation is pure overhead and is turned off. max_accelerated_files is raised to 30000 because Filament loads far more classes than the 10000 default allows, and save_comments stays on because attributes are read via reflection. JIT is present but disabled: it gives little for a request/response workload and should be benchmarked before being switched on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/production/php.ini | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 admin/docker/production/php.ini diff --git a/admin/docker/production/php.ini b/admin/docker/production/php.ini new file mode 100644 index 00000000..8c988e6b --- /dev/null +++ b/admin/docker/production/php.ini @@ -0,0 +1,40 @@ +; Production PHP settings. Baked into the image at /usr/local/etc/php/conf.d/. + +; --- OPcache --------------------------------------------------------------- +; The image is immutable, so timestamp validation is pure overhead: code only +; changes when a new image is deployed, and that restarts the container. +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.validate_timestamps = 0 +opcache.revalidate_freq = 0 +opcache.memory_consumption = 256 +opcache.interned_strings_buffer = 32 +; Filament pulls in a lot of classes; the 10000 default is not enough. +opcache.max_accelerated_files = 30000 +; Attributes are read via reflection, so doc comments must be kept. +opcache.save_comments = 1 +opcache.fast_shutdown = 1 + +; JIT gives little for a request/response workload and is the least battle-worn +; part of OPcache. Enable deliberately, after benchmarking: +; opcache.jit = tracing +; opcache.jit_buffer_size = 128M +opcache.jit = disable + +; --- realpath cache -------------------------------------------------------- +realpath_cache_size = 4096K +realpath_cache_ttl = 600 + +; --- limits ---------------------------------------------------------------- +memory_limit = 512M +max_execution_time = 60 +upload_max_filesize = 100M +post_max_size = 100M + +; --- hardening ------------------------------------------------------------- +expose_php = Off +display_errors = Off +display_startup_errors = Off +log_errors = On +; Docker collects stderr, so no log file to rotate. +error_log = /proc/self/fd/2 From 750ebff71c84650711cd39573f80dbb662ae2db5 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 12/55] feat(admin): add a production php-fpm pool configuration clear_env is off, without which php-fpm would discard the environment Compose passes in and hide every value in the app's env_file from PHP. Worker output and the slow log go to stderr so `docker compose logs` is the single place to look, and pm.max_requests recycles workers to bound the damage from any slow leak. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/production/fpm-pool.conf | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 admin/docker/production/fpm-pool.conf diff --git a/admin/docker/production/fpm-pool.conf b/admin/docker/production/fpm-pool.conf new file mode 100644 index 00000000..7d295abe --- /dev/null +++ b/admin/docker/production/fpm-pool.conf @@ -0,0 +1,31 @@ +; Overrides the stock www pool. Baked into /usr/local/etc/php-fpm.d/. +[www] +user = www-data +group = www-data +listen = 9000 + +pm = dynamic +; Roughly (available RAM for PHP) / (peak memory per request). Raise together +; with the VPS size; every worker can use up to php.ini's memory_limit. +pm.max_children = 20 +pm.start_servers = 4 +pm.min_spare_servers = 2 +pm.max_spare_servers = 6 +; Recycle workers to cap the damage from any slow leak. +pm.max_requests = 500 + +; Keep the environment passed in by Compose. php-fpm clears it by default, +; which would hide every value in the app's env_file from PHP. +clear_env = no + +; Send everything to the container's stdout/stderr for `docker compose logs`. +catch_workers_output = yes +decorate_workers_output = no +access.log = /proc/self/fd/2 +php_admin_value[error_log] = /proc/self/fd/2 +php_admin_flag[log_errors] = on + +; Log a stack trace for anything slower than this instead of guessing later. +slowlog = /proc/self/fd/2 +request_slowlog_timeout = 10s +request_terminate_timeout = 60s From 33ae90ce5e452b260f00b98953f1c61745f98364 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 13/55] feat(admin): add a hardened production nginx configuration Only the front controller may execute: any other .php path returns 404 instead of being handed to the interpreter. TLS is terminated by the proxy in front, so this listens on plain 80 inside the Docker network. Content-hashed Vite output under /build is served immutable, and symlinks stay enabled because public/storage points into the uploads volume. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/production/nginx.conf | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 admin/docker/production/nginx.conf diff --git a/admin/docker/production/nginx.conf b/admin/docker/production/nginx.conf new file mode 100644 index 00000000..5254542e --- /dev/null +++ b/admin/docker/production/nginx.conf @@ -0,0 +1,79 @@ +# Admin web server. TLS and HTTP/2 are handled by the Caddy proxy in front, so +# this only listens on plain 80 inside the Docker network. +server { + listen 80; + server_name _; + + root /var/www/html/public; + index index.php; + charset utf-8; + + client_max_body_size 100M; + # public/storage is a symlink into the uploads volume. + disable_symlinks off; + + access_log /dev/stdout; + error_log /dev/stderr warn; + + gzip on; + gzip_vary on; + gzip_comp_level 5; + gzip_min_length 256; + gzip_proxied any; + gzip_types text/plain text/css text/xml application/json application/javascript + application/xml application/rss+xml image/svg+xml font/woff font/woff2; + + # Vite writes content-hashed filenames, so these can never go stale. + location /build/ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + location /storage/ { + expires 30d; + add_header Cache-Control "public"; + access_log off; + try_files $uri =404; + } + + location = /favicon.ico { + access_log off; + log_not_found off; + } + + location = /robots.txt { + access_log off; + log_not_found off; + } + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Only the front controller is allowed to execute. + location ~ ^/index\.php(/|$) { + fastcgi_pass admin_app:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_hide_header X-Powered-By; + fastcgi_read_timeout 60s; + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + } + + # Anything else ending in .php is not a route — never hand it to PHP. + location ~ \.php$ { + return 404; + } + + # Dotfiles are not web content. + location ~ /\.(?!well-known).* { + deny all; + } +} From 32cd3016dde10017a5ff355fc2a4ad991c1a7a72 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 14/55] feat(admin): add a production entrypoint that builds caches The development entrypoint runs composer install, npm build and migrate on every container start, then clears the caches. In production that is backwards: startup would depend on the network, and two containers starting together would race on the schema. This builds config/event/view caches instead, which has to happen at start rather than at build time because it bakes in the runtime environment. route:cache is left out because routes/web.php still has two closure routes, which Laravel cannot serialize. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/production/entrypoint.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100755 admin/docker/production/entrypoint.sh diff --git a/admin/docker/production/entrypoint.sh b/admin/docker/production/entrypoint.sh new file mode 100755 index 00000000..a1845799 --- /dev/null +++ b/admin/docker/production/entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Production entrypoint. +# +# Deliberately does NOT run composer install, npm build, or migrations: the +# first two happen at image build time, and migrations are a release step run +# once per deploy (see infrastructure/production/deploy.sh). Doing them here +# would make every container start depend on the network and would let two +# replicas race each other on the schema. +set -euo pipefail + +if [ -z "${APP_KEY:-}" ]; then + echo "FATAL: APP_KEY is not set. Generate one with 'php artisan key:generate --show'." >&2 + exit 1 +fi + +# Build the caches rather than clear them. Cheap, offline, and has to happen +# here rather than at build time because it bakes in the runtime environment. +php artisan config:cache +php artisan event:cache +php artisan view:cache +# route:cache is skipped on purpose: routes/web.php still defines two closure +# routes, which Laravel cannot serialize. Convert them to controller actions +# and this can be enabled. +php artisan filament:optimize + +exec "$@" From 2e93804ae82ad8285764fe4241d19027134d00de Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 15/55] feat(shop): add production php.ini with tuned opcache The image is immutable, so opcache timestamp validation is pure overhead and is turned off, and the accelerated-file limit is raised well above the 10000 default. JIT is present but disabled: it gives little for a request/response workload and should be benchmarked before being switched on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/production/php.ini | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 shop/docker/production/php.ini diff --git a/shop/docker/production/php.ini b/shop/docker/production/php.ini new file mode 100644 index 00000000..1f5e5a06 --- /dev/null +++ b/shop/docker/production/php.ini @@ -0,0 +1,40 @@ +; Production PHP settings. Baked into the image at /usr/local/etc/php/conf.d/. + +; --- OPcache --------------------------------------------------------------- +; The image is immutable, so timestamp validation is pure overhead: code only +; changes when a new image is deployed, and that restarts the container. +opcache.enable = 1 +opcache.enable_cli = 0 +opcache.validate_timestamps = 0 +opcache.revalidate_freq = 0 +opcache.memory_consumption = 256 +opcache.interned_strings_buffer = 32 +; Laravel plus Inertia loads a lot of classes; the 10000 default is tight. +opcache.max_accelerated_files = 30000 +; Attributes are read via reflection, so doc comments must be kept. +opcache.save_comments = 1 +opcache.fast_shutdown = 1 + +; JIT gives little for a request/response workload and is the least battle-worn +; part of OPcache. Enable deliberately, after benchmarking: +; opcache.jit = tracing +; opcache.jit_buffer_size = 128M +opcache.jit = disable + +; --- realpath cache -------------------------------------------------------- +realpath_cache_size = 4096K +realpath_cache_ttl = 600 + +; --- limits ---------------------------------------------------------------- +memory_limit = 512M +max_execution_time = 60 +upload_max_filesize = 100M +post_max_size = 100M + +; --- hardening ------------------------------------------------------------- +expose_php = Off +display_errors = Off +display_startup_errors = Off +log_errors = On +; Docker collects stderr, so no log file to rotate. +error_log = /proc/self/fd/2 From f4a724526c15646c561b2e92af9f2aacedcd4127 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 16/55] feat(shop): add a production php-fpm pool configuration clear_env is off, without which php-fpm would discard the environment Compose passes in and hide every value in the app's env_file from PHP. Worker output and the slow log go to stderr so `docker compose logs` is the single place to look, and pm.max_requests recycles workers to bound the damage from any slow leak. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/production/fpm-pool.conf | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 shop/docker/production/fpm-pool.conf diff --git a/shop/docker/production/fpm-pool.conf b/shop/docker/production/fpm-pool.conf new file mode 100644 index 00000000..7d295abe --- /dev/null +++ b/shop/docker/production/fpm-pool.conf @@ -0,0 +1,31 @@ +; Overrides the stock www pool. Baked into /usr/local/etc/php-fpm.d/. +[www] +user = www-data +group = www-data +listen = 9000 + +pm = dynamic +; Roughly (available RAM for PHP) / (peak memory per request). Raise together +; with the VPS size; every worker can use up to php.ini's memory_limit. +pm.max_children = 20 +pm.start_servers = 4 +pm.min_spare_servers = 2 +pm.max_spare_servers = 6 +; Recycle workers to cap the damage from any slow leak. +pm.max_requests = 500 + +; Keep the environment passed in by Compose. php-fpm clears it by default, +; which would hide every value in the app's env_file from PHP. +clear_env = no + +; Send everything to the container's stdout/stderr for `docker compose logs`. +catch_workers_output = yes +decorate_workers_output = no +access.log = /proc/self/fd/2 +php_admin_value[error_log] = /proc/self/fd/2 +php_admin_flag[log_errors] = on + +; Log a stack trace for anything slower than this instead of guessing later. +slowlog = /proc/self/fd/2 +request_slowlog_timeout = 10s +request_terminate_timeout = 60s From 4e070b68fb4f73213c26fb3bd2071d784d367f6d Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 17/55] feat(shop): add a hardened production nginx configuration Only the front controller may execute: any other .php path returns 404 instead of being handed to the interpreter. TLS is terminated by the proxy in front, so this listens on plain 80 inside the Docker network. Content-hashed Vite output under /build is served immutable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/production/nginx.conf | 79 +++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 shop/docker/production/nginx.conf diff --git a/shop/docker/production/nginx.conf b/shop/docker/production/nginx.conf new file mode 100644 index 00000000..b8f0fa6c --- /dev/null +++ b/shop/docker/production/nginx.conf @@ -0,0 +1,79 @@ +# Storefront web server. TLS and HTTP/2 are handled by the Caddy proxy in front, so +# this only listens on plain 80 inside the Docker network. +server { + listen 80; + server_name _; + + root /var/www/html/public; + index index.php; + charset utf-8; + + client_max_body_size 100M; + # public/storage is a symlink into the uploads volume. + disable_symlinks off; + + access_log /dev/stdout; + error_log /dev/stderr warn; + + gzip on; + gzip_vary on; + gzip_comp_level 5; + gzip_min_length 256; + gzip_proxied any; + gzip_types text/plain text/css text/xml application/json application/javascript + application/xml application/rss+xml image/svg+xml font/woff font/woff2; + + # Vite writes content-hashed filenames, so these can never go stale. + location /build/ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + try_files $uri =404; + } + + location /storage/ { + expires 30d; + add_header Cache-Control "public"; + access_log off; + try_files $uri =404; + } + + location = /favicon.ico { + access_log off; + log_not_found off; + } + + location = /robots.txt { + access_log off; + log_not_found off; + } + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Only the front controller is allowed to execute. + location ~ ^/index\.php(/|$) { + fastcgi_pass shop_app:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_hide_header X-Powered-By; + fastcgi_read_timeout 60s; + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + } + + # Anything else ending in .php is not a route — never hand it to PHP. + location ~ \.php$ { + return 404; + } + + # Dotfiles are not web content. + location ~ /\.(?!well-known).* { + deny all; + } +} From 92ff8c3f9ce5d9b622bd97a58d50f52afdc04fd6 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 18/55] feat(shop): add a production entrypoint that builds caches The development entrypoint runs composer install, npm build and migrate on every container start, then clears the caches. In production that is backwards: startup would depend on the network being reachable. This builds the config, route, event and view caches instead, which has to happen at start rather than at build time because it bakes in the runtime environment. The storefront never migrates: admin owns the schema. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/production/entrypoint.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100755 shop/docker/production/entrypoint.sh diff --git a/shop/docker/production/entrypoint.sh b/shop/docker/production/entrypoint.sh new file mode 100755 index 00000000..d98bcd93 --- /dev/null +++ b/shop/docker/production/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Production entrypoint. +# +# Deliberately does NOT run composer install, npm build, or migrations: the +# first two happen at image build time, and the storefront never migrates the +# shared schema — admin owns it (see infrastructure/production/deploy.sh). +set -euo pipefail + +if [ -z "${APP_KEY:-}" ]; then + echo "FATAL: APP_KEY is not set. Generate one with 'php artisan key:generate --show'." >&2 + exit 1 +fi + +# Build the caches rather than clear them. Cheap, offline, and has to happen +# here rather than at build time because it bakes in the runtime environment. +php artisan config:cache +php artisan route:cache +php artisan event:cache +php artisan view:cache + +exec "$@" From 83c58dd5b25e8890b42dd279d7b86b4dd5e0a9d2 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 19/55] feat(admin): add a multi-stage production image Targets `app` (php-fpm with the application baked in) and `web` (nginx with only public/), so the two share every layer up to `app`. Dependencies are built on the same PHP base as the runtime, because the composer image lacks ext-intl and cannot resolve this app's platform requirements. opcache is deliberately absent from the extension list: PHP 8.5 links Zend OPcache in statically, so there is no shared module to build and installing it fails. apt retries are set because apt only warns when an index fails to download, which otherwise surfaces much later as a confusing "Unable to locate package". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/docker/Dockerfile.prod | 130 +++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 admin/docker/Dockerfile.prod diff --git a/admin/docker/Dockerfile.prod b/admin/docker/Dockerfile.prod new file mode 100644 index 00000000..851a426a --- /dev/null +++ b/admin/docker/Dockerfile.prod @@ -0,0 +1,130 @@ +# Production image for the admin panel. Build context is admin/ (see +# .dockerignore); the dev image lives in Dockerfile and is unrelated. +# +# Two images come out of this file, selected with --target: +# app -> php-fpm with the application baked in +# web -> nginx with only public/ baked in +# Compose builds both, so they share every layer up to `app`. + +# --- PHP base -------------------------------------------------------------- +# Shared by the dependency build and the runtime, so composer resolves platform +# requirements against exactly the extensions production will have. +FROM php:8.5-fpm-bookworm AS base + +# One layer, no recommends, and the build-only -dev packages are purged again +# after the extensions are compiled. This is the pattern the official PHP +# images use; it keeps the shared libraries the extensions actually link to. +# Retries matter: apt only warns when an index fails to download, so a flaky +# network otherwise surfaces as a confusing "Unable to locate package". +# opcache is absent from the list on purpose: PHP 8.5 links Zend OPcache in +# statically, so there is no shared module to build. php.ini tunes it. +RUN set -eux; \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update; \ + apt-get install -y --no-install-recommends \ + libfreetype6-dev \ + libicu-dev \ + libjpeg62-turbo-dev \ + libonig-dev \ + libpng-dev \ + libpq-dev \ + libwebp-dev \ + libxml2-dev \ + libxpm-dev \ + libzip-dev \ + ; \ + docker-php-ext-configure gd \ + --enable-gd \ + --with-freetype \ + --with-jpeg \ + --with-webp \ + --with-xpm \ + ; \ + docker-php-ext-install -j"$(nproc)" \ + bcmath \ + exif \ + gd \ + intl \ + mbstring \ + pcntl \ + pdo_pgsql \ + pgsql \ + zip \ + ; \ + pecl install redis; \ + docker-php-ext-enable redis; \ + \ + apt-mark auto '.*' > /dev/null; \ + [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \ + find /usr/local/lib/php/extensions -name '*.so' -exec ldd '{}' ';' \ + | awk '/=>/ { so = $(NF-1); if (index(so, "/usr/local/") == 1) next; gsub("^/(usr/)?", "", so); print so }' \ + | sort -u \ + | xargs -r dpkg-query --search 2>/dev/null \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ + rm -rf /var/lib/apt/lists/* /tmp/pear + +COPY docker/production/php.ini /usr/local/etc/php/conf.d/zz-production.ini +COPY docker/production/fpm-pool.conf /usr/local/etc/php-fpm.d/zz-www.conf + +WORKDIR /var/www/html + +# --- Front-end assets ------------------------------------------------------ +FROM node:20-bookworm-slim AS assets +WORKDIR /app +# Dependencies first: the install layer then survives any source-only change. +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# --- Application build ----------------------------------------------------- +# Runs on `base` so composer sees the production extension set. Composer itself +# never reaches the runtime image. +FROM base AS build +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --no-interaction \ + --prefer-dist + +COPY . . +COPY --from=assets /app/public/build ./public/build + +# dump-autoload runs package:discover, which needs the full source. Filament +# publishes its own JS/CSS into public/ so the web image can serve them. +RUN set -eux; \ + composer dump-autoload --no-dev --optimize --no-interaction; \ + php artisan filament:assets; \ + php artisan storage:link + +# --- Runtime --------------------------------------------------------------- +FROM base AS app + +COPY --from=build /var/www/html /var/www/html + +# Code stays root-owned and read-only to the fpm workers; only the two paths +# Laravel writes to are handed over. +RUN chown -R www-data:www-data storage bootstrap/cache \ + && chmod -R u+rwX,g+rwX storage bootstrap/cache + +COPY docker/production/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 9000 +ENTRYPOINT ["entrypoint.sh"] +CMD ["php-fpm"] + +# --- Web server ------------------------------------------------------------ +FROM nginx:1.27-alpine AS web +COPY docker/production/nginx.conf /etc/nginx/conf.d/default.conf +# Static files only. public/storage is a symlink resolved by the uploads volume +# that compose mounts into this container as well. +COPY --from=build /var/www/html/public /var/www/html/public +EXPOSE 80 From fff2302538390ed23f6c34524f8f3091e8888a23 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 20/55] feat(shop): add a multi-stage production image with an SSR target Targets `app` (php-fpm), `web` (nginx with only public/) and `ssr` (node running the Inertia renderer), so all three share their common layers. The renderer gets its own target because Vite externalises npm dependencies from an SSR build, so the bundle still needs the production node_modules at runtime. The PHP image carries the bundle too, since Inertia's ensure_bundle_exists check runs on the PHP side. Dependencies are built on the same PHP base as the runtime so composer resolves platform requirements against the production extension set. opcache is absent from that list on purpose: PHP 8.5 links it in statically, so installing it as a shared module fails. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/docker/Dockerfile.prod | 146 ++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 shop/docker/Dockerfile.prod diff --git a/shop/docker/Dockerfile.prod b/shop/docker/Dockerfile.prod new file mode 100644 index 00000000..46dc64db --- /dev/null +++ b/shop/docker/Dockerfile.prod @@ -0,0 +1,146 @@ +# Production image for the storefront. Build context is shop/ (see +# .dockerignore); the dev image lives in Dockerfile and is unrelated. +# +# Three images come out of this file, selected with --target: +# app -> php-fpm with the application baked in +# web -> nginx with only public/ baked in +# ssr -> node running the Inertia server-side renderer +# Compose builds all three, so they share every layer they have in common. + +# --- PHP base -------------------------------------------------------------- +# Shared by the dependency build and the runtime, so composer resolves platform +# requirements against exactly the extensions production will have. +FROM php:8.5-fpm-bookworm AS base + +# One layer, no recommends, and the build-only -dev packages are purged again +# after the extensions are compiled. This is the pattern the official PHP +# images use; it keeps the shared libraries the extensions actually link to. +# Retries matter: apt only warns when an index fails to download, so a flaky +# network otherwise surfaces as a confusing "Unable to locate package". +# opcache is absent from the list on purpose: PHP 8.5 links Zend OPcache in +# statically, so there is no shared module to build. php.ini tunes it. +RUN set -eux; \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update; \ + apt-get install -y --no-install-recommends \ + libfreetype6-dev \ + libicu-dev \ + libjpeg62-turbo-dev \ + libonig-dev \ + libpng-dev \ + libpq-dev \ + libwebp-dev \ + libxml2-dev \ + libxpm-dev \ + libzip-dev \ + ; \ + docker-php-ext-configure gd \ + --enable-gd \ + --with-freetype \ + --with-jpeg \ + --with-webp \ + --with-xpm \ + ; \ + docker-php-ext-install -j"$(nproc)" \ + bcmath \ + exif \ + gd \ + intl \ + mbstring \ + pcntl \ + pdo_pgsql \ + pgsql \ + zip \ + ; \ + pecl install redis; \ + docker-php-ext-enable redis; \ + \ + apt-mark auto '.*' > /dev/null; \ + [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \ + find /usr/local/lib/php/extensions -name '*.so' -exec ldd '{}' ';' \ + | awk '/=>/ { so = $(NF-1); if (index(so, "/usr/local/") == 1) next; gsub("^/(usr/)?", "", so); print so }' \ + | sort -u \ + | xargs -r dpkg-query --search 2>/dev/null \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual; \ + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \ + rm -rf /var/lib/apt/lists/* /tmp/pear + +COPY docker/production/php.ini /usr/local/etc/php/conf.d/zz-production.ini +COPY docker/production/fpm-pool.conf /usr/local/etc/php-fpm.d/zz-www.conf + +WORKDIR /var/www/html + +# --- Front-end assets ------------------------------------------------------ +# `npm run build` is `vite build && vite build --ssr`, so this produces both +# public/build (browser) and bootstrap/ssr (server renderer). The full source is +# copied in because Tailwind v4 scans the project for the classes it emits. +FROM node:24-bookworm-slim AS assets +WORKDIR /app +# Dependencies first: the install layer then survives any source-only change. +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# --- Application build ----------------------------------------------------- +# Runs on `base` so composer sees the production extension set. Composer itself +# never reaches the runtime image. +FROM base AS build +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --no-interaction \ + --prefer-dist + +COPY . . +COPY --from=assets /app/public/build ./public/build +# Inertia's ensure_bundle_exists check runs in PHP, so the app image needs the +# SSR bundle too even though the ssr container is the one that executes it. +COPY --from=assets /app/bootstrap/ssr ./bootstrap/ssr + +# dump-autoload runs package:discover, which needs the full source. +RUN set -eux; \ + composer dump-autoload --no-dev --optimize --no-interaction; \ + php artisan storage:link + +# --- Runtime --------------------------------------------------------------- +FROM base AS app + +COPY --from=build /var/www/html /var/www/html + +# Code stays root-owned and read-only to the fpm workers; only the two paths +# Laravel writes to are handed over. +RUN chown -R www-data:www-data storage bootstrap/cache \ + && chmod -R u+rwX,g+rwX storage bootstrap/cache + +COPY docker/production/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 9000 +ENTRYPOINT ["entrypoint.sh"] +CMD ["php-fpm"] + +# --- Web server ------------------------------------------------------------ +FROM nginx:1.27-alpine AS web +COPY docker/production/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /var/www/html/public /var/www/html/public +EXPOSE 80 + +# --- Inertia SSR renderer -------------------------------------------------- +# Vite externalises npm dependencies from an SSR build, so the bundle still +# needs node_modules at runtime — the production set only. +FROM node:24-bookworm-slim AS ssr +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY --from=assets /app/bootstrap/ssr ./bootstrap/ssr +USER node +EXPOSE 13714 +CMD ["node", "bootstrap/ssr/ssr.js"] From ee0b6e0649aac46d74243a522426f612719745ad Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 21/55] feat: add the production compose stack Source is baked into images rather than bind-mounted, Postgres and Redis sit on named volumes and publish no host ports, and Caddy is the only container listening on the public interface. The Inertia renderer runs as its own container so a crash restarts it instead of silently dropping the storefront to client-side rendering. Queue and scheduler containers are behind a `workers` profile because nothing queues a job or registers a schedule yet. The redis command is a single-line list on purpose: as a YAML folded scalar the more-indented flags keep their newlines and end up as unreachable lines after `exec`, which left the server with no password at all. Its healthcheck asserts an unauthenticated PING is rejected, since a successful authenticated PING also passes against a passwordless server and hid exactly that bug. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- compose.prod.yaml | 246 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 compose.prod.yaml diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 00000000..38b5acde --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,246 @@ +# Production stack for a single Ubuntu VPS. +# +# docker compose -f compose.prod.yaml --env-file .env.production up -d --build +# +# Use infrastructure/production/deploy.sh instead of calling this directly — it +# also runs the migration step, which is not part of container startup. +# +# Differences from the development compose.yaml: the application source is +# baked into the images instead of bind-mounted, Postgres and Redis are on named +# volumes and publish no host ports, and Caddy is the only thing listening on +# the public interface. +name: shopflow_prod + +# A VPS disk fills up quietly. Cap every container's logs. +x-logging: &logging + driver: json-file + options: + max-size: "10m" + max-file: "5" + +x-admin-image: &admin-image + build: + context: ./admin + dockerfile: docker/Dockerfile.prod + target: app + image: shopflow/admin-app:${IMAGE_TAG:-latest} + env_file: [admin/.env.production] + restart: unless-stopped + logging: *logging + networks: [net] + depends_on: + db: {condition: service_healthy} + redis: {condition: service_healthy} + +x-shop-image: &shop-image + build: + context: ./shop + dockerfile: docker/Dockerfile.prod + target: app + image: shopflow/shop-app:${IMAGE_TAG:-latest} + env_file: [shop/.env.production] + restart: unless-stopped + logging: *logging + networks: [net] + depends_on: + db: {condition: service_healthy} + redis: {condition: service_healthy} + +services: + + # --- shared services --------------------------------------------------- + + db: + image: postgres:16-alpine + restart: unless-stopped + logging: *logging + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + # Without this an initdb on a fresh volume uses trust auth. + POSTGRES_HOST_AUTH_METHOD: scram-sha-256 + POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + networks: [net] + + redis: + image: redis:7-alpine + restart: unless-stopped + logging: *logging + # The password is read from the environment inside the container rather + # than passed on the command line, where `docker inspect` would show it. + # Kept on one line deliberately: in a YAML folded scalar the flags would + # become separate lines after `exec`, i.e. never run at all. + command: ["sh", "-c", "exec redis-server --appendonly yes --maxmemory-policy noeviction --requirepass \"$$REDIS_PASSWORD\""] + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + volumes: + - redisdata:/data + healthcheck: + # Asserts the password is actually enforced. A plain authenticated + # PING is not enough: it also succeeds against a server with no + # password at all, which is the failure this is guarding against. + test: + - CMD-SHELL + - redis-cli ping 2>&1 | grep -q NOAUTH && redis-cli --no-auth-warning -a "$$REDIS_PASSWORD" ping | grep -q PONG + interval: 10s + timeout: 5s + retries: 6 + networks: [net] + + # --- admin panel ------------------------------------------------------- + + admin_app: + <<: *admin-image + volumes: + # Uploads are the only state the app writes; everything else in + # storage/ is disposable and logs go to stderr. + - admin_storage:/var/www/html/storage/app/public + healthcheck: + test: ["CMD-SHELL", "bash -c ' /dev/null || exit 1"] + interval: 15s + timeout: 5s + retries: 4 + start_period: 40s + depends_on: + admin_app: {condition: service_started} + networks: [net] + + # --- storefront -------------------------------------------------------- + + shop_app: + <<: *shop-image + healthcheck: + test: ["CMD-SHELL", "bash -c ' /dev/null || exit 1"] + interval: 15s + timeout: 5s + retries: 4 + start_period: 40s + depends_on: + shop_app: {condition: service_started} + networks: [net] + + # Its own container so a crashed renderer restarts instead of silently + # dropping the storefront back to client-side rendering. + shop_ssr: + build: + context: ./shop + dockerfile: docker/Dockerfile.prod + target: ssr + image: shopflow/shop-ssr:${IMAGE_TAG:-latest} + restart: unless-stopped + logging: *logging + healthcheck: + test: + - CMD + - node + - -e + - "require('net').connect(13714,'127.0.0.1').on('connect',()=>process.exit(0)).on('error',()=>process.exit(1))" + interval: 15s + timeout: 5s + retries: 4 + start_period: 20s + networks: [net] + + # --- workers ----------------------------------------------------------- + # Nothing in the codebase queues a job or registers a scheduled task yet, + # so these stay out of the default `up`. Start them with: + # docker compose -f compose.prod.yaml --profile workers up -d + # and set QUEUE_CONNECTION=redis in the app env files first. + + admin_queue: + <<: *admin-image + profiles: [workers] + command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=1"] + + admin_scheduler: + <<: *admin-image + profiles: [workers] + command: ["php", "artisan", "schedule:work"] + + shop_queue: + <<: *shop-image + profiles: [workers] + command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=1"] + + shop_scheduler: + <<: *shop-image + profiles: [workers] + command: ["php", "artisan", "schedule:work"] + + # --- edge -------------------------------------------------------------- + # The only container with published ports. Caddy terminates TLS and renews + # Let's Encrypt certificates on its own. + proxy: + image: caddy:2-alpine + restart: unless-stopped + logging: *logging + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + SHOP_DOMAIN: ${SHOP_DOMAIN} + ADMIN_DOMAIN: ${ADMIN_DOMAIN} + LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} + volumes: + - ./infrastructure/production/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + admin_web: {condition: service_started} + shop_web: {condition: service_started} + networks: [net] + +volumes: + pgdata: + redisdata: + admin_storage: + caddy_data: + caddy_config: + +networks: + net: + driver: bridge From f9071c04a0348d6fa1fa39b4bc47b0c626cacb8d Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 22/55] feat(infra): terminate TLS for both domains with Caddy Caddy obtains and renews Let's Encrypt certificates itself, so the only requirement is working DNS and reachable ports 80/443. It also sets the X-Forwarded-* headers the applications read through trustProxies. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- infrastructure/production/Caddyfile | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 infrastructure/production/Caddyfile diff --git a/infrastructure/production/Caddyfile b/infrastructure/production/Caddyfile new file mode 100644 index 00000000..404ab433 --- /dev/null +++ b/infrastructure/production/Caddyfile @@ -0,0 +1,31 @@ +# Caddy obtains and renews Let's Encrypt certificates for both domains by +# itself; the only requirement is that the DNS A/AAAA records already point at +# this VPS and that ports 80 and 443 are reachable. +{ + email {$LETSENCRYPT_EMAIL} +} + +(common) { + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + # Caddy advertises itself otherwise. + -Server + } +} + +{$SHOP_DOMAIN} { + import common + # Caddy sets X-Forwarded-For/Proto/Host, which Laravel reads through the + # trustProxies middleware in bootstrap/app.php. + reverse_proxy shop_web:80 +} + +{$ADMIN_DOMAIN} { + import common + reverse_proxy admin_web:80 +} From 518b725e781fa08c6df61b7bccbea04b78546688 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 23/55] feat(infra): add a deploy script with migrations as a release step Images are built before anything is stopped, then migrations run once from a throwaway admin container, and only then are the long-running containers replaced. Only admin migrates, because it owns the shared schema. Images are tagged with the deployed commit so a rollback has something to point at without rebuilding. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- infrastructure/production/deploy.sh | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100755 infrastructure/production/deploy.sh diff --git a/infrastructure/production/deploy.sh b/infrastructure/production/deploy.sh new file mode 100755 index 00000000..059d96b9 --- /dev/null +++ b/infrastructure/production/deploy.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Deploy ShopFlow on the production VPS. Run from the repository root: +# +# ./infrastructure/production/deploy.sh +# +# Order matters: images are built before anything is stopped, migrations run +# once from a throwaway container rather than from every app container's +# startup, and only then are the long-running containers replaced. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +COMPOSE_FILE="compose.prod.yaml" +ENV_FILE=".env.production" + +compose() { + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@" +} + +for f in "$ENV_FILE" admin/.env.production shop/.env.production; do + if [ ! -f "$f" ]; then + echo "missing $f — copy it from ${f%.production}.production.example and fill it in" >&2 + exit 1 + fi +done + +# Tag the images with the commit being deployed so a rollback has a target. +if [ -z "${IMAGE_TAG:-}" ] && git rev-parse --git-dir > /dev/null 2>&1; then + IMAGE_TAG="$(git rev-parse --short HEAD)" + export IMAGE_TAG +fi +echo "==> deploying ${IMAGE_TAG:-latest}" + +echo "==> building images" +compose build + +echo "==> starting database and cache" +compose up -d --wait db redis + +# Only admin migrates: it owns the shared schema. --force skips the interactive +# confirmation that a non-tty deploy cannot answer. +echo "==> running migrations (admin owns the schema)" +compose run --rm --no-deps admin_app php artisan migrate --force + +echo "==> replacing application containers" +compose up -d --build --remove-orphans --wait + +echo "==> reclaiming disk from superseded images" +docker image prune -f + +echo +compose ps +echo +echo "done. Logs: docker compose -f $COMPOSE_FILE --env-file $ENV_FILE logs -f" From 2d1e48a01bf723e493d9da4ff5783148915f178b Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 24/55] docs(infra): document deploying to an Ubuntu VPS Ordered checklist from DNS through backups, with the reasoning for the parts that are easy to get wrong: DNS has to be in place before Caddy starts, the Postgres credentials only apply when the volume is first created, and the Vite build is the memory peak on a small box. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- infrastructure/production/README.md | 298 ++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 infrastructure/production/README.md diff --git a/infrastructure/production/README.md b/infrastructure/production/README.md new file mode 100644 index 00000000..e4bff791 --- /dev/null +++ b/infrastructure/production/README.md @@ -0,0 +1,298 @@ +# Deploying ShopFlow to an Ubuntu VPS + +The production stack is `compose.prod.yaml` at the repository root. It is +separate from the development `compose.yaml` and shares nothing with it: the +application source is baked into images instead of bind-mounted, Postgres and +Redis publish no host ports, and Caddy is the only container listening on the +public interface. + +``` + :80 :443 + │ + ┌───▼───┐ + │ Caddy │ automatic Let's Encrypt TLS + └─┬───┬─┘ + admin.example │ │ shop.example + ┌────▼┐ ┌▼────┐ + │admin│ │shop │ nginx, static files only + │_web │ │_web │ + └──┬──┘ └──┬──┘ + │fastcgi│ + ┌──▼──┐ ┌──▼──┐ ┌─────────┐ + │admin│ │shop │ │shop_ssr │ Inertia renderer (node) + │_app │ │_app │◄──┤ │ + └──┬──┘ └──┬──┘ └─────────┘ + └───┬───┘ + ┌─────▼─────┐ + │ db redis│ named volumes, no published ports + └───────────┘ +``` + +## What you have to do + +A first deployment, in order. Each step is detailed below. + +1. Point DNS for two hostnames at the server — **do this first**, Caddy needs it + to work before it can get certificates. +2. Create a VPS: Ubuntu 24.04, 2 GB RAM minimum (4 GB recommended), 20 GB disk. +3. Install Docker from Docker's own apt repository. +4. Create a non-root `deploy` user, enable `ufw`, turn off SSH passwords. +5. Clone the repo as `deploy`. +6. Fill in three env files: `.env.production`, `admin/.env.production`, + `shop/.env.production`. +7. Run `./infrastructure/production/deploy.sh`. +8. Create the first admin user. +9. Set up database and uploads backups — nothing here does that for you. + +Redeploys after that are `git pull && ./infrastructure/production/deploy.sh`. + +## 1. DNS + +Create `A` records (and `AAAA` if you have IPv6) for the storefront and the +panel, both pointing at the server's IP: + +``` +shop.example.com A 203.0.113.10 +admin.example.com A 203.0.113.10 +``` + +Caddy requests certificates on first boot over HTTP-01, so these have to resolve +and ports 80/443 must be reachable from the internet. If DNS is not ready, the +stack still comes up but Caddy will keep retrying and the sites serve TLS errors. + +## 2. Server + +- Ubuntu 24.04 LTS. +- **2 GB RAM minimum, 4 GB recommended.** The storefront's Vite build (browser + bundle plus SSR bundle) is the memory peak. On a 2 GB box add swap first: + + ```bash + sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile + sudo mkswap /swapfile && sudo swapon /swapfile + echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + ``` + + Or build the images elsewhere and push them to a registry. +- 20 GB disk or more — images, the Postgres volume, and uploads all live here. + +## 3. Install Docker + +Use Docker's own apt repository, not Ubuntu's `docker.io` package — the latter +lags and does not ship the Compose v2 plugin this setup needs. + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ +https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io \ + docker-buildx-plugin docker-compose-plugin +``` + +Check it: `docker compose version` should report v2.x. + +## 4. Harden the box + +```bash +# Run the stack as a non-root user. +sudo adduser --disabled-password --gecos "" deploy +sudo usermod -aG docker deploy +sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy # copy your key + +# Only SSH and the web ports. +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable + +# Security updates without a login. +sudo apt-get install -y unattended-upgrades +sudo dpkg-reconfigure -plow unattended-upgrades +``` + +Then set `PasswordAuthentication no` in `/etc/ssh/sshd_config` and +`sudo systemctl restart ssh`, once you have confirmed key login works. + +Note that Docker writes iptables rules that bypass ufw for *published* ports. +That is harmless here because only Caddy publishes anything — but keep it in mind +before adding `ports:` to another service. + +## 5. Clone + +```bash +sudo -iu deploy +git clone https://github.com/bahman026/ShopFlow.git +cd ShopFlow +``` + +## 6. Configure + +```bash +cp .env.production.example .env.production +cp admin/.env.production.example admin/.env.production +cp shop/.env.production.example shop/.env.production +``` + +Generate the secrets: + +```bash +openssl rand -base64 32 # POSTGRES_PASSWORD +openssl rand -base64 32 # REDIS_PASSWORD +echo "base64:$(openssl rand -base64 32)" # APP_KEY for admin +echo "base64:$(openssl rand -base64 32)" # APP_KEY for shop — a different one +``` + +Then fill in all three files. Values that must not be left at their defaults: + +| File | Key | Notes | +| --- | --- | --- | +| `.env.production` | `POSTGRES_PASSWORD`, `REDIS_PASSWORD` | From above | +| `.env.production` | `SHOP_DOMAIN`, `ADMIN_DOMAIN`, `LETSENCRYPT_EMAIL` | Real hostnames from step 1 | +| `admin/.env.production` | `APP_KEY` | `base64:...` — required, the container refuses to start without it | +| `shop/.env.production` | `APP_KEY` | A **different** key from admin's | +| both app files | `DB_PASSWORD`, `REDIS_PASSWORD` | Must match `.env.production` | +| both app files | `APP_URL` | `https://` plus the real hostname | +| `shop/.env.production` | `IMAGE_URL` | `https:///storage` — product images are served by the panel | +| both app files | `MAIL_*` | Password resets and order mail need a real SMTP host | +| `shop/.env.production` | `ZARINPAL_MERCHANT_ID`, `NESHAN_*` | Live payment and map credentials | + +Two things worth knowing: + +- The Postgres credentials in `.env.production` are only applied when the + `pgdata` volume is first created. Changing them later does **not** change the + existing role — you have to `ALTER ROLE` by hand. +- The app env files are passed to the containers as environment variables; there + is no `.env` inside the images. The entrypoint runs `config:cache` at startup, + so any change here needs a container restart to take effect. + +## 7. Deploy + +```bash +./infrastructure/production/deploy.sh +``` + +The script: + +1. checks the three env files exist, +2. tags the images with the current commit SHA, +3. builds all five images, +4. starts Postgres and Redis and waits for them to report healthy, +5. runs `php artisan migrate --force` **once** from a throwaway admin + container — admin owns the shared schema, the storefront never migrates it, +6. replaces the long-running containers and waits for their healthchecks, +7. prunes superseded images. + +Migrations are deliberately not part of container startup. Doing them there +would make every restart depend on the database being reachable and would let +two containers race each other on the schema. + +First build takes 5–15 minutes (PHP extensions are compiled from source). +Later builds reuse the cache and are much faster. + +## 8. First admin user + +```bash +docker compose -f compose.prod.yaml --env-file .env.production \ + exec admin_app php artisan make:filament-user +``` + +Then log in at `https://admin.example.com/admin`. + +## 9. Backups + +Nothing here backs anything up. Two things cannot be rebuilt from git — the +database and the uploads volume: + +```bash +# Database +docker compose -f compose.prod.yaml --env-file .env.production \ + exec -T db pg_dump -U shop_flow shop_flow | gzip > db-$(date +%F).sql.gz + +# Uploads +docker run --rm -v shopflow_prod_admin_storage:/data -v "$PWD":/backup \ + alpine tar czf /backup/uploads-$(date +%F).tar.gz -C /data . +``` + +Put both in a cron job that copies the archives *off* the server. A backup on +the same disk is not a backup. + +## Redeploying + +```bash +cd ~/ShopFlow +git pull +./infrastructure/production/deploy.sh +``` + +### Rolling back + +Images are tagged with the commit they were built from, so a rollback needs no +rebuild: + +```bash +IMAGE_TAG= docker compose -f compose.prod.yaml \ + --env-file .env.production up -d +``` + +A migration that has already run is not undone by this. If the bad deploy +changed the schema, restore the database dump as well. + +## Day-to-day operations + +```bash +# Worth putting in ~/.bashrc +alias dcp='docker compose -f compose.prod.yaml --env-file .env.production' + +dcp ps # health of every container +dcp logs -f shop_app # follow one service +dcp exec admin_app bash # shell in the panel +dcp restart shop_ssr # bounce the renderer +dcp exec admin_app php artisan tinker +``` + +Logs go to stdout/stderr and are capped at 10 MB × 5 files per container, so +there is nothing on disk to rotate. + +### Queue workers and the scheduler + +Nothing in the codebase queues a job or registers a scheduled task yet, so those +containers sit behind a Compose profile and do not start by default. When you add +the first one, set `QUEUE_CONNECTION=redis` in both app env files, then: + +```bash +dcp --profile workers up -d +``` + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| Caddy logs `could not get certificate` | DNS not pointing here yet, or 80/443 blocked upstream | +| `FATAL: APP_KEY is not set` | Missing `APP_KEY` in that app's `.env.production` | +| Storefront renders but unstyled | Asset build failed; check `dcp logs shop_web` and rebuild | +| Pages render client-side only | `shop_ssr` is unhealthy — `dcp logs shop_ssr` | +| Product images 404 | `IMAGE_URL` does not match the admin domain, or the uploads volume is not mounted | +| Panel redirects to `http://` | `APP_URL` is not `https://` | +| Build killed during `npm run build` | Out of RAM — add swap (step 2) | + +## Known follow-ups + +- **`route:cache` is disabled for admin.** `admin/routes/web.php` defines two + closure routes, which Laravel cannot serialize. Convert them to controller + actions and enable it in `admin/docker/production/entrypoint.sh`. +- **OPcache JIT is off.** The setting is present but disabled in + `/docker/production/php.ini`; benchmark before enabling. +- **`pm.max_children = 20`** in `fpm-pool.conf` assumes a mid-size box. Each + worker may use up to the 512 MB `memory_limit`; size it against real RAM. +- **No CI build.** `.github/workflows/deploy-application.yml` only runs tests. + Building on the VPS is fine for one server; pushing to a registry becomes + worthwhile at two. +- **Single host, no zero-downtime.** `deploy.sh` replaces containers in place, so + there is a few-second gap. Fine for one VPS; a rolling setup needs a second + host or a blue/green proxy config. From 00f88a5042c216ecaff5c16f7273a016d7332884 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 25/55] chore: add the compose-level production env template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holds only what Compose itself interpolates — database and Redis credentials, the two hostnames, and the image tag. The applications read their own env files. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- .env.production.example | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .env.production.example diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 00000000..a007dfbd --- /dev/null +++ b/.env.production.example @@ -0,0 +1,26 @@ +# Compose-level values for compose.prod.yaml. Copy to .env.production on the +# VPS and fill in. These are read by Compose itself (via --env-file), not by +# the Laravel apps — those have their own admin/.env.production and +# shop/.env.production. + +# --- Postgres -------------------------------------------------------------- +# Only used the first time the pgdata volume is created. Changing them later +# does not change the existing role or database. +POSTGRES_DB=shop_flow +POSTGRES_USER=shop_flow +POSTGRES_PASSWORD= + +# --- Redis ----------------------------------------------------------------- +REDIS_PASSWORD= + +# --- Public hostnames ------------------------------------------------------ +# Both must already resolve to this server; Caddy uses them to request +# certificates on first boot. +SHOP_DOMAIN=shop.example.com +ADMIN_DOMAIN=admin.example.com +LETSENCRYPT_EMAIL=you@example.com + +# --- Images ---------------------------------------------------------------- +# Tag applied to the built images. Set it to the deployed commit +# (`git rev-parse --short HEAD`) so a rollback has something to point at. +IMAGE_TAG=latest From 206ef190883055d324f42a120ff6a3df14258954 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 26/55] chore(admin): add the production env template Redis for sessions and cache, stderr logging so nothing on disk needs rotating, and the public disk for uploads so they land in the mounted volume that nginx serves at /storage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/.env.production.example | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 admin/.env.production.example diff --git a/admin/.env.production.example b/admin/.env.production.example new file mode 100644 index 00000000..46e31225 --- /dev/null +++ b/admin/.env.production.example @@ -0,0 +1,71 @@ +# Runtime environment for the admin containers. Copy to admin/.env.production +# on the VPS and fill in. Never committed — it holds real credentials. +# +# This file is passed to the container as environment variables; there is no +# .env inside the image. The entrypoint runs `config:cache` at start, so every +# change here needs a container restart to take effect. + +APP_NAME=ShopFlow +APP_ENV=production +# php artisan key:generate --show +APP_KEY= +APP_DEBUG=false +APP_URL=https://admin.example.com + +APP_LOCALE=fa +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US +APP_MAINTENANCE_DRIVER=file +BCRYPT_ROUNDS=12 + +# Docker captures stderr, so there is no log file on disk to rotate. +LOG_CHANNEL=stderr +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=warning + +# Host names are the compose service names on the internal network. +DB_CONNECTION=pgsql +DB_HOST=db +DB_PORT=5432 +DB_DATABASE=shop_flow +DB_USERNAME=shop_flow +DB_PASSWORD= + +REDIS_CLIENT=phpredis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Redis rather than the database: sessions and cache are the hottest small +# reads in the panel, and Postgres should not be paying for them. +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null +# Cookies are only ever sent over the Caddy TLS listener. +SESSION_SECURE_COOKIE=true +SESSION_SAME_SITE=lax + +CACHE_STORE=redis +CACHE_PREFIX= + +# sync until the workers profile is started; then set this to redis. +QUEUE_CONNECTION=sync + +BROADCAST_CONNECTION=log + +# Uploads land in storage/app/public, which is the admin_storage volume and the +# path admin_web serves at /storage. The storefront reads them from there. +FILESYSTEM_DISK=public +FILAMENT_FILESYSTEM_DISK=public + +MAIL_MAILER=smtp +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_SCHEME=tls +MAIL_FROM_ADDRESS="noreply@example.com" +MAIL_FROM_NAME="${APP_NAME}" From f0f81721c120f7274e5d8edc0ff17c7ee0de272c Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:54 +0330 Subject: [PATCH 27/55] chore(shop): add the production env template Redis for sessions, because file sessions would be lost on every deploy when the container filesystem is replaced. INERTIA_SSR_URL points at the renderer container, and IMAGE_URL at the admin domain, which is what serves product images. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- shop/.env.production.example | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 shop/.env.production.example diff --git a/shop/.env.production.example b/shop/.env.production.example new file mode 100644 index 00000000..c8e72518 --- /dev/null +++ b/shop/.env.production.example @@ -0,0 +1,80 @@ +# Runtime environment for the storefront containers. Copy to +# shop/.env.production on the VPS and fill in. Never committed — it holds real +# credentials. +# +# This file is passed to the container as environment variables; there is no +# .env inside the image. The entrypoint runs `config:cache` at start, so every +# change here needs a container restart to take effect. + +APP_NAME=ShopFlow +APP_ENV=production +# php artisan key:generate --show — a different key from admin's. +APP_KEY= +APP_DEBUG=false +APP_URL=https://shop.example.com + +# Product images live on the admin side; this must be the admin domain. +IMAGE_URL=https://admin.example.com/storage + +APP_LOCALE=fa +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US +APP_MAINTENANCE_DRIVER=file +BCRYPT_ROUNDS=12 + +# Docker captures stderr, so there is no log file on disk to rotate. +LOG_CHANNEL=stderr +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=warning + +# Host names are the compose service names on the internal network. The +# storefront only reads this schema — admin owns and migrates it. +DB_CONNECTION=pgsql +DB_HOST=db +DB_PORT=5432 +DB_DATABASE=shop_flow +DB_USERNAME=shop_flow +DB_PASSWORD= + +REDIS_CLIENT=phpredis +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD= + +# File sessions would be lost on every deploy, since the container filesystem +# is replaced with the new image. +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null +SESSION_SECURE_COOKIE=true +SESSION_SAME_SITE=lax + +CACHE_STORE=redis +CACHE_PREFIX= + +# sync until the workers profile is started; then set this to redis. +QUEUE_CONNECTION=sync + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local + +# The renderer runs in the shop_ssr container, not on localhost. +INERTIA_SSR_ENABLED=true +INERTIA_SSR_URL=http://shop_ssr:13714 + +MAIL_MAILER=smtp +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_SCHEME=tls +MAIL_FROM_ADDRESS="noreply@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +NESHAN_MAP_KEY= +NESHAN_SERVICE_KEY= +ZARINPAL_MERCHANT_ID= +ZARINPAL_BASE_URL=https://payment.zarinpal.com From ebdf09a6f022a85b49cb0fc8a84031e5af9f9524 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:55 +0330 Subject: [PATCH 28/55] fix(admin): trust the reverse proxy headers In production the panel sits behind Caddy, which terminates TLS. Without this the application sees plain HTTP and generates http:// URLs and redirects. The storefront already had it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- admin/bootstrap/app.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/admin/bootstrap/app.php b/admin/bootstrap/app.php index aa24029e..8c001a9b 100644 --- a/admin/bootstrap/app.php +++ b/admin/bootstrap/app.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -15,6 +16,17 @@ ) ->withMiddleware(function (Middleware $middleware) { $middleware->append(ArToEnMiddleware::class); + + // In production the panel sits behind the Caddy proxy, which terminates + // TLS. Without this, Laravel sees plain HTTP and generates http:// URLs + // and redirects. Matches the storefront's configuration. + $middleware->trustProxies( + at: '*', + headers: Request::HEADER_X_FORWARDED_FOR + | Request::HEADER_X_FORWARDED_HOST + | Request::HEADER_X_FORWARDED_PORT + | Request::HEADER_X_FORWARDED_PROTO, + ); }) ->withExceptions(function (Exceptions $exceptions) { // From 44fa61b6910c72c53a37092cafcd1c87b420092e Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:55 +0330 Subject: [PATCH 29/55] chore: ignore the root production env file It holds real database and Redis credentials. The per-app .gitignore files already cover admin/.env.production and shop/.env.production. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 757fee31..c5e584dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -/.idea \ No newline at end of file +/.idea +/.env.production From c2968fab5689b94bea94d4d20e377eb874feb529 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 6 Aug 2026 19:51:55 +0330 Subject: [PATCH 30/55] docs: document the root compose file and the production stack Getting started now runs one command from the root, with the container and port table, and points at infrastructure/production/ for deployment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d015e532..00cd67a7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The admin panel covers the full schema today. The storefront is built feature by ``` ShopFlow/ +├── compose.yaml # Root entry point: brings up all six containers ├── admin/ # Filament admin panel (owns the DB schema) ├── shop/ # Inertia + Vue storefront (SSR) ├── infrastructure/ @@ -54,16 +55,49 @@ Run migrations and seeders from `admin/` only. The storefront must not migrate t ## Getting started -### 1. Shared services (Postgres + Redis) +### 1. Docker environment files -In `infrastructure/docker`, create a `.env` from `.env.example`, then start the containers: +Each compose file reads its own `.env`. Create all three from their examples: ```bash -cd infrastructure/docker -sudo docker compose up -d --build +cp infrastructure/docker/.env.example infrastructure/docker/.env +cp admin/docker/.env.example admin/docker/.env +cp shop/docker/.env.example shop/docker/.env ``` -### 2. Configure each app +Fill in the blanks in `infrastructure/docker/.env` (database name, user, password, +Redis password) and set `USER_ID`/`GROUP_ID` to your own (`id -u`, `id -g`). + +### 2. Start every container + +The root `compose.yaml` merges the three compose files into one project, so a +single command from the repository root brings up the shared services and both +applications: + +```bash +docker compose up -d --build +``` + +That starts six containers on a shared `shop_flow_net` network: + +| Container | Role | Host port | +| --- | --- | --- | +| `shop_flow_db` | PostgreSQL 16 | `127.0.0.1:5432` | +| `shop_flow_redis` | Redis | `127.0.0.1:6379` | +| `shop_flow_admin_app` | admin PHP-FPM | — | +| `shop_flow_admin_nginx` | admin web server | `127.0.0.1:4040` | +| `shop_flow_shop_app` | storefront PHP-FPM | — | +| `shop_flow_shop_nginx` | storefront web server | `127.0.0.1:8080` | + +Both apps wait for Postgres and Redis to report healthy before they start. Host +ports come from the `*_EXPOSE_PORT` variables in the three `.env` files. + +Each app can still be started on its own — `docker compose up -d` inside +`infrastructure/docker`, `admin/docker`, or `shop/docker`. In that mode the +`infrastructure` project must come up first, because it creates the +`shop_flow_net` network that the other two join as an external network. + +### 3. Configure each app In both `admin/.env` and `shop/.env`, point the database at the shared Postgres (matching the values from `infrastructure/docker/.env`): @@ -74,7 +108,7 @@ DB_PORT=5432 # DB_DATABASE / DB_USERNAME / DB_PASSWORD must match infrastructure/docker/.env ``` -### 3. Admin (schema owner — set up first) +### 4. Admin (schema owner — set up first) ```bash cd admin @@ -84,7 +118,7 @@ php artisan migrate --seed npm install && npm run build ``` -### 4. Storefront +### 5. Storefront ```bash cd shop @@ -95,6 +129,18 @@ npm install && npm run build For app-specific details (Docker containers, SSR, conventions), see each app's own `README.md`, `AGENTS.md`, and `docs/`. +## Production + +`compose.yaml` is for development only — it bind-mounts the source and installs +dependencies on every container start. Production uses a separate stack, +`compose.prod.yaml`, which bakes the application into images, serves both apps +through Caddy with automatic TLS, and runs the Inertia renderer as its own +container. + +Setup for an Ubuntu VPS is documented in +[`infrastructure/production/README.md`](infrastructure/production/README.md); +deploys run through `./infrastructure/production/deploy.sh`. + ## Testing & quality The storefront bundles all checks into one command (run inside its container): From ec08f94edc02834fcccfd7a648c54022feaff3d5 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:25:59 +0330 Subject: [PATCH 31/55] feat(infra): build production images off-box and ship them over SSH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.sh builds on the server, which needs Docker Hub, deb.debian.org, packagist and the npm registry all reachable — unusable on a host where those are filtered. ship-images.sh cross-builds the five app images plus the three pulled service images for linux/amd64 on a workstation, verifies every one is actually that architecture, and streams the lot through `docker save | ssh | docker load`. deploy-prebuilt.sh is the server-side counterpart: deploy.sh with the build step dropped and `--pull never` added, so a missing tag fails immediately instead of hanging on a registry that will never answer. Co-Authored-By: Claude Sonnet 5 --- infrastructure/production/deploy-prebuilt.sh | 81 ++++++++++++++ infrastructure/production/ship-images.sh | 108 +++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100755 infrastructure/production/deploy-prebuilt.sh create mode 100755 infrastructure/production/ship-images.sh diff --git a/infrastructure/production/deploy-prebuilt.sh b/infrastructure/production/deploy-prebuilt.sh new file mode 100755 index 00000000..eb8e14b0 --- /dev/null +++ b/infrastructure/production/deploy-prebuilt.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Deploy from images that are already in the local image store, without +# building anything. Run from the deploy directory on the server: +# +# IMAGE_TAG= ./infrastructure/production/deploy-prebuilt.sh +# +# The counterpart to ship-images.sh, for a server that cannot build its own +# images because Docker Hub, deb.debian.org, packagist and the npm registry are +# all filtered from it. Everything else matches deploy.sh: migrations run once +# from a throwaway container, and only then are the long-running containers +# replaced. +# +# Nothing here reaches the network. `--pull never` is deliberate: without it a +# missing tag turns into a several-minute registry timeout instead of an +# immediate, readable error. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +COMPOSE_FILE="compose.prod.yaml" +ENV_FILE=".env.production" + +compose() { + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@" +} + +for f in "$ENV_FILE" admin/.env.production shop/.env.production; do + if [ ! -f "$f" ]; then + echo "missing $f — copy it from ${f%.production}.production.example and fill it in" >&2 + exit 1 + fi +done + +if [ -z "${IMAGE_TAG:-}" ]; then + IMAGE_TAG="$(grep -E '^IMAGE_TAG=' "$ENV_FILE" | cut -d= -f2-)" +fi +if [ -z "${IMAGE_TAG:-}" ]; then + echo "IMAGE_TAG is not set and $ENV_FILE does not define one" >&2 + exit 1 +fi +export IMAGE_TAG + +echo "==> deploying ${IMAGE_TAG} from local images" + +missing=0 +for img in admin-app admin-web shop-app shop-web shop-ssr; do + if ! docker image inspect "shopflow/${img}:${IMAGE_TAG}" > /dev/null 2>&1; then + echo "missing image shopflow/${img}:${IMAGE_TAG}" >&2 + missing=1 + fi +done +if [ "$missing" -ne 0 ]; then + echo "run ship-images.sh from a workstation first" >&2 + exit 1 +fi + +echo "==> starting database and cache" +compose up -d --pull never --wait db redis + +# Only admin migrates: it owns the shared schema. --force skips the interactive +# confirmation that a non-tty deploy cannot answer. +echo "==> running migrations (admin owns the schema)" +compose run --rm --no-deps admin_app php artisan migrate --force + +# Every Filament resource is gated by a permission row, so these have to exist +# before the panel is usable. RolePermissionSeeder is idempotent, so running it +# on every deploy is safe and picks up any permission added since the last one. +echo "==> syncing roles and permissions" +compose run --rm --no-deps admin_app php artisan db:seed --class="Database\\Seeders\\RolePermissionSeeder" --force + +echo "==> replacing application containers" +compose up -d --pull never --remove-orphans --wait + +echo "==> reclaiming disk from superseded images" +docker image prune -f + +echo +compose ps +echo +echo "done. Logs: docker compose -f $COMPOSE_FILE --env-file $ENV_FILE logs -f" diff --git a/infrastructure/production/ship-images.sh b/infrastructure/production/ship-images.sh new file mode 100755 index 00000000..cf6f5b86 --- /dev/null +++ b/infrastructure/production/ship-images.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Build the production images on a workstation and stream them into the VPS's +# image store over SSH. Run from the repository root: +# +# ./infrastructure/production/ship-images.sh deploy@203.0.113.10 [ssh-port] +# +# Why this exists: deploy.sh builds on the server, which needs Docker Hub, +# Debian's apt mirrors, packagist and the npm registry. On a host where those +# are filtered — an Iranian IP, for instance — every build stage fails and a +# registry mirror alone does not help, because the PHP base stage still has to +# `apt-get install` from deb.debian.org. So the images are built where the +# network works and shipped as a layer tarball. No registry involved. +# +# The server is x86-64 and a workstation may not be, so the build is pinned to +# linux/amd64 and every image's architecture is verified before it is shipped: +# loading an arm64 image would leave containers crash-looping with "exec format +# error" long after the cause scrolled away. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +TARGET="${1:-}" +SSH_PORT="${2:-22}" +PLATFORM="${PLATFORM:-linux/amd64}" + +if [ -z "$TARGET" ]; then + echo "usage: $0 user@host [ssh-port]" >&2 + exit 1 +fi + +if [ -z "${IMAGE_TAG:-}" ]; then + IMAGE_TAG="$(git rev-parse --short HEAD)" +fi + +# Built from the commit, not the working tree: the tag has to mean something +# for `IMAGE_TAG= docker compose up -d` to be a usable rollback. +TREE="$(mktemp -d)" +trap 'rm -rf "$TREE"' EXIT +git archive "${GIT_REF:-HEAD}" | tar -x -C "$TREE" + +APP_IMAGES=( + "shopflow/admin-app:$IMAGE_TAG" + "shopflow/admin-web:$IMAGE_TAG" + "shopflow/shop-app:$IMAGE_TAG" + "shopflow/shop-web:$IMAGE_TAG" + "shopflow/shop-ssr:$IMAGE_TAG" +) +# Pulled rather than built, but the server cannot reach Docker Hub either, so +# they travel in the same tarball. +SERVICE_IMAGES=(postgres:16-alpine redis:7-alpine caddy:2-alpine) + +build() { + local ctx="$1" target="$2" image="$3" + echo "==> building $image ($PLATFORM, target=$target)" + docker buildx build \ + --platform "$PLATFORM" \ + --file "$TREE/$ctx/docker/Dockerfile.prod" \ + --target "$target" \ + --tag "$image" \ + --load \ + "$TREE/$ctx" +} + +echo "==> shipping ${IMAGE_TAG} to ${TARGET} (ssh port ${SSH_PORT})" + +build admin app "shopflow/admin-app:$IMAGE_TAG" +build admin web "shopflow/admin-web:$IMAGE_TAG" +build shop app "shopflow/shop-app:$IMAGE_TAG" +build shop web "shopflow/shop-web:$IMAGE_TAG" +build shop ssr "shopflow/shop-ssr:$IMAGE_TAG" + +echo "==> fetching shared service images" +for img in "${SERVICE_IMAGES[@]}"; do + docker pull --quiet --platform "$PLATFORM" "$img" +done + +# --platform is not optional here. A plain `docker image inspect` resolves a +# multi-platform image to the *host* variant, so on an arm64 workstation the +# pulled postgres/redis/caddy report arm64 and the check would pass while +# shipping images the server cannot execute. +echo "==> verifying every image is $PLATFORM" +for img in "${APP_IMAGES[@]}" "${SERVICE_IMAGES[@]}"; do + got="$(docker image inspect --platform "$PLATFORM" "$img" --format '{{.Os}}/{{.Architecture}}')" + if [ "$got" != "$PLATFORM" ]; then + echo "$img is $got, expected $PLATFORM — refusing to ship" >&2 + exit 1 + fi + printf ' %-40s %s\n' "$img" "$got" +done + +# One `save` for all eight: the two php-fpm images share their whole base and +# the tarball only carries those layers once. --platform again, or a +# multi-platform entry exports every variant it has locally and doubles the +# upload over a link where the upload is the whole cost. +echo "==> streaming images over SSH (this is the slow part)" +docker save --platform "$PLATFORM" "${APP_IMAGES[@]}" "${SERVICE_IMAGES[@]}" \ + | gzip \ + | ssh -p "$SSH_PORT" "$TARGET" 'gunzip | docker load' + +echo +echo "==> images on the server" +ssh -p "$SSH_PORT" "$TARGET" \ + "docker images --filter reference='shopflow/*' --format '{{.Repository}}:{{.Tag}} {{.Size}}'" + +echo +echo "done. Deploy with, on the server:" +echo " IMAGE_TAG=$IMAGE_TAG ./infrastructure/production/deploy-prebuilt.sh" From 6b5751541080669b19b1d9bbbab575ef6fc20754 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:26:14 +0330 Subject: [PATCH 32/55] fix(admin): document seeding the first admin user with a role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canAccessPanel() requires the super-admin or admin role, but make:filament-user assigns none — the account it creates cannot log in. Add the ADMIN_* variables AdminSeeder reads (config/admin.php), so a production deploy sets them instead of shipping the admin@shopFlow.dev / password defaults, and so the seeder — which does assign the role — has values to seed with. Co-Authored-By: Claude Sonnet 5 --- admin/.env.production.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/admin/.env.production.example b/admin/.env.production.example index 46e31225..ed3253e3 100644 --- a/admin/.env.production.example +++ b/admin/.env.production.example @@ -61,6 +61,18 @@ BROADCAST_CONNECTION=log FILESYSTEM_DISK=public FILAMENT_FILESYSTEM_DISK=public +# --- First admin account --------------------------------------------------- +# Read by AdminSeeder, which creates the account and assigns it the super-admin +# role. Set these before running `db:seed --class=Database\Seeders\AdminSeeder` +# and the defaults in config/admin.php (admin@shopFlow.dev / password) never +# reach production. Use AdminSeeder rather than `make:filament-user`: the panel +# gate is `canAccessPanel()`, which requires a role, and make:filament-user +# assigns none — the user it creates cannot log in. +ADMIN_FIRST_NAME= +ADMIN_LAST_NAME= +ADMIN_EMAIL= +ADMIN_PASSWORD= + MAIL_MAILER=smtp MAIL_HOST= MAIL_PORT=587 From e7ff2f5c9c76f9d140c28fbe7cd228816d184028 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:26:37 +0330 Subject: [PATCH 33/55] feat(infra): terminate TLS with a manually-issued certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caddy's automatic HTTPS needs to reach acme-v02.api.letsencrypt.org directly from the VPS to request a certificate — unreachable on a host where ACME, and every other outbound path this stack depends on (Docker Hub, apt, npm), is filtered. Proxying through a CDN doesn't route around it either: a CDN's edge still has to reach this origin, and Cloudflare's cannot. TLS_CERT_FILE / TLS_KEY_FILE point Caddy at a certificate obtained elsewhere instead — a DNS-01 challenge run from a workstation that can reach both Let's Encrypt and the zone's DNS provider — and mounted read-only from infrastructure/production/certs/ (gitignored; holds a private key). certs/README.md documents issuing and renewing it. Also add SHOP_LEGACY_DOMAINS, a Caddy site block that 301-redirects old or alternate storefront hostnames to SHOP_DOMAIN, and TRUSTED_PROXIES, so X-Forwarded-Proto survives correctly if a CDN or LB ever does sit in front of Caddy. Co-Authored-By: Claude Sonnet 5 --- .env.production.example | 19 +++++++-- .gitignore | 7 ++++ compose.prod.yaml | 9 ++++- infrastructure/production/Caddyfile | 35 +++++++++++++--- infrastructure/production/certs/README.md | 49 +++++++++++++++++++++++ 5 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 infrastructure/production/certs/README.md diff --git a/.env.production.example b/.env.production.example index a007dfbd..577055b0 100644 --- a/.env.production.example +++ b/.env.production.example @@ -14,11 +14,24 @@ POSTGRES_PASSWORD= REDIS_PASSWORD= # --- Public hostnames ------------------------------------------------------ -# Both must already resolve to this server; Caddy uses them to request -# certificates on first boot. +# Both must resolve to this server. Caddy does not request a certificate for +# them itself — see infrastructure/production/certs/README.md — so DNS only +# needs to be correct by the time clients connect, not before Caddy starts. SHOP_DOMAIN=shop.example.com ADMIN_DOMAIN=admin.example.com -LETSENCRYPT_EMAIL=you@example.com + +# Optional, space-separated. Old/alternate hostnames for the storefront that +# should 301-redirect to SHOP_DOMAIN instead of serving anything themselves — +# e.g. a bare apex when SHOP_DOMAIN is a subdomain, or vice versa after moving +# it. Must be included as SANs on the certificate in +# infrastructure/production/certs/ (see that directory's README) or the +# redirect itself fails as a certificate error. Leave unset for none. +SHOP_LEGACY_DOMAINS= + +# Space-separated CIDRs whose X-Forwarded-* headers Caddy should trust, or the +# token `private_ranges`. Only matters if a CDN or LB sits in front of Caddy; +# `private_ranges` is correct when clients connect to Caddy directly. +TRUSTED_PROXIES=private_ranges # --- Images ---------------------------------------------------------------- # Tag applied to the built images. Set it to the deployed commit diff --git a/.gitignore b/.gitignore index c5e584dd..c2dd778b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ /.idea /.env.production + +# macOS finder metadata, at any depth +.DS_Store + +# TLS certificate + key for the production proxy, obtained per-deployment +# via infrastructure/production/certs/README.md. +/infrastructure/production/certs/*.pem diff --git a/compose.prod.yaml b/compose.prod.yaml index 38b5acde..fa524a6a 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -224,9 +224,16 @@ services: environment: SHOP_DOMAIN: ${SHOP_DOMAIN} ADMIN_DOMAIN: ${ADMIN_DOMAIN} - LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} + SHOP_LEGACY_DOMAINS: ${SHOP_LEGACY_DOMAINS:-legacy.invalid} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-private_ranges} + # A certificate obtained off-box (see infrastructure/production/ + # README.md) and mounted below — this host cannot reach Let's + # Encrypt's ACME API to get one on its own. + TLS_CERT_FILE: /etc/caddy/certs/fullchain.pem + TLS_KEY_FILE: /etc/caddy/certs/privkey.pem volumes: - ./infrastructure/production/Caddyfile:/etc/caddy/Caddyfile:ro + - ./infrastructure/production/certs:/etc/caddy/certs:ro - caddy_data:/data - caddy_config:/config depends_on: diff --git a/infrastructure/production/Caddyfile b/infrastructure/production/Caddyfile index 404ab433..417b7a0b 100644 --- a/infrastructure/production/Caddyfile +++ b/infrastructure/production/Caddyfile @@ -1,8 +1,19 @@ -# Caddy obtains and renews Let's Encrypt certificates for both domains by -# itself; the only requirement is that the DNS A/AAAA records already point at -# this VPS and that ports 80 and 443 are reachable. +# Caddy's automatic HTTPS calls Let's Encrypt's ACME API +# (acme-v02.api.letsencrypt.org) directly from this host to request a +# certificate, and needs port 80 reachable from the internet to prove control +# of the domain. Both are unusable on a host that can't reach the wider +# internet on those paths — this VPS included, see +# infrastructure/production/README.md — so this Caddyfile does not use it. +# +# Instead TLS_CERT_FILE / TLS_KEY_FILE point at a certificate obtained +# elsewhere (a DNS-01 challenge run from a workstation that can reach both +# Let's Encrypt and this zone's DNS provider) and mounted read-only into this +# container. Both are required — `tls` with no arguments is a Caddyfile parse +# error, not a fallback to automatic management. { - email {$LETSENCRYPT_EMAIL} + servers { + trusted_proxies static {$TRUSTED_PROXIES:private_ranges} + } } (common) { @@ -20,12 +31,24 @@ {$SHOP_DOMAIN} { import common - # Caddy sets X-Forwarded-For/Proto/Host, which Laravel reads through the - # trustProxies middleware in bootstrap/app.php. + tls {$TLS_CERT_FILE} {$TLS_KEY_FILE} reverse_proxy shop_web:80 } {$ADMIN_DOMAIN} { import common + tls {$TLS_CERT_FILE} {$TLS_KEY_FILE} reverse_proxy admin_web:80 } + +# Former/alternate hostnames for the storefront. Kept on the same certificate +# (SHOP_LEGACY_DOMAINS must be included as SANs — see certs/README.md) so the +# redirect itself is a valid HTTPS response instead of a cert error. Leave +# unset for none — `legacy.invalid` is an unroutable placeholder, never +# reached by real traffic. Don't set it to an explicit empty string: unlike +# `tls`, a blank site address is a Caddyfile parse error, not a no-op. +{$SHOP_LEGACY_DOMAINS:legacy.invalid} { + import common + tls {$TLS_CERT_FILE} {$TLS_KEY_FILE} + redir https://{$SHOP_DOMAIN}{uri} permanent +} diff --git a/infrastructure/production/certs/README.md b/infrastructure/production/certs/README.md new file mode 100644 index 00000000..7cb85fce --- /dev/null +++ b/infrastructure/production/certs/README.md @@ -0,0 +1,49 @@ +# TLS certificate for the proxy container + +This directory is bind-mounted read-only into the `proxy` container at +`/etc/caddy/certs`. Caddy expects `fullchain.pem` and `privkey.pem` here — see +`TLS_CERT_FILE`/`TLS_KEY_FILE` in `compose.prod.yaml`. + +Nothing is generated in this directory automatically, and nothing in it is +committed (see `.gitignore`) — it holds a private key. + +## Why a certificate is placed here instead of Caddy fetching its own + +Caddy's automatic HTTPS needs to reach `acme-v02.api.letsencrypt.org` directly +from this host. Where that's blocked — documented in +`infrastructure/production/README.md` for the case this repo was built +against — it never succeeds, and no CDN in front is a workaround either if the +CDN's edge can't route to the origin at all (also documented there). + +## Getting a certificate + +Run this from a workstation that can reach both Let's Encrypt and this +zone's DNS provider — it does not need to reach the origin server at all, +because DNS-01 proves domain control via a TXT record, not an HTTP callback: + +```bash +certbot certonly --manual --preferred-challenges dns \ + --agree-tos -m you@example.com \ + -d shop.example.com -d admin.example.com +``` + +Certbot pauses per domain asking you to publish a +`_acme-challenge. TXT ""` record. Create it with your DNS +provider, wait for it to resolve (`dig TXT _acme-challenge.`), then +continue. The result is a single certificate covering both names at +`/etc/letsencrypt/live/shop.example.com/{fullchain,privkey}.pem`. + +Copy those two files here as `fullchain.pem` and `privkey.pem`, then: + +```bash +docker compose -f compose.prod.yaml --env-file .env.production \ + up -d --pull never --force-recreate proxy +``` + +## Renewal + +Let's Encrypt certificates are valid 90 days. Nothing renews this +automatically — repeat the steps above before it expires and restart `proxy`. +Automating it needs either a DNS provider API certbot has a plugin for (so the +TXT record can be created without a human) or DNS-01 propagation delegated +to a script; neither is wired up here. From 9e8543e43b2449fee2f853c8b7835e59fc09e6cc Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:26:59 +0330 Subject: [PATCH 34/55] docs(infra): document off-box builds, manual TLS, and the admin-seed fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the new deploy path end to end: why deploy.sh's on-box build fails on a filtered host, how ship-images.sh / deploy-prebuilt.sh replace it, why Caddy needs a manually-issued certificate instead of automatic ACME, and the corrected first-admin-user step (AdminSeeder, not make:filament-user — the latter creates a user with no role, and the panel gate requires one). Co-Authored-By: Claude Sonnet 5 --- infrastructure/production/README.md | 82 ++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/infrastructure/production/README.md b/infrastructure/production/README.md index e4bff791..393f3a06 100644 --- a/infrastructure/production/README.md +++ b/infrastructure/production/README.md @@ -46,6 +46,10 @@ A first deployment, in order. Each step is detailed below. Redeploys after that are `git pull && ./infrastructure/production/deploy.sh`. +If the server cannot reach Docker Hub, `deb.debian.org`, packagist or the npm +registry, none of that works and steps 3, 5 and 7 change — see +[Building somewhere else](#building-somewhere-else) below. + ## 1. DNS Create `A` records (and `AAAA` if you have IPv6) for the storefront and the @@ -197,11 +201,24 @@ Later builds reuse the cache and are much faster. ## 8. First admin user +Not `make:filament-user`. The panel gate is `User::canAccessPanel()`, which +requires the `super-admin` or `admin` role, and that command assigns neither — +the account it creates exists and cannot log in. Use `AdminSeeder`, which +creates the user *and* gives it `super-admin`. + +Set the four `ADMIN_*` values in `admin/.env.production` first, or the account +is created with the `config/admin.php` defaults — `admin@shopFlow.dev` with the +password `password`. Then: + ```bash docker compose -f compose.prod.yaml --env-file .env.production \ - exec admin_app php artisan make:filament-user + run --rm --no-deps admin_app \ + php artisan db:seed --class="Database\Seeders\AdminSeeder" --force ``` +It is `firstOrCreate` on the email, so re-running it is harmless — but note that +it will not change the password of an account that already exists. + Then log in at `https://admin.example.com/admin`. ## 9. Backups @@ -222,6 +239,67 @@ docker run --rm -v shopflow_prod_admin_storage:/data -v "$PWD":/backup \ Put both in a cron job that copies the archives *off* the server. A backup on the same disk is not a backup. +## Building somewhere else + +`deploy.sh` builds on the server, and each build stage reaches out to the +network: Docker Hub for the base images, `deb.debian.org` for the libraries the +PHP extensions link against, packagist for `composer install`, the npm registry +for `npm ci`. Where those are filtered — an Iranian IP, for one — the build dies +at the first `apt-get update` inside `php:8.5-fpm-bookworm`. A registry mirror +in `/etc/docker/daemon.json` does not rescue it: that fixes only the base image +pull, and the three later stages still have nowhere to fetch from. + +Build on a workstation instead and ship the result. No registry is involved. + +```bash +# On the workstation, from the repository root: +./infrastructure/production/ship-images.sh deploy@203.0.113.10 9011 + +# Then on the server: +cd ~/ShopFlow +IMAGE_TAG= ./infrastructure/production/deploy-prebuilt.sh +``` + +`ship-images.sh` builds the five images from a clean `git archive` of `HEAD` +(not the working tree — the tag has to mean something for a rollback), pulls +`postgres`/`redis`/`caddy` alongside them, checks every one is `linux/amd64`, +and streams all eight through `docker save | ssh | docker load` as a single +tarball so the two php-fpm images share their base layers instead of carrying +them twice. + +The architecture check is the part not to remove. A plain `docker image +inspect` resolves a multi-platform image to the *host* variant, so on an Apple +Silicon machine the pulled `redis:7-alpine` reports `arm64` and looks fine; +`--platform` is what makes the check mean anything. Ship the wrong variant and +the containers crash-loop with `exec format error` long after the cause has +scrolled off screen. + +`deploy-prebuilt.sh` is `deploy.sh` with the build dropped and `--pull never` +added, so a missing tag fails immediately instead of hanging on a registry that +will never answer. Migrations still run once from a throwaway container. + +Only the deploy files need to reach the server — `compose.prod.yaml`, the +`Caddyfile`, `deploy-prebuilt.sh` and the three env files. The app source does +not: Compose never stats a build context it is not building. + +### TLS when ACME is unreachable + +Caddy cannot issue a certificate on a host that cannot reach +`acme-v02.api.letsencrypt.org`. Set `SITE_SCHEME=http://` in `.env.production` +and it serves plain HTTP and asks for no certificate, leaving TLS to a CDN in +front — Cloudflare, ArvanCloud — which reaches the origin over port 80. The +apps still generate `https://` URLs, because the CDN's `X-Forwarded-Proto` +survives the extra hop and `trustProxies` in both `bootstrap/app.php` files +honours it. Keep `APP_URL` on `https://` and `SESSION_SECURE_COOKIE=true`: the +browser's half of the connection is TLS even though this hop is not. + +Two consequences worth stating. Anyone who knows the origin IP can reach the +site over plain HTTP with a spoofed `Host` header and bypass the CDN entirely, +so an origin allowlist of the CDN's ranges belongs on the follow-up list. And +with Cloudflare specifically, "Flexible" mode is the quick version; a Cloudflare +Origin Certificate mounted into Caddy plus "Full (strict)" is the one to end up +on, and it needs no ACME access. + ## Redeploying ```bash @@ -280,6 +358,8 @@ dcp --profile workers up -d | Product images 404 | `IMAGE_URL` does not match the admin domain, or the uploads volume is not mounted | | Panel redirects to `http://` | `APP_URL` is not `https://` | | Build killed during `npm run build` | Out of RAM — add swap (step 2) | +| Containers exit with `exec format error` | Images built for the wrong architecture — see [Building somewhere else](#building-somewhere-else) | +| `apt-get update` fails in the PHP base stage | The host cannot reach `deb.debian.org`; build elsewhere | ## Known follow-ups From b000a8ed13fde48ef2c95ec439ff9e8984feb05c Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:56:41 +0330 Subject: [PATCH 35/55] feat(admin)!: remove the admin-composed home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storefront never read `home_sections`. It always rendered a fixed layout, so reordering or disabling a row in the panel changed nothing on the site — the table, its resource, model, factory, seeder and enum were an elaborate no-op. What appears on the home page is controlled by banner/slider positions instead, which the storefront does read. The create migration is deleted so a fresh database never builds the table, and drop_home_sections_table removes it from databases that already ran it. Co-Authored-By: Claude Sonnet 5 --- admin/app/Enums/HomeSectionTypeEnum.php | 36 ----- .../Resources/HomeSectionResource.php | 141 ------------------ .../Pages/CreateHomeSection.php | 18 --- .../Pages/EditHomeSection.php | 26 ---- .../Pages/ListHomeSections.php | 29 ---- admin/app/Models/HomeSection.php | 45 ------ .../database/factories/HomeSectionFactory.php | 31 ---- ...6_20_000025_create_home_sections_table.php | 37 ----- ..._08_07_000000_drop_home_sections_table.php | 45 ++++++ admin/database/seeders/DatabaseSeeder.php | 1 - admin/database/seeders/HomeSectionSeeder.php | 37 ----- admin/lang/en/home_section.php | 28 ---- admin/lang/fa/home_section.php | 28 ---- .../Resource/HomeSectionResourceTest.php | 78 ---------- 14 files changed, 45 insertions(+), 535 deletions(-) delete mode 100644 admin/app/Enums/HomeSectionTypeEnum.php delete mode 100644 admin/app/Filament/Resources/HomeSectionResource.php delete mode 100644 admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php delete mode 100644 admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php delete mode 100644 admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php delete mode 100644 admin/app/Models/HomeSection.php delete mode 100644 admin/database/factories/HomeSectionFactory.php delete mode 100644 admin/database/migrations/2026_06_20_000025_create_home_sections_table.php create mode 100644 admin/database/migrations/2026_08_07_000000_drop_home_sections_table.php delete mode 100644 admin/database/seeders/HomeSectionSeeder.php delete mode 100644 admin/lang/en/home_section.php delete mode 100644 admin/lang/fa/home_section.php delete mode 100644 admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php diff --git a/admin/app/Enums/HomeSectionTypeEnum.php b/admin/app/Enums/HomeSectionTypeEnum.php deleted file mode 100644 index 5516ce2b..00000000 --- a/admin/app/Enums/HomeSectionTypeEnum.php +++ /dev/null @@ -1,36 +0,0 @@ - trans('home_section.type_slider'), - self::TAGS => trans('home_section.type_tags'), - self::CATEGORIES => trans('home_section.type_categories'), - self::BANNERS => trans('home_section.type_banners'), - self::PRODUCTS => trans('home_section.type_products'), - self::BRANDS => trans('home_section.type_brands'), - }; - } -} diff --git a/admin/app/Filament/Resources/HomeSectionResource.php b/admin/app/Filament/Resources/HomeSectionResource.php deleted file mode 100644 index 3e0b6463..00000000 --- a/admin/app/Filament/Resources/HomeSectionResource.php +++ /dev/null @@ -1,141 +0,0 @@ -components([ - Select::make('type') - ->label(trans('home_section.type')) - ->required() - ->live() - ->options(HomeSectionTypeEnum::options()) - ->default(HomeSectionTypeEnum::PRODUCTS->value) - ->native(false) - ->hintIcon('heroicon-o-information-circle') - ->hintIconTooltip(trans('home_section.type_hint')), - - // Slider/banner sections point at a position (which slider or - // banner group to show); the options depend on the type. - Select::make('config.position') - ->label(trans('home_section.position')) - ->options(fn (Get $get): array => match ($get('type')) { - HomeSectionTypeEnum::SLIDER->value => SliderPositionEnum::options(), - HomeSectionTypeEnum::BANNERS->value => BannerPositionEnum::options(), - default => [], - }) - ->visible(fn (Get $get): bool => in_array($get('type'), [ - HomeSectionTypeEnum::SLIDER->value, - HomeSectionTypeEnum::BANNERS->value, - ], true)) - ->required(fn (Get $get): bool => in_array($get('type'), [ - HomeSectionTypeEnum::SLIDER->value, - HomeSectionTypeEnum::BANNERS->value, - ], true)) - ->native(false), - - // Product rows carry a sort and a heading. - Select::make('config.sort') - ->label(trans('home_section.sort_by')) - ->options([ - 'newest' => trans('home_section.sort_newest'), - 'popular' => trans('home_section.sort_popular'), - ]) - ->visible(fn (Get $get): bool => $get('type') === HomeSectionTypeEnum::PRODUCTS->value) - ->required(fn (Get $get): bool => $get('type') === HomeSectionTypeEnum::PRODUCTS->value) - ->native(false), - TextInput::make('title') - ->label(trans('home_section.title')) - ->maxLength(255) - ->visible(fn (Get $get): bool => $get('type') === HomeSectionTypeEnum::PRODUCTS->value) - ->hintIcon('heroicon-o-information-circle') - ->hintIconTooltip(trans('home_section.title_hint')), - - Toggle::make('status') - ->label(trans('home_section.status')) - ->default(true), - ]); - } - - public static function table(Table $table): Table - { - return $table - ->reorderable('order') - ->defaultSort('order') - ->columns([ - TextColumn::make('order') - ->label(trans('home_section.order')) - ->sortable(), - TextColumn::make('type') - ->label(trans('home_section.type')) - ->getStateUsing(fn (HomeSection $record): string => $record->type->label()), - TextColumn::make('title') - ->label(trans('home_section.title')) - ->placeholder('—'), - IconColumn::make('status') - ->label(trans('home_section.status')) - ->boolean(), - ]) - ->recordActions([ - EditAction::make(), - ]) - ->toolbarActions([ - BulkActionGroup::make([ - DeleteBulkAction::make(), - ]), - ]); - } - - public static function getPages(): array - { - return [ - 'index' => ListHomeSections::route('/'), - 'create' => CreateHomeSection::route('/create'), - 'edit' => EditHomeSection::route('/{record}/edit'), - ]; - } -} diff --git a/admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php b/admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php deleted file mode 100644 index 815d1783..00000000 --- a/admin/app/Filament/Resources/HomeSectionResource/Pages/CreateHomeSection.php +++ /dev/null @@ -1,18 +0,0 @@ -getResource()::getUrl('index'); - } -} diff --git a/admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php b/admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php deleted file mode 100644 index 255885b0..00000000 --- a/admin/app/Filament/Resources/HomeSectionResource/Pages/EditHomeSection.php +++ /dev/null @@ -1,26 +0,0 @@ -getResource()::getUrl('index'); - } - - protected function getHeaderActions(): array - { - return [ - DeleteAction::make(), - ]; - } -} diff --git a/admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php b/admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php deleted file mode 100644 index a04a404e..00000000 --- a/admin/app/Filament/Resources/HomeSectionResource/Pages/ListHomeSections.php +++ /dev/null @@ -1,29 +0,0 @@ -subheading = trans('home_section.subheading'); - } - - protected function getHeaderActions(): array - { - return [ - CreateAction::make(), - ]; - } -} diff --git a/admin/app/Models/HomeSection.php b/admin/app/Models/HomeSection.php deleted file mode 100644 index 98e3f713..00000000 --- a/admin/app/Models/HomeSection.php +++ /dev/null @@ -1,45 +0,0 @@ -|null $config - * @property int $order - * @property bool $status - * @property Carbon|null $created_at - * @property Carbon|null $updated_at - */ -class HomeSection extends Model -{ - /** @use HasFactory */ - use HasFactory; - - protected $fillable = [ - 'type', - 'title', - 'config', - 'order', - 'status', - ]; - - protected $casts = [ - 'type' => HomeSectionTypeEnum::class, - 'config' => 'array', - 'order' => 'integer', - 'status' => 'boolean', - ]; -} diff --git a/admin/database/factories/HomeSectionFactory.php b/admin/database/factories/HomeSectionFactory.php deleted file mode 100644 index 44de3675..00000000 --- a/admin/database/factories/HomeSectionFactory.php +++ /dev/null @@ -1,31 +0,0 @@ - - */ -class HomeSectionFactory extends Factory -{ - protected $model = HomeSection::class; - - /** - * @return array - */ - public function definition(): array - { - return [ - 'type' => HomeSectionTypeEnum::CATEGORIES, - 'title' => null, - 'config' => null, - 'order' => fake()->numberBetween(0, 20), - 'status' => true, - ]; - } -} diff --git a/admin/database/migrations/2026_06_20_000025_create_home_sections_table.php b/admin/database/migrations/2026_06_20_000025_create_home_sections_table.php deleted file mode 100644 index 7b5e0e10..00000000 --- a/admin/database/migrations/2026_06_20_000025_create_home_sections_table.php +++ /dev/null @@ -1,37 +0,0 @@ -id(); - $table->string('type')->default(HomeSectionTypeEnum::PRODUCTS->value); - $table->string('title')->nullable(); - // Type-specific settings (e.g. {"position":"home-main"} for a - // slider, {"sort":"newest"} for a product row). Null for types - // that need none (tags/categories/brands). - $table->json('config')->nullable(); - $table->unsignedInteger('order')->default(0); - $table->boolean('status')->default(true); - $table->timestamps(); - - $table->index(['status', 'order']); - }); - } - - public function down(): void - { - Schema::dropIfExists('home_sections'); - } -}; diff --git a/admin/database/migrations/2026_08_07_000000_drop_home_sections_table.php b/admin/database/migrations/2026_08_07_000000_drop_home_sections_table.php new file mode 100644 index 00000000..44777e8a --- /dev/null +++ b/admin/database/migrations/2026_08_07_000000_drop_home_sections_table.php @@ -0,0 +1,45 @@ +id(); + $table->string('type')->default('products'); + $table->string('title')->nullable(); + $table->json('config')->nullable(); + $table->unsignedInteger('order')->default(0); + $table->boolean('status')->default(true); + $table->timestamps(); + $table->index(['status', 'order']); + }); + } +}; diff --git a/admin/database/seeders/DatabaseSeeder.php b/admin/database/seeders/DatabaseSeeder.php index 059464c7..91070415 100644 --- a/admin/database/seeders/DatabaseSeeder.php +++ b/admin/database/seeders/DatabaseSeeder.php @@ -23,7 +23,6 @@ public function run(): void AttributeGroupCategorySeeder::class, ShippingSeeder::class, SettingSeeder::class, - HomeSectionSeeder::class, ]); } } diff --git a/admin/database/seeders/HomeSectionSeeder.php b/admin/database/seeders/HomeSectionSeeder.php deleted file mode 100644 index b0937d26..00000000 --- a/admin/database/seeders/HomeSectionSeeder.php +++ /dev/null @@ -1,37 +0,0 @@ - HomeSectionTypeEnum::SLIDER, 'title' => null, 'config' => ['position' => 'home-main'], 'order' => 1], - ['type' => HomeSectionTypeEnum::TAGS, 'title' => null, 'config' => null, 'order' => 2], - ['type' => HomeSectionTypeEnum::CATEGORIES, 'title' => null, 'config' => null, 'order' => 3], - ['type' => HomeSectionTypeEnum::BANNERS, 'title' => null, 'config' => ['position' => 'home-middle'], 'order' => 4], - ['type' => HomeSectionTypeEnum::PRODUCTS, 'title' => 'جدیدترین محصولات', 'config' => ['sort' => 'newest'], 'order' => 5], - ['type' => HomeSectionTypeEnum::PRODUCTS, 'title' => 'پربازدیدترین محصولات', 'config' => ['sort' => 'popular'], 'order' => 6], - ['type' => HomeSectionTypeEnum::BRANDS, 'title' => null, 'config' => null, 'order' => 7], - ]; - - foreach ($sections as $section) { - HomeSection::updateOrCreate( - ['order' => $section['order']], - [...$section, 'status' => true], - ); - } - } -} diff --git a/admin/lang/en/home_section.php b/admin/lang/en/home_section.php deleted file mode 100644 index 6f554a3a..00000000 --- a/admin/lang/en/home_section.php +++ /dev/null @@ -1,28 +0,0 @@ - 'Home Section', - 'plural_label' => 'Home Sections', - 'navigation_group' => 'Content', - 'subheading' => 'Compose the storefront home page: add, reorder (drag rows) and toggle sections. Each section is rendered by its type.', - - 'type' => 'Type', - 'type_hint' => 'Which kind of section to render. Some types need extra settings below.', - 'position' => 'Position', - 'sort_by' => 'Sort products by', - 'sort_newest' => 'Newest', - 'sort_popular' => 'Most viewed', - 'title' => 'Title', - 'title_hint' => 'Heading shown above a product row.', - 'order' => 'Order', - 'status' => 'Active', - - 'type_slider' => 'Slider', - 'type_tags' => 'Tags', - 'type_categories' => 'Categories', - 'type_banners' => 'Banners', - 'type_products' => 'Product row', - 'type_brands' => 'Brands', -]; diff --git a/admin/lang/fa/home_section.php b/admin/lang/fa/home_section.php deleted file mode 100644 index dcb165b5..00000000 --- a/admin/lang/fa/home_section.php +++ /dev/null @@ -1,28 +0,0 @@ - 'بخش صفحه خانه', - 'plural_label' => 'بخش‌های صفحه خانه', - 'navigation_group' => 'محتوا', - 'subheading' => 'چیدمان صفحه خانه فروشگاه: بخش‌ها را اضافه، جابجا (با کشیدن ردیف‌ها) و فعال/غیرفعال کنید. هر بخش بر اساس نوعش نمایش داده می‌شود.', - - 'type' => 'نوع', - 'type_hint' => 'نوع بخشی که نمایش داده می‌شود. برخی نوع‌ها به تنظیمات بیشتری در پایین نیاز دارند.', - 'position' => 'موقعیت', - 'sort_by' => 'مرتب‌سازی کالاها بر اساس', - 'sort_newest' => 'جدیدترین', - 'sort_popular' => 'پربازدیدترین', - 'title' => 'عنوان', - 'title_hint' => 'عنوانی که بالای ردیف کالاها نمایش داده می‌شود.', - 'order' => 'ترتیب', - 'status' => 'فعال', - - 'type_slider' => 'اسلایدر', - 'type_tags' => 'تگ‌ها', - 'type_categories' => 'دسته‌بندی‌ها', - 'type_banners' => 'بنرها', - 'type_products' => 'ردیف کالا', - 'type_brands' => 'برندها', -]; diff --git a/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php b/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php deleted file mode 100644 index 022de52d..00000000 --- a/admin/tests/Feature/Filament/Resource/HomeSectionResourceTest.php +++ /dev/null @@ -1,78 +0,0 @@ -assertOk(); -}); - -it('can list home sections in the table.', function () { - $sections = HomeSection::factory()->count(3)->create(); - - livewire(ListHomeSections::class) - ->assertCanSeeTableRecords($sections); -}); - -it('can create a product-row section with a sort and title.', function () { - livewire(CreateHomeSection::class) - ->fillForm([ - 'type' => HomeSectionTypeEnum::PRODUCTS->value, - 'title' => 'جدیدترین محصولات', - 'config' => ['sort' => 'newest'], - 'status' => true, - ]) - ->call('create') - ->assertHasNoFormErrors(); - - $section = HomeSection::query()->latest('id')->firstOrFail(); - expect($section->type)->toBe(HomeSectionTypeEnum::PRODUCTS) - ->and($section->config)->toBe(['sort' => 'newest']); -}); - -it('can create a slider section with a position.', function () { - livewire(CreateHomeSection::class) - ->fillForm([ - 'type' => HomeSectionTypeEnum::SLIDER->value, - 'config' => ['position' => 'home-main'], - 'status' => true, - ]) - ->call('create') - ->assertHasNoFormErrors(); - - expect(HomeSection::query()->latest('id')->firstOrFail()->config)->toBe(['position' => 'home-main']); -}); - -it('requires a position for a slider section.', function () { - livewire(CreateHomeSection::class) - ->fillForm([ - 'type' => HomeSectionTypeEnum::SLIDER->value, - 'config' => ['position' => null], - 'status' => true, - ]) - ->call('create') - ->assertHasFormErrors(['config.position']); -}); - -it('can delete a home section.', function () { - $section = HomeSection::factory()->create(); - - livewire(EditHomeSection::class, ['record' => $section->getRouteKey()]) - ->callAction(DeleteAction::class); - - $this->assertModelMissing($section); -}); From 1490a0173e51684cf634415ff70dfecbdc232bda Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:57:00 +0330 Subject: [PATCH 36/55] feat(admin): show where each banner/slider position lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A position value is an opaque slug — picking `product-side` from a dropdown was guesswork about where it would appear. Each position now carries a description, the storefront page it sits on, the aspect ratio that slot renders at and a recommended source size. The form renders a wireframe of the three storefront pages and highlights the slot the chosen position fills, mirrored into `data-selected` by Alpine so it keeps up with the radio without a server round-trip. Uploads are cropped to the slot's real ratio, so one oddly-shaped image cannot stretch the layout. Co-Authored-By: Claude Sonnet 5 --- admin/app/Enums/BannerPositionEnum.php | 59 +++++ admin/app/Enums/SliderPositionEnum.php | 63 ++++++ admin/app/Traits/HasDescriptions.php | 27 +++ admin/lang/en/banner.php | 5 + admin/lang/en/position_guide.php | 14 ++ admin/lang/en/slide.php | 2 + admin/lang/en/slider.php | 4 + admin/lang/en/variety.php | 1 + admin/lang/fa/banner.php | 5 + admin/lang/fa/position_guide.php | 14 ++ admin/lang/fa/slide.php | 2 + admin/lang/fa/slider.php | 4 + admin/lang/fa/variety.php | 1 + .../filament/forms/position-guide.blade.php | 201 ++++++++++++++++++ .../Feature/Filament/ImageAspectTest.php | 73 +++++++ .../Feature/Filament/PositionGuideTest.php | 154 ++++++++++++++ 16 files changed, 629 insertions(+) create mode 100644 admin/app/Traits/HasDescriptions.php create mode 100644 admin/lang/en/position_guide.php create mode 100644 admin/lang/fa/position_guide.php create mode 100644 admin/resources/views/filament/forms/position-guide.blade.php create mode 100644 admin/tests/Feature/Filament/ImageAspectTest.php create mode 100644 admin/tests/Feature/Filament/PositionGuideTest.php diff --git a/admin/app/Enums/BannerPositionEnum.php b/admin/app/Enums/BannerPositionEnum.php index 2db7b01f..0389f128 100644 --- a/admin/app/Enums/BannerPositionEnum.php +++ b/admin/app/Enums/BannerPositionEnum.php @@ -4,10 +4,17 @@ namespace App\Enums; +use App\Traits\HasDescriptions; use App\Traits\HasOptions; +/** + * Where a banner is shown on the storefront. The storefront mirrors this enum + * (same string values) and has a render site for every case, so anything + * chosen here appears on the site once the banner is published. + */ enum BannerPositionEnum: string { + use HasDescriptions; use HasOptions; case HOME_TOP = 'home-top'; @@ -22,4 +29,56 @@ public function label(): string self::CATEGORY_SIDE => trans('banner.position_category_side'), }; } + + /** + * One sentence describing exactly where on the page this lands, shown + * under the option in the admin form. + */ + public function description(): string + { + return match ($this) { + self::HOME_TOP => trans('banner.position_home_top_description'), + self::HOME_MIDDLE => trans('banner.position_home_middle_description'), + self::CATEGORY_SIDE => trans('banner.position_category_side_description'), + }; + } + + /** + * The aspect ratio this slot renders at on the storefront, as Filament's + * crop format. The upload is cropped to it so a stray tall or square image + * cannot stretch the layout. + */ + public function aspectRatio(): string + { + return match ($this) { + self::HOME_TOP => '5:1', + self::HOME_MIDDLE => '16:9', + self::CATEGORY_SIDE => '4:5', + }; + } + + /** + * Recommended source dimensions in pixels — the rendered size at roughly + * 2x, so the image stays sharp on a retina screen without being wasteful. + */ + public function recommendedSize(): string + { + return match ($this) { + self::HOME_TOP => '1920 × 384', + self::HOME_MIDDLE => '800 × 450', + self::CATEGORY_SIDE => '600 × 750', + }; + } + + /** + * Which storefront page this position sits on — drives the wireframe the + * admin form highlights. + */ + public function page(): string + { + return match ($this) { + self::HOME_TOP, self::HOME_MIDDLE => 'home', + self::CATEGORY_SIDE => 'category', + }; + } } diff --git a/admin/app/Enums/SliderPositionEnum.php b/admin/app/Enums/SliderPositionEnum.php index 879b9c3e..8a378395 100644 --- a/admin/app/Enums/SliderPositionEnum.php +++ b/admin/app/Enums/SliderPositionEnum.php @@ -4,10 +4,17 @@ namespace App\Enums; +use App\Traits\HasDescriptions; use App\Traits\HasOptions; +/** + * Where a slider is shown on the storefront. The storefront mirrors this enum + * (same string values) and has a render site for every case, so anything + * chosen here appears on the site once the slider is published and has slides. + */ enum SliderPositionEnum: string { + use HasDescriptions; use HasOptions; case HOME_MAIN = 'home-main'; @@ -24,4 +31,60 @@ public function label(): string self::PRODUCT_SIDE => trans('slider.position_product_side'), }; } + + /** + * One sentence describing exactly where on the page this lands, shown + * under the option in the admin form. + */ + public function description(): string + { + return match ($this) { + self::HOME_MAIN => trans('slider.position_home_main_description'), + self::HOME_SECONDARY => trans('slider.position_home_secondary_description'), + self::CATEGORY_TOP => trans('slider.position_category_top_description'), + self::PRODUCT_SIDE => trans('slider.position_product_side_description'), + }; + } + + /** + * The aspect ratio this slot renders at on the storefront, as Filament's + * crop format. The upload is cropped to it so a stray tall or square image + * cannot stretch the layout. + */ + public function aspectRatio(): string + { + return match ($this) { + self::HOME_MAIN => '3:1', + self::HOME_SECONDARY => '4:1', + self::CATEGORY_TOP => '4:1', + self::PRODUCT_SIDE => '4:5', + }; + } + + /** + * Recommended source dimensions in pixels — the rendered size at roughly + * 2x, so the image stays sharp on a retina screen without being wasteful. + */ + public function recommendedSize(): string + { + return match ($this) { + self::HOME_MAIN => '1920 × 640', + self::HOME_SECONDARY => '1920 × 480', + self::CATEGORY_TOP => '1920 × 480', + self::PRODUCT_SIDE => '600 × 750', + }; + } + + /** + * Which storefront page this position sits on — drives the wireframe the + * admin form highlights. + */ + public function page(): string + { + return match ($this) { + self::HOME_MAIN, self::HOME_SECONDARY => 'home', + self::CATEGORY_TOP => 'category', + self::PRODUCT_SIDE => 'product', + }; + } } diff --git a/admin/app/Traits/HasDescriptions.php b/admin/app/Traits/HasDescriptions.php new file mode 100644 index 00000000..ab4af880 --- /dev/null +++ b/admin/app/Traits/HasDescriptions.php @@ -0,0 +1,27 @@ + + */ + public static function descriptions(): array + { + return collect(self::cases()) + ->mapWithKeys(fn (self $enum) => [ + $enum->value => $enum->description(), + ]) + ->toArray(); + } +} diff --git a/admin/lang/en/banner.php b/admin/lang/en/banner.php index a2ac9e5b..ee2a89f7 100644 --- a/admin/lang/en/banner.php +++ b/admin/lang/en/banner.php @@ -10,8 +10,11 @@ 'position' => 'Position', 'position_hint' => 'Where this banner appears on the storefront. Only the fixed placements the frontend knows about are offered.', 'position_home_top' => 'Home — top', + 'position_home_top_description' => 'Full-width strip at the very top of the home page, above the main slider. Only the first banner here is shown.', 'position_home_middle' => 'Home — middle grid', + 'position_home_middle_description' => 'Promo grid in the middle of the home page, below the categories. Up to three banners sit side by side.', 'position_category_side' => 'Category page — side', + 'position_category_side_description' => 'Stacked in the sidebar of every category page, under the filters. Hidden on mobile, where the sidebar collapses.', 'heading' => 'Heading', 'url' => 'URL', 'url_hint' => 'Where the banner links to. Use an absolute URL (https://…) or an internal path such as /tags/gaming-gear or /categories/mobile.', @@ -20,6 +23,8 @@ 'status' => 'Status', 'images' => 'Images', 'path' => 'Image File', + 'path_hint' => 'Cropped to :ratio for this position. Upload at least :size pixels so it stays sharp.', + 'path_hint_no_position' => 'Choose a position first — it decides the crop ratio for this image.', 'is_featured' => 'Featured Image', 'alt_text' => 'Alt Text', 'featured' => 'Featured', diff --git a/admin/lang/en/position_guide.php b/admin/lang/en/position_guide.php new file mode 100644 index 00000000..f3d38190 --- /dev/null +++ b/admin/lang/en/position_guide.php @@ -0,0 +1,14 @@ + 'Where it appears', + 'hint' => 'The highlighted block is where this content will be shown. Grey blocks are other parts of the page.', + 'page_home' => 'Home page', + 'page_category' => 'Category page', + 'page_product' => 'Product page', + 'legend_selected' => 'Selected position', + 'legend_available' => 'Other available positions', + 'legend_other' => 'Rest of the page', +]; diff --git a/admin/lang/en/slide.php b/admin/lang/en/slide.php index 501251b8..5d6b46af 100644 --- a/admin/lang/en/slide.php +++ b/admin/lang/en/slide.php @@ -21,6 +21,8 @@ 'order_hint' => 'Display order within the slider. Lower numbers appear first.', 'image' => 'Image', 'path' => 'Image File', + 'path_hint' => ':position — cropped to :ratio. Upload at least :size pixels so it stays sharp.', + 'path_hint_no_slider' => 'Choose a slider first — its position decides the crop ratio for this image.', 'alt_text' => 'Alt Text', 'slider' => 'Slider', 'created_at' => 'Created At', diff --git a/admin/lang/en/slider.php b/admin/lang/en/slider.php index 97d9a4f5..68909c2d 100644 --- a/admin/lang/en/slider.php +++ b/admin/lang/en/slider.php @@ -13,9 +13,13 @@ 'position' => 'Position', 'position_hint' => 'Where this slider appears on the storefront. Pick from the fixed list of placements the frontend knows about; keep one published slider per position.', 'position_home_main' => 'Home — main banner', + 'position_home_main_description' => 'The large hero slider at the top of the home page, under the top banner strip.', 'position_home_secondary' => 'Home — secondary banner', + 'position_home_secondary_description' => 'A shorter, wide slider on the home page, between the promo banner grid and the product rows.', 'position_category_top' => 'Category page — top', + 'position_category_top_description' => 'A wide slider shown on every category page, directly under the category title.', 'position_product_side' => 'Product page — sidebar', + 'position_product_side_description' => 'A narrow, portrait slider on every product page, directly under the buy box.', 'status' => 'Status', 'slides_count' => 'Slides', 'created_at' => 'Created At', diff --git a/admin/lang/en/variety.php b/admin/lang/en/variety.php index 822e5d91..f4c0ec1d 100644 --- a/admin/lang/en/variety.php +++ b/admin/lang/en/variety.php @@ -29,6 +29,7 @@ 'image' => 'Image', 'path' => 'Image File', + 'path_hint' => 'Cropped to 1:1 — product images are square everywhere they appear. Upload at least 1000 × 1000 pixels.', 'alt_text' => 'Alt Text', 'product' => 'Product', diff --git a/admin/lang/fa/banner.php b/admin/lang/fa/banner.php index 38097e0b..ba5be372 100644 --- a/admin/lang/fa/banner.php +++ b/admin/lang/fa/banner.php @@ -10,8 +10,11 @@ 'position' => 'موقعیت', 'position_hint' => 'محل نمایش این بنر در فروشگاه. فقط جایگاه‌های ثابتی که فرانت‌اند می‌شناسد در دسترس است.', 'position_home_top' => 'صفحه خانه — بالا', + 'position_home_top_description' => 'نوار تمام‌عرض در بالاترین بخش صفحه خانه، بالای اسلایدر اصلی. فقط اولین بنر این موقعیت نمایش داده می‌شود.', 'position_home_middle' => 'صفحه خانه — شبکه میانی', + 'position_home_middle_description' => 'شبکه بنرهای تبلیغاتی در میانه صفحه خانه، زیر دسته‌بندی‌ها. تا سه بنر کنار هم نمایش داده می‌شود.', 'position_category_side' => 'صفحه دسته‌بندی — کنار', + 'position_category_side_description' => 'به‌صورت ستونی در نوار کناری همه صفحه‌های دسته‌بندی، زیر فیلترها. در موبایل نمایش داده نمی‌شود.', 'heading' => 'عنوان', 'url' => 'لینک', 'url_hint' => 'مقصد لینک بنر. یک نشانی کامل (‏https://…‏) یا یک مسیر داخلی مانند ‏/tags/gaming-gear‏ یا ‏/categories/mobile‏ وارد کنید.', @@ -20,6 +23,8 @@ 'status' => 'وضعیت', 'images' => 'تصاویر', 'path' => 'فایل تصویر', + 'path_hint' => 'برای این موقعیت با نسبت :ratio برش می‌خورد. دست‌کم :size پیکسل بارگذاری کنید تا کیفیت حفظ شود.', + 'path_hint_no_position' => 'ابتدا موقعیت را انتخاب کنید؛ نسبت برش تصویر از روی آن تعیین می‌شود.', 'is_featured' => 'تصویر شاخص', 'alt_text' => 'متن جایگزین', 'featured' => 'شاخص', diff --git a/admin/lang/fa/position_guide.php b/admin/lang/fa/position_guide.php new file mode 100644 index 00000000..af1bcb1c --- /dev/null +++ b/admin/lang/fa/position_guide.php @@ -0,0 +1,14 @@ + 'محل نمایش', + 'hint' => 'بلوک برجسته‌شده همان جایی است که این محتوا نمایش داده می‌شود. بلوک‌های خاکستری بقیه بخش‌های صفحه هستند.', + 'page_home' => 'صفحه خانه', + 'page_category' => 'صفحه دسته‌بندی', + 'page_product' => 'صفحه محصول', + 'legend_selected' => 'موقعیت انتخاب‌شده', + 'legend_available' => 'موقعیت‌های دیگر این بخش', + 'legend_other' => 'سایر بخش‌های صفحه', +]; diff --git a/admin/lang/fa/slide.php b/admin/lang/fa/slide.php index dab57d5c..e7a57e72 100644 --- a/admin/lang/fa/slide.php +++ b/admin/lang/fa/slide.php @@ -21,6 +21,8 @@ 'order_hint' => 'ترتیب نمایش در اسلایدر. اعداد کمتر اول نشان داده می‌شوند.', 'image' => 'تصویر', 'path' => 'فایل تصویر', + 'path_hint' => ':position — با نسبت :ratio برش می‌خورد. دست‌کم :size پیکسل بارگذاری کنید تا کیفیت حفظ شود.', + 'path_hint_no_slider' => 'ابتدا اسلایدر را انتخاب کنید؛ نسبت برش تصویر از موقعیت آن تعیین می‌شود.', 'alt_text' => 'متن جایگزین', 'slider' => 'اسلایدر', 'created_at' => 'تاریخ ایجاد', diff --git a/admin/lang/fa/slider.php b/admin/lang/fa/slider.php index a34a1652..2bfa2759 100644 --- a/admin/lang/fa/slider.php +++ b/admin/lang/fa/slider.php @@ -13,9 +13,13 @@ 'position' => 'موقعیت', 'position_hint' => 'محل نمایش این اسلایدر در فروشگاه. از فهرست ثابت موقعیت‌هایی که فرانت‌اند می‌شناسد انتخاب کنید؛ برای هر موقعیت یک اسلایدر منتشرشده نگه دارید.', 'position_home_main' => 'صفحه خانه — بنر اصلی', + 'position_home_main_description' => 'اسلایدر بزرگ بالای صفحه خانه، زیر نوار بنر بالایی.', 'position_home_secondary' => 'صفحه خانه — بنر دوم', + 'position_home_secondary_description' => 'اسلایدر عریض و کوتاه‌تر در صفحه خانه، بین شبکه بنرها و ردیف‌های محصول.', 'position_category_top' => 'صفحه دسته‌بندی — بالا', + 'position_category_top_description' => 'اسلایدر عریض در همه صفحه‌های دسته‌بندی، دقیقاً زیر عنوان دسته‌بندی.', 'position_product_side' => 'صفحه محصول — کنار', + 'position_product_side_description' => 'اسلایدر باریک و عمودی در همه صفحه‌های محصول، دقیقاً زیر جعبه خرید.', 'status' => 'وضعیت', 'slides_count' => 'اسلایدها', 'created_at' => 'تاریخ ایجاد', diff --git a/admin/lang/fa/variety.php b/admin/lang/fa/variety.php index 800e52ba..b481278f 100644 --- a/admin/lang/fa/variety.php +++ b/admin/lang/fa/variety.php @@ -29,6 +29,7 @@ 'image' => 'تصویر', 'path' => 'فایل تصویر', + 'path_hint' => 'با نسبت ۱:۱ برش می‌خورد؛ تصاویر محصول در همه‌جا مربعی هستند. دست‌کم ۱۰۰۰ × ۱۰۰۰ پیکسل بارگذاری کنید.', 'alt_text' => 'متن جایگزین', 'product' => 'محصول', diff --git a/admin/resources/views/filament/forms/position-guide.blade.php b/admin/resources/views/filament/forms/position-guide.blade.php new file mode 100644 index 00000000..8cfdd852 --- /dev/null +++ b/admin/resources/views/filament/forms/position-guide.blade.php @@ -0,0 +1,201 @@ +{{-- + Wireframe of the storefront pages, with the slot for the currently selected + position highlighted. + + Receives: + $get - Filament's state accessor (injected into every component view) + $kind - 'banner' | 'slider', passed via ->viewData() + + The highlight is driven by a `data-selected` attribute on the wrapper plus + one CSS rule per position, not by classes computed in PHP. Filament wraps + each schema component in a wire:partial and this component's own state never + changes, so a server round-trip cannot be relied on to repaint it — Alpine + keeps the attribute in step with the radio instantly, and the server-rendered + value covers the first paint. + + Coordinates are right-to-left, matching the Persian storefront: sidebars sit + on the right, headings start on the right. + + Colours are inline rather than Tailwind classes: Filament ships a + precompiled stylesheet that does not scan this file, so utility classes + invented here would not exist. +--}} +@php + // Banner/slider forms select by a `position` field. Other forms (tags) have + // no position column, so they pass the slot to highlight directly. + $selected ??= $get('position'); + $alpineExpression ??= "\$wire.data?.position ?? ''"; + + /** + * Slots per page. `kind` decides which ones this form can fill; the rest are + * drawn as fixed page furniture so the layout stays recognisable. + */ + $pages = [ + 'home' => [ + 'title' => trans('position_guide.page_home'), + 'height' => 198, + 'slots' => [ + ['x' => 4, 'y' => 4, 'w' => 112, 'h' => 8], + ['x' => 4, 'y' => 16, 'w' => 112, 'h' => 11, 'kind' => 'banner', 'value' => 'home-top'], + ['x' => 4, 'y' => 31, 'w' => 112, 'h' => 26, 'kind' => 'slider', 'value' => 'home-main'], + // Category strip. + ['x' => 4, 'y' => 61, 'w' => 112, 'h' => 10], + ['x' => 81, 'y' => 75, 'w' => 35, 'h' => 17, 'kind' => 'banner', 'value' => 'home-middle'], + ['x' => 43, 'y' => 75, 'w' => 34, 'h' => 17, 'kind' => 'banner', 'value' => 'home-middle'], + ['x' => 4, 'y' => 75, 'w' => 35, 'h' => 17, 'kind' => 'banner', 'value' => 'home-middle'], + ['x' => 4, 'y' => 96, 'w' => 112, 'h' => 15, 'kind' => 'slider', 'value' => 'home-secondary'], + // Newest / most viewed product carousels. + ['x' => 4, 'y' => 115,'w' => 112, 'h' => 19], + ['x' => 4, 'y' => 138,'w' => 112, 'h' => 19], + // One carousel per featured tag, after the standard rows. + ['x' => 4, 'y' => 161,'w' => 112, 'h' => 19, 'kind' => 'tags', 'value' => 'home-tags'], + // Brand strip. + ['x' => 4, 'y' => 184,'w' => 112, 'h' => 10], + ], + ], + 'category' => [ + 'title' => trans('position_guide.page_category'), + 'height' => 155, + 'slots' => [ + ['x' => 4, 'y' => 4, 'w' => 112, 'h' => 8], + // Heading starts on the right. + ['x' => 56, 'y' => 16, 'w' => 60, 'h' => 8], + ['x' => 4, 'y' => 28, 'w' => 112, 'h' => 15, 'kind' => 'slider', 'value' => 'category-top'], + // Sidebar on the right: filters, then the banner column. + ['x' => 84, 'y' => 47, 'w' => 32, 'h' => 36], + ['x' => 84, 'y' => 87, 'w' => 32, 'h' => 30, 'kind' => 'banner', 'value' => 'category-side'], + ['x' => 4, 'y' => 47, 'w' => 76, 'h' => 32], + ['x' => 4, 'y' => 83, 'w' => 76, 'h' => 32], + ['x' => 4, 'y' => 119,'w' => 76, 'h' => 32], + ], + ], + 'product' => [ + 'title' => trans('position_guide.page_product'), + 'height' => 155, + 'slots' => [ + ['x' => 4, 'y' => 4, 'w' => 112, 'h' => 8], + ['x' => 56, 'y' => 16, 'w' => 60, 'h' => 6], + // Gallery on the right, details in the middle, buy box on the + // left — the RTL order of the real page. + ['x' => 70, 'y' => 26, 'w' => 46, 'h' => 50], + ['x' => 36, 'y' => 26, 'w' => 30, 'h' => 50], + ['x' => 4, 'y' => 26, 'w' => 28, 'h' => 30], + ['x' => 4, 'y' => 60, 'w' => 28, 'h' => 34, 'kind' => 'slider', 'value' => 'product-side'], + ['x' => 4, 'y' => 100,'w' => 112, 'h' => 22], + ['x' => 4, 'y' => 126,'w' => 112, 'h' => 22], + ], + ], + ]; + + // value => page, used to build one highlight rule per position. + $slotPages = []; + + foreach ($pages as $pageKey => $page) { + foreach ($page['slots'] as $slot) { + if (isset($slot['value'])) { + $slotPages[$slot['value']] = $pageKey; + } + } + } +@endphp + +
+
+ @foreach ($pages as $pageKey => $page) + @php + // A page is dimmed unless it holds a slot this form can fill. + $hasKind = collect($page['slots']) + ->contains(fn (array $slot): bool => ($slot['kind'] ?? null) === $kind); + @endphp + +
+ + @foreach ($page['slots'] as $slot) + + @endforeach + +
{{ $page['title'] }}
+
+ @endforeach +
+ +

+ {{ trans('position_guide.legend_selected') }} + {{ trans('position_guide.legend_available') }} + {{ trans('position_guide.legend_other') }} +

+
+ + diff --git a/admin/tests/Feature/Filament/ImageAspectTest.php b/admin/tests/Feature/Filament/ImageAspectTest.php new file mode 100644 index 00000000..d42f453d --- /dev/null +++ b/admin/tests/Feature/Filament/ImageAspectTest.php @@ -0,0 +1,73 @@ +aspectRatio())->toMatch('/^\d+:\d+$/') + ->and($position->recommendedSize())->toMatch('/^\d+ × \d+$/'); + } +}); + +it('gives every slider position a usable crop ratio and size', function () { + foreach (SliderPositionEnum::cases() as $position) { + expect($position->aspectRatio())->toMatch('/^\d+:\d+$/') + ->and($position->recommendedSize())->toMatch('/^\d+ × \d+$/'); + } +}); + +it('matches the ratios the storefront components render at', function () { + // Keep these in step with SliderSlot.vue / BannerSlot.vue. + expect(SliderPositionEnum::HOME_MAIN->aspectRatio())->toBe('3:1') // hero + ->and(SliderPositionEnum::HOME_SECONDARY->aspectRatio())->toBe('4:1') // wide + ->and(SliderPositionEnum::CATEGORY_TOP->aspectRatio())->toBe('4:1') // wide + ->and(SliderPositionEnum::PRODUCT_SIDE->aspectRatio())->toBe('4:5') // portrait + ->and(BannerPositionEnum::HOME_TOP->aspectRatio())->toBe('5:1') // wide strip + ->and(BannerPositionEnum::HOME_MIDDLE->aspectRatio())->toBe('16:9') // grid + ->and(BannerPositionEnum::CATEGORY_SIDE->aspectRatio())->toBe('4:5'); // sidebar stack +}); + +it('tells the admin the ratio and size once a banner position is chosen', function () { + $hint = BannerResource::imageHint(BannerPositionEnum::HOME_MIDDLE); + + expect($hint)->toContain('16:9')->toContain('800 × 450') + // A missing translation key would echo the key back. + ->not->toContain('banner.path_hint'); +}); + +it('asks for a position before it can name a banner ratio', function () { + expect(BannerResource::imageHint(null)) + ->not->toBe('') + ->not->toContain('banner.path_hint_no_position'); +}); + +it('reads a slide ratio from the slider it belongs to', function () { + $slider = Slider::factory()->create(['position' => SliderPositionEnum::PRODUCT_SIDE->value]); + + expect(SlideResource::positionOf($slider->id))->toBe(SliderPositionEnum::PRODUCT_SIDE) + ->and(SlideResource::positionOf(null))->toBeNull() + ->and(SlideResource::positionOf(999999))->toBeNull(); + + expect(SlideResource::imageHint(SlideResource::positionOf($slider->id))) + ->toContain('4:5')->toContain('600 × 750'); +}); + +it('renders the banner and slide forms with the image editor enabled', function () { + get(BannerResource::getUrl('create'))->assertOk(); + get(SlideResource::getUrl('create'))->assertOk(); +}); diff --git a/admin/tests/Feature/Filament/PositionGuideTest.php b/admin/tests/Feature/Filament/PositionGuideTest.php new file mode 100644 index 00000000..cf20c1f7 --- /dev/null +++ b/admin/tests/Feature/Filament/PositionGuideTest.php @@ -0,0 +1,154 @@ +label())->not->toBe('') + ->and($position->description())->not->toBe('') + // A missing key makes trans() echo the key back. + ->and($position->description())->not->toContain('banner.position_') + ->and($position->page())->toBeIn(['home', 'category', 'product']); + } +}); + +it('gives every slider position a label, a description and a page', function () { + foreach (SliderPositionEnum::cases() as $position) { + expect($position->label())->not->toBe('') + ->and($position->description())->not->toBe('') + ->and($position->description())->not->toContain('slider.position_') + ->and($position->page())->toBeIn(['home', 'category', 'product']); + } +}); + +it('offers a description for every option the banner form lists', function () { + expect(array_keys(BannerPositionEnum::descriptions())) + ->toBe(array_keys(BannerPositionEnum::options())); +}); + +it('offers a description for every option the slider form lists', function () { + expect(array_keys(SliderPositionEnum::descriptions())) + ->toBe(array_keys(SliderPositionEnum::options())); +}); + +it('renders the layout wireframe on the banner create form', function () { + get(BannerResource::getUrl('create')) + ->assertOk() + ->assertSee('pg__slot', escape: false) + ->assertSee(trans('position_guide.page_home')) + ->assertSee(trans('position_guide.page_category')) + ->assertSee(BannerPositionEnum::HOME_TOP->description()); +}); + +it('renders the layout wireframe on the slider create form', function () { + get(SliderResource::getUrl('create')) + ->assertOk() + ->assertSee('pg__slot', escape: false) + ->assertSee(trans('position_guide.page_product')) + ->assertSee(SliderPositionEnum::PRODUCT_SIDE->description()); +}); + +it('highlights the saved position when editing a banner', function () { + $banner = Banner::factory()->create(['position' => BannerPositionEnum::CATEGORY_SIDE->value]); + + get(BannerResource::getUrl('edit', ['record' => $banner])) + ->assertOk() + // The wrapper attribute drives the highlight ... + ->assertSee('data-selected="category-side"', escape: false) + // ... and the slot it points at has to exist. + ->assertSee('data-slot="category-side"', escape: false); +}); + +it('highlights the saved position when editing a slider', function () { + $slider = Slider::factory()->create(['position' => SliderPositionEnum::PRODUCT_SIDE->value]); + + get(SliderResource::getUrl('edit', ['record' => $slider])) + ->assertOk() + ->assertSee('data-selected="product-side"', escape: false) + ->assertSee('data-slot="product-side"', escape: false); +}); + +it('emits a highlight rule for every position of both kinds', function () { + $html = get(BannerResource::getUrl('create'))->getContent(); + + foreach ([...BannerPositionEnum::cases(), ...SliderPositionEnum::cases()] as $position) { + expect($html) + ->toContain('data-slot="' . $position->value . '"') + ->toContain('.pg[data-selected="' . $position->value . '"]'); + } +}); + +// Filament wraps every schema component in a wire:partial. The guide's own +// state never changes, so without an explicit partial re-render the browser +// keeps showing the stale wireframe even though the server renders the right +// one. Filament throws if the named component cannot be resolved, so simply +// changing the position is enough to catch a rename or a typo here. +it('re-renders the wireframe when the banner position changes', function () { + livewire(CreateBanner::class) + ->set('data.position', BannerPositionEnum::HOME_TOP->value) + ->assertSee('data-selected="home-top"', escape: false); +}); + +it('re-renders the wireframe when the slider position changes', function () { + livewire(CreateSlider::class) + ->set('data.position', SliderPositionEnum::PRODUCT_SIDE->value) + ->assertSee('data-selected="product-side"', escape: false); +}); + +it('keeps the guide in step with the radio without a server round-trip', function () { + // Alpine mirrors the radio into data-selected, so a stale wire:partial can + // never leave the wireframe showing the wrong slot. + get(BannerResource::getUrl('create')) + ->assertOk() + ->assertSee('x-bind:data-selected', escape: false); +}); + +it('never writes the wireframe field to the model', function () { + expect(Banner::factory()->create()->getAttributes())->not->toHaveKey('position_guide') + ->and(Slider::factory()->create()->getAttributes())->not->toHaveKey('position_guide'); +}); + +// Featured tags have no position column: they always land in the same slot on +// the home page. The guide appears once the toggle is on, to say where. + +it('hides the tag layout guide until the homepage toggle is on', function () { + livewire(CreateTag::class) + ->set('data.show_on_home', false) + ->assertDontSee('data-slot="home-tags"', escape: false); +}); + +it('shows the tag layout guide when the homepage toggle is on', function () { + livewire(CreateTag::class) + ->set('data.show_on_home', true) + ->assertSee('data-slot="home-tags"', escape: false) + ->assertSee('data-selected="home-tags"', escape: false); +}); + +it('dims the pages a featured tag never appears on', function () { + $html = livewire(CreateTag::class) + ->set('data.show_on_home', true) + ->html(); + + // Home holds the slot; category and product do not. + expect(substr_count($html, 'pg__card--muted'))->toBeGreaterThan(1); +}); From 0c4096909ce8d05fefa198cd668f02c3ae4edac3 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:57:20 +0330 Subject: [PATCH 37/55] feat(admin): gate every Filament resource behind a permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PermissionsEnum` was decorative: it named four post-shaped permissions nothing checked, while no resource declared any authorization at all. Filament therefore fell back to model policies, and with almost none registered every panel user had full access to everything — settings, gateway credentials and staff accounts included. Permissions are now a PermissionGroupEnum (catalog / content / orders / customers / shipping / marketing / settings) crossed with a PermissionActionEnum, so one grant covers every resource in an area instead of needing a permission per resource. Each resource opts in via `AuthorizesWithPermissions` and declares its group; `ResourcePermissionsTest` fails the build if one forgets, since an ungated resource is reachable by anyone who can open the panel. Where a policy exists it still applies on top, but only for the abilities it actually implements — Laravel denies any ability a policy omits, which would otherwise forbid viewing categories just because CategoryPolicy defines only delete. `login()` now seeds real roles instead of hand-rolling one, so tests authorize exactly as the panel does, and deploy.sh runs the seeder on every release to pick up newly added permissions. Co-Authored-By: Claude Sonnet 5 --- admin/AGENTS.md | 15 ++ admin/app/Enums/PermissionActionEnum.php | 57 ++++++++ admin/app/Enums/PermissionGroupEnum.php | 40 ++++++ admin/app/Enums/PermissionsEnum.php | 44 ------ .../Filament/Resources/AddressResource.php | 9 ++ .../Filament/Resources/AncestorResource.php | 9 ++ .../AttributeGroupCategoryResource.php | 9 ++ .../Resources/AttributeGroupResource.php | 9 ++ .../Filament/Resources/AttributeResource.php | 9 ++ .../app/Filament/Resources/BannerResource.php | 56 +++++++- .../app/Filament/Resources/BrandResource.php | 9 ++ .../Filament/Resources/CategoryResource.php | 9 ++ admin/app/Filament/Resources/CityResource.php | 9 ++ .../app/Filament/Resources/CouponResource.php | 9 ++ .../Filament/Resources/DiscountResource.php | 9 ++ admin/app/Filament/Resources/FaqResource.php | 9 ++ .../Filament/Resources/GatewayResource.php | 9 ++ .../Filament/Resources/MenuItemResource.php | 9 ++ admin/app/Filament/Resources/MenuResource.php | 9 ++ .../Filament/Resources/OrderNoteResource.php | 9 ++ .../app/Filament/Resources/OrderResource.php | 9 ++ .../Resources/OrderShippingResource.php | 9 ++ .../Resources/OrderVarietyResource.php | 9 ++ admin/app/Filament/Resources/PageResource.php | 9 ++ .../Filament/Resources/ProductResource.php | 9 ++ .../Filament/Resources/ProvinceResource.php | 9 ++ .../Filament/Resources/ReceiptResource.php | 9 ++ .../app/Filament/Resources/ReviewResource.php | 9 ++ .../Filament/Resources/SettingResource.php | 9 ++ .../Resources/ShippingCityResource.php | 9 ++ .../Resources/ShippingLineResource.php | 9 ++ .../Resources/ShippingMethodResource.php | 9 ++ .../app/Filament/Resources/SlideResource.php | 51 +++++++ .../app/Filament/Resources/SliderResource.php | 31 ++++- admin/app/Filament/Resources/TagResource.php | 28 ++++ .../Resources/TransactionResource.php | 9 ++ .../Filament/Resources/UserConfigResource.php | 9 ++ admin/app/Filament/Resources/UserResource.php | 18 ++- .../Filament/Resources/VarietyResource.php | 15 ++ .../Filament/Resources/WishlistResource.php | 9 ++ .../app/Traits/AuthorizesWithPermissions.php | 104 ++++++++++++++ .../database/seeders/RolePermissionSeeder.php | 87 ++++++++---- admin/lang/en/permission.php | 18 +++ admin/lang/fa/permission.php | 18 +++ .../tests/Feature/ResourcePermissionsTest.php | 128 ++++++++++++++++++ admin/tests/Pest.php | 29 +++- infrastructure/production/deploy.sh | 8 ++ 47 files changed, 941 insertions(+), 76 deletions(-) create mode 100644 admin/app/Enums/PermissionActionEnum.php create mode 100644 admin/app/Enums/PermissionGroupEnum.php delete mode 100644 admin/app/Enums/PermissionsEnum.php create mode 100644 admin/app/Traits/AuthorizesWithPermissions.php create mode 100644 admin/lang/en/permission.php create mode 100644 admin/lang/fa/permission.php create mode 100644 admin/tests/Feature/ResourcePermissionsTest.php diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 4a1e2bc4..725ed846 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -410,6 +410,21 @@ Project-specific patterns. Match these when adding or editing code. All PHP file - Commit with this author: `Bahman026 ` (use `git commit --author="Bahman026 "`). - Always ask before committing. NEVER commit without explicit user approval. +## Authorization + +- Every Filament resource is gated by `App\Traits\AuthorizesWithPermissions` and declares a + `PermissionGroupEnum` (catalog / content / orders / customers / shipping / marketing / settings). + Permissions are `{view,create,update,delete}_{group}`, seeded by `RolePermissionSeeder`. +- **A new resource must declare `permissionGroup()`** — `ResourcePermissionsTest` fails the build otherwise, + because an ungated resource would be reachable by anyone who can open the panel. +- Where a model has a policy it still applies *on top of* the permission, but only for the abilities the + policy actually implements (Laravel denies any ability a policy omits, which would otherwise forbid + viewing categories just because `CategoryPolicy` defines only `delete`). +- `super-admin` holds everything. `admin` runs catalogue/content/promotions fully, and processes orders, + shipping and customers without delete. Settings, gateways and staff accounts are super-admin only. +- `User::canAccessPanel()` keeps storefront customers out of the panel entirely — the two apps share the + `users` table. + ## Implementation order When adding a new entity, build the files in this order, matching the existing files: diff --git a/admin/app/Enums/PermissionActionEnum.php b/admin/app/Enums/PermissionActionEnum.php new file mode 100644 index 00000000..362f37be --- /dev/null +++ b/admin/app/Enums/PermissionActionEnum.php @@ -0,0 +1,57 @@ + trans('permission.action_view'), + self::CREATE => trans('permission.action_create'), + self::UPDATE => trans('permission.action_update'), + self::DELETE => trans('permission.action_delete'), + }; + } + + /** + * The permission name stored in the `permissions` table, e.g. `edit` + * within `orders` becomes `update_orders`. + */ + public function for(PermissionGroupEnum $group): string + { + return $this->value . '_' . $group->value; + } + + /** + * Every permission name this application recognises. + * + * @return array + */ + public static function all(): array + { + $names = []; + + foreach (PermissionGroupEnum::cases() as $group) { + foreach (self::cases() as $action) { + $names[] = $action->for($group); + } + } + + return $names; + } +} diff --git a/admin/app/Enums/PermissionGroupEnum.php b/admin/app/Enums/PermissionGroupEnum.php new file mode 100644 index 00000000..7d85d2c9 --- /dev/null +++ b/admin/app/Enums/PermissionGroupEnum.php @@ -0,0 +1,40 @@ + trans('permission.group_catalog'), + self::CONTENT => trans('permission.group_content'), + self::ORDERS => trans('permission.group_orders'), + self::CUSTOMERS => trans('permission.group_customers'), + self::SHIPPING => trans('permission.group_shipping'), + self::MARKETING => trans('permission.group_marketing'), + self::SETTINGS => trans('permission.group_settings'), + }; + } +} diff --git a/admin/app/Enums/PermissionsEnum.php b/admin/app/Enums/PermissionsEnum.php deleted file mode 100644 index 0a174cc8..00000000 --- a/admin/app/Enums/PermissionsEnum.php +++ /dev/null @@ -1,44 +0,0 @@ - 'View Posts', - self::CREATE_POSTS => 'Create Posts', - self::EDIT_POSTS => 'Edit Posts', - self::DELETE_POSTS => 'Delete Posts', - self::VIEW_USERS => 'View Users', - self::CREATE_USERS => 'Create Users', - self::EDIT_USERS => 'Edit Users', - self::DELETE_USERS => 'Delete Users', - self::VIEW_SETTINGS => 'View Settings', - self::EDIT_SETTINGS => 'Edit Settings', - }; - } -} diff --git a/admin/app/Filament/Resources/AddressResource.php b/admin/app/Filament/Resources/AddressResource.php index d0c072eb..e4a707a3 100644 --- a/admin/app/Filament/Resources/AddressResource.php +++ b/admin/app/Filament/Resources/AddressResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\AddressResource\Pages\CreateAddress; use App\Filament\Resources\AddressResource\Pages\EditAddress; use App\Filament\Resources\AddressResource\Pages\ListAddresses; use App\Models\Address; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\EditAction; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; @@ -21,6 +23,13 @@ class AddressResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CUSTOMERS; + } + protected static ?string $model = Address::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-map-pin'; diff --git a/admin/app/Filament/Resources/AncestorResource.php b/admin/app/Filament/Resources/AncestorResource.php index 600db0dd..7843a2d8 100644 --- a/admin/app/Filament/Resources/AncestorResource.php +++ b/admin/app/Filament/Resources/AncestorResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\AncestorResource\Pages\CreateAncestor; use App\Filament\Resources\AncestorResource\Pages\EditAncestor; use App\Filament\Resources\AncestorResource\Pages\ListAncestors; use App\Models\Ancestor; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\EditAction; use Filament\Forms\Components\TextInput; @@ -19,6 +21,13 @@ class AncestorResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = Ancestor::class; protected static ?int $navigationSort = 1; diff --git a/admin/app/Filament/Resources/AttributeGroupCategoryResource.php b/admin/app/Filament/Resources/AttributeGroupCategoryResource.php index cb579239..589439b0 100644 --- a/admin/app/Filament/Resources/AttributeGroupCategoryResource.php +++ b/admin/app/Filament/Resources/AttributeGroupCategoryResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\AttributeGroupCategoryResource\Pages\CreateAttributeGroupCategory; use App\Filament\Resources\AttributeGroupCategoryResource\Pages\EditAttributeGroupCategory; use App\Filament\Resources\AttributeGroupCategoryResource\Pages\ListAttributeGroupCategories; use App\Models\AttributeGroupCategory; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\EditAction; use Filament\Forms\Components\Select; @@ -21,6 +23,13 @@ class AttributeGroupCategoryResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = AttributeGroupCategory::class; protected static ?int $navigationSort = 3; diff --git a/admin/app/Filament/Resources/AttributeGroupResource.php b/admin/app/Filament/Resources/AttributeGroupResource.php index 9caf3504..ceb0a345 100644 --- a/admin/app/Filament/Resources/AttributeGroupResource.php +++ b/admin/app/Filament/Resources/AttributeGroupResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\AttributeGroupResource\Pages\CreateAttributeGroup; use App\Filament\Resources\AttributeGroupResource\Pages\EditAttributeGroup; use App\Filament\Resources\AttributeGroupResource\Pages\ListAttributeGroups; use App\Models\AttributeGroup; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\EditAction; use Filament\Forms\Components\Select; @@ -20,6 +22,13 @@ class AttributeGroupResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = AttributeGroup::class; protected static ?int $navigationSort = 2; diff --git a/admin/app/Filament/Resources/AttributeResource.php b/admin/app/Filament/Resources/AttributeResource.php index e8fa5458..b9bab647 100644 --- a/admin/app/Filament/Resources/AttributeResource.php +++ b/admin/app/Filament/Resources/AttributeResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\AttributeResource\Pages\CreateAttribute; use App\Filament\Resources\AttributeResource\Pages\EditAttribute; use App\Filament\Resources\AttributeResource\Pages\ListAttributes; use App\Models\Attribute; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\EditAction; use Filament\Forms\Components\Select; @@ -20,6 +22,13 @@ class AttributeResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = Attribute::class; protected static ?int $navigationSort = 4; diff --git a/admin/app/Filament/Resources/BannerResource.php b/admin/app/Filament/Resources/BannerResource.php index 44e1dc5b..359edb2c 100644 --- a/admin/app/Filament/Resources/BannerResource.php +++ b/admin/app/Filament/Resources/BannerResource.php @@ -6,20 +6,25 @@ use App\Enums\BannerPositionEnum; use App\Enums\BannerStatusEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\BannerResource\Pages\CreateBanner; use App\Filament\Resources\BannerResource\Pages\EditBanner; use App\Filament\Resources\BannerResource\Pages\ListBanners; use App\Models\Banner; +use App\Traits\AuthorizesWithPermissions; use Closure; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Radio; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\ViewField; use Filament\Resources\Resource; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; @@ -27,6 +32,13 @@ class BannerResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = Banner::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-photo'; @@ -52,13 +64,29 @@ public static function form(Schema $schema): Schema { return $schema ->components([ - Select::make('position') + // Radio rather than a dropdown: each placement needs a line of + // explanation, and there are only three of them. + Radio::make('position') ->label(trans('banner.position')) ->required() ->options(BannerPositionEnum::options()) - ->native(false) + ->descriptions(BannerPositionEnum::descriptions()) + ->live() + // Filament wraps each component in a wire:partial, and the + // guide's own state never changes — so without this the + // browser keeps the stale wireframe even though the server + // renders the right one. + ->partiallyRenderComponentsAfterStateUpdated(['position_guide']) ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('banner.position_hint')), + // UI only — never written to the model. + ViewField::make('position_guide') + ->label(trans('position_guide.label')) + ->helperText(trans('position_guide.hint')) + ->view('filament.forms.position-guide') + ->viewData(['kind' => 'banner']) + ->dehydrated(false) + ->columnSpanFull(), TextInput::make('heading') ->label(trans('banner.heading')) ->required() @@ -93,6 +121,13 @@ public static function form(Schema $schema): Schema FileUpload::make('path') ->label(trans('banner.path')) ->image() + ->imageEditor() + // Crop to the ratio this position actually renders + // at, so a tall or square upload cannot stretch the + // storefront layout. Two levels up: repeater item, + // then the form. + ->imageCropAspectRatio(fn (Get $get): ?string => BannerPositionEnum::tryFrom((string) $get('../../position'))?->aspectRatio()) + ->helperText(fn (Get $get): string => BannerResource::imageHint(BannerPositionEnum::tryFrom((string) $get('../../position')))) ->nullable() ->columns(1) ->columnSpanFull(), @@ -106,6 +141,23 @@ public static function form(Schema $schema): Schema ]); } + /** + * Tells the admin what to upload: the ratio the slot renders at and the + * pixel size that stays sharp on a retina screen. Falls back to a nudge to + * pick a position first, since the ratio depends on it. + */ + public static function imageHint(?BannerPositionEnum $position): string + { + if (! $position instanceof BannerPositionEnum) { + return trans('banner.path_hint_no_position'); + } + + return trans('banner.path_hint', [ + 'ratio' => $position->aspectRatio(), + 'size' => $position->recommendedSize(), + ]); + } + public static function table(Table $table): Table { return $table diff --git a/admin/app/Filament/Resources/BrandResource.php b/admin/app/Filament/Resources/BrandResource.php index 04da4540..67193e30 100644 --- a/admin/app/Filament/Resources/BrandResource.php +++ b/admin/app/Filament/Resources/BrandResource.php @@ -6,10 +6,12 @@ use AmidEsfahani\FilamentTinyEditor\TinyEditor; use App\Enums\BrandStatusEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\BrandResource\Pages\CreateBrand; use App\Filament\Resources\BrandResource\Pages\EditBrand; use App\Filament\Resources\BrandResource\Pages\ListBrands; use App\Models\Brand; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\EditAction; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; @@ -27,6 +29,13 @@ class BrandResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = Brand::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rectangle-stack'; diff --git a/admin/app/Filament/Resources/CategoryResource.php b/admin/app/Filament/Resources/CategoryResource.php index 6d190321..78e75726 100644 --- a/admin/app/Filament/Resources/CategoryResource.php +++ b/admin/app/Filament/Resources/CategoryResource.php @@ -6,11 +6,13 @@ use AmidEsfahani\FilamentTinyEditor\TinyEditor; use App\Enums\CategoryStatusEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\CategoryResource\Pages\CreateCategory; use App\Filament\Resources\CategoryResource\Pages\EditCategory; use App\Filament\Resources\CategoryResource\Pages\ListCategories; use App\Models\Category; use App\Models\Image; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\EditAction; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Select; @@ -29,6 +31,13 @@ class CategoryResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = Category::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rectangle-stack'; diff --git a/admin/app/Filament/Resources/CityResource.php b/admin/app/Filament/Resources/CityResource.php index af79da0a..2af7deec 100644 --- a/admin/app/Filament/Resources/CityResource.php +++ b/admin/app/Filament/Resources/CityResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\CityResource\Pages\CreateCity; use App\Filament\Resources\CityResource\Pages\EditCity; use App\Filament\Resources\CityResource\Pages\ListCities; use App\Models\City; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -20,6 +22,13 @@ class CityResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::SHIPPING; + } + protected static ?string $model = City::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-map-pin'; diff --git a/admin/app/Filament/Resources/CouponResource.php b/admin/app/Filament/Resources/CouponResource.php index 3e15b88f..7b007ff5 100644 --- a/admin/app/Filament/Resources/CouponResource.php +++ b/admin/app/Filament/Resources/CouponResource.php @@ -6,11 +6,13 @@ use App\Enums\CouponForEnum; use App\Enums\CouponStatusEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\CouponResource\Pages\CreateCoupon; use App\Filament\Resources\CouponResource\Pages\EditCoupon; use App\Filament\Resources\CouponResource\Pages\ListCoupons; use App\Models\Coupon; use App\Models\Variety; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -26,6 +28,13 @@ class CouponResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::MARKETING; + } + protected static ?string $model = Coupon::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-ticket'; diff --git a/admin/app/Filament/Resources/DiscountResource.php b/admin/app/Filament/Resources/DiscountResource.php index 60fb2d7d..4231ac17 100644 --- a/admin/app/Filament/Resources/DiscountResource.php +++ b/admin/app/Filament/Resources/DiscountResource.php @@ -5,11 +5,13 @@ namespace App\Filament\Resources; use App\Enums\DiscountForEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\DiscountResource\Pages\CreateDiscount; use App\Filament\Resources\DiscountResource\Pages\EditDiscount; use App\Filament\Resources\DiscountResource\Pages\ListDiscounts; use App\Models\Discount; use App\Models\Variety; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -25,6 +27,13 @@ class DiscountResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::MARKETING; + } + protected static ?string $model = Discount::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-receipt-percent'; diff --git a/admin/app/Filament/Resources/FaqResource.php b/admin/app/Filament/Resources/FaqResource.php index f2609fe7..4a89f3d9 100644 --- a/admin/app/Filament/Resources/FaqResource.php +++ b/admin/app/Filament/Resources/FaqResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\FaqResource\Pages\CreateFaq; use App\Filament\Resources\FaqResource\Pages\EditFaq; use App\Filament\Resources\FaqResource\Pages\ListFaqs; use App\Models\Faq; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -20,6 +22,13 @@ class FaqResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = Faq::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-question-mark-circle'; diff --git a/admin/app/Filament/Resources/GatewayResource.php b/admin/app/Filament/Resources/GatewayResource.php index c74157ef..afef78d2 100644 --- a/admin/app/Filament/Resources/GatewayResource.php +++ b/admin/app/Filament/Resources/GatewayResource.php @@ -5,10 +5,12 @@ namespace App\Filament\Resources; use App\Enums\GatewayForEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\GatewayResource\Pages\CreateGateway; use App\Filament\Resources\GatewayResource\Pages\EditGateway; use App\Filament\Resources\GatewayResource\Pages\ListGateways; use App\Models\Gateway; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -25,6 +27,13 @@ class GatewayResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::SETTINGS; + } + protected static ?string $model = Gateway::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-library'; diff --git a/admin/app/Filament/Resources/MenuItemResource.php b/admin/app/Filament/Resources/MenuItemResource.php index f5eb242c..69edde75 100644 --- a/admin/app/Filament/Resources/MenuItemResource.php +++ b/admin/app/Filament/Resources/MenuItemResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\MenuItemResource\Pages\CreateMenuItem; use App\Filament\Resources\MenuItemResource\Pages\EditMenuItem; use App\Filament\Resources\MenuItemResource\Pages\ListMenuItems; use App\Models\MenuItem; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -25,6 +27,13 @@ class MenuItemResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = MenuItem::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-list-bullet'; diff --git a/admin/app/Filament/Resources/MenuResource.php b/admin/app/Filament/Resources/MenuResource.php index d7e7271a..0e592da7 100644 --- a/admin/app/Filament/Resources/MenuResource.php +++ b/admin/app/Filament/Resources/MenuResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\MenuResource\Pages\CreateMenu; use App\Filament\Resources\MenuResource\Pages\EditMenu; use App\Filament\Resources\MenuResource\Pages\ListMenus; use App\Models\Menu; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -21,6 +23,13 @@ class MenuResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = Menu::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-bars-3'; diff --git a/admin/app/Filament/Resources/OrderNoteResource.php b/admin/app/Filament/Resources/OrderNoteResource.php index 2a11103c..8c6a1e59 100644 --- a/admin/app/Filament/Resources/OrderNoteResource.php +++ b/admin/app/Filament/Resources/OrderNoteResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\OrderNoteResource\Pages\CreateOrderNote; use App\Filament\Resources\OrderNoteResource\Pages\EditOrderNote; use App\Filament\Resources\OrderNoteResource\Pages\ListOrderNotes; use App\Models\OrderNote; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -20,6 +22,13 @@ class OrderNoteResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::ORDERS; + } + protected static ?string $model = OrderNote::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-pencil-square'; diff --git a/admin/app/Filament/Resources/OrderResource.php b/admin/app/Filament/Resources/OrderResource.php index b0362050..a1c01876 100644 --- a/admin/app/Filament/Resources/OrderResource.php +++ b/admin/app/Filament/Resources/OrderResource.php @@ -6,6 +6,7 @@ use App\Enums\OrderSrcEnum; use App\Enums\OrderStatusEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\OrderResource\Pages\CreateOrder; use App\Filament\Resources\OrderResource\Pages\EditOrder; use App\Filament\Resources\OrderResource\Pages\ListOrders; @@ -15,6 +16,7 @@ use App\Filament\Resources\OrderResource\RelationManagers\ReceiptsRelationManager; use App\Filament\Resources\OrderResource\RelationManagers\TransactionsRelationManager; use App\Models\Order; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -33,6 +35,13 @@ class OrderResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::ORDERS; + } + protected static ?string $model = Order::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-bag'; diff --git a/admin/app/Filament/Resources/OrderShippingResource.php b/admin/app/Filament/Resources/OrderShippingResource.php index 08ef3582..0ea99a0e 100644 --- a/admin/app/Filament/Resources/OrderShippingResource.php +++ b/admin/app/Filament/Resources/OrderShippingResource.php @@ -5,10 +5,12 @@ namespace App\Filament\Resources; use App\Enums\OrderShippingPaymentTypeEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\OrderShippingResource\Pages\CreateOrderShipping; use App\Filament\Resources\OrderShippingResource\Pages\EditOrderShipping; use App\Filament\Resources\OrderShippingResource\Pages\ListOrderShippings; use App\Models\OrderShipping; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -26,6 +28,13 @@ class OrderShippingResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::ORDERS; + } + protected static ?string $model = OrderShipping::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck'; diff --git a/admin/app/Filament/Resources/OrderVarietyResource.php b/admin/app/Filament/Resources/OrderVarietyResource.php index f8c43b40..0ff4f34e 100644 --- a/admin/app/Filament/Resources/OrderVarietyResource.php +++ b/admin/app/Filament/Resources/OrderVarietyResource.php @@ -4,11 +4,13 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\OrderVarietyResource\Pages\CreateOrderVariety; use App\Filament\Resources\OrderVarietyResource\Pages\EditOrderVariety; use App\Filament\Resources\OrderVarietyResource\Pages\ListOrderVarieties; use App\Models\OrderVariety; use App\Models\Variety; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -21,6 +23,13 @@ class OrderVarietyResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::ORDERS; + } + protected static ?string $model = OrderVariety::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-list-bullet'; diff --git a/admin/app/Filament/Resources/PageResource.php b/admin/app/Filament/Resources/PageResource.php index 82532ae0..777f5ea3 100644 --- a/admin/app/Filament/Resources/PageResource.php +++ b/admin/app/Filament/Resources/PageResource.php @@ -6,10 +6,12 @@ use AmidEsfahani\FilamentTinyEditor\TinyEditor; use App\Enums\PageStatusEnum; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\PageResource\Pages\CreatePage; use App\Filament\Resources\PageResource\Pages\EditPage; use App\Filament\Resources\PageResource\Pages\ListPages; use App\Models\Page as PageModel; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -30,6 +32,13 @@ class PageResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = PageModel::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-document-text'; diff --git a/admin/app/Filament/Resources/ProductResource.php b/admin/app/Filament/Resources/ProductResource.php index ff7192d6..aba2e300 100644 --- a/admin/app/Filament/Resources/ProductResource.php +++ b/admin/app/Filament/Resources/ProductResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources; use AmidEsfahani\FilamentTinyEditor\TinyEditor; +use App\Enums\PermissionGroupEnum; use App\Enums\ProductStatusEnum; use App\Enums\VarietyStatusEnum; use App\Filament\Resources\ProductResource\Pages\CreateProduct; @@ -16,6 +17,7 @@ use App\Models\AttributeGroupCategory; use App\Models\Product; use App\Models\Variety; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -43,6 +45,13 @@ class ProductResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = Product::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-bag'; diff --git a/admin/app/Filament/Resources/ProvinceResource.php b/admin/app/Filament/Resources/ProvinceResource.php index be72997b..12a1f875 100644 --- a/admin/app/Filament/Resources/ProvinceResource.php +++ b/admin/app/Filament/Resources/ProvinceResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\ProvinceResource\Pages\CreateProvince; use App\Filament\Resources\ProvinceResource\Pages\EditProvince; use App\Filament\Resources\ProvinceResource\Pages\ListProvinces; use App\Models\Province; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\EditAction; use Filament\Forms\Components\Textarea; @@ -19,6 +21,13 @@ class ProvinceResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::SHIPPING; + } + protected static ?string $model = Province::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-map-pin'; diff --git a/admin/app/Filament/Resources/ReceiptResource.php b/admin/app/Filament/Resources/ReceiptResource.php index bbc4287a..7de514a2 100644 --- a/admin/app/Filament/Resources/ReceiptResource.php +++ b/admin/app/Filament/Resources/ReceiptResource.php @@ -4,11 +4,13 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Enums\ReceiptTypeEnum; use App\Filament\Resources\ReceiptResource\Pages\CreateReceipt; use App\Filament\Resources\ReceiptResource\Pages\EditReceipt; use App\Filament\Resources\ReceiptResource\Pages\ListReceipts; use App\Models\Receipt; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -26,6 +28,13 @@ class ReceiptResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::ORDERS; + } + protected static ?string $model = Receipt::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-banknotes'; diff --git a/admin/app/Filament/Resources/ReviewResource.php b/admin/app/Filament/Resources/ReviewResource.php index 4931b5ea..491cfab3 100644 --- a/admin/app/Filament/Resources/ReviewResource.php +++ b/admin/app/Filament/Resources/ReviewResource.php @@ -4,12 +4,14 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Enums\ReviewStatusEnum; use App\Filament\Resources\ReviewResource\Pages\CreateReview; use App\Filament\Resources\ReviewResource\Pages\EditReview; use App\Filament\Resources\ReviewResource\Pages\ListReviews; use App\Models\Review; use App\Models\Variety; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -25,6 +27,13 @@ class ReviewResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = Review::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-chat-bubble-left-right'; diff --git a/admin/app/Filament/Resources/SettingResource.php b/admin/app/Filament/Resources/SettingResource.php index 102ccc4c..4a1a3f11 100644 --- a/admin/app/Filament/Resources/SettingResource.php +++ b/admin/app/Filament/Resources/SettingResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\SettingResource\Pages\CreateSetting; use App\Filament\Resources\SettingResource\Pages\EditSetting; use App\Filament\Resources\SettingResource\Pages\ListSettings; use App\Models\Setting; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -22,6 +24,13 @@ class SettingResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::SETTINGS; + } + protected static ?string $model = Setting::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-cog-6-tooth'; diff --git a/admin/app/Filament/Resources/ShippingCityResource.php b/admin/app/Filament/Resources/ShippingCityResource.php index e525e69e..53aad6f0 100644 --- a/admin/app/Filament/Resources/ShippingCityResource.php +++ b/admin/app/Filament/Resources/ShippingCityResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\ShippingCityResource\Pages\CreateShippingCity; use App\Filament\Resources\ShippingCityResource\Pages\EditShippingCity; use App\Filament\Resources\ShippingCityResource\Pages\ListShippingCities; use App\Models\ShippingCity; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -24,6 +26,13 @@ class ShippingCityResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::SHIPPING; + } + protected static ?string $model = ShippingCity::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-map-pin'; diff --git a/admin/app/Filament/Resources/ShippingLineResource.php b/admin/app/Filament/Resources/ShippingLineResource.php index e075029d..b5705b76 100644 --- a/admin/app/Filament/Resources/ShippingLineResource.php +++ b/admin/app/Filament/Resources/ShippingLineResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\ShippingLineResource\Pages\CreateShippingLine; use App\Filament\Resources\ShippingLineResource\Pages\EditShippingLine; use App\Filament\Resources\ShippingLineResource\Pages\ListShippingLines; use App\Models\ShippingLine; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -19,6 +21,13 @@ class ShippingLineResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::SHIPPING; + } + protected static ?string $model = ShippingLine::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-truck'; diff --git a/admin/app/Filament/Resources/ShippingMethodResource.php b/admin/app/Filament/Resources/ShippingMethodResource.php index 2d818cb7..3a6805a2 100644 --- a/admin/app/Filament/Resources/ShippingMethodResource.php +++ b/admin/app/Filament/Resources/ShippingMethodResource.php @@ -4,11 +4,13 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Enums\ShippingMethodForEnum; use App\Filament\Resources\ShippingMethodResource\Pages\CreateShippingMethod; use App\Filament\Resources\ShippingMethodResource\Pages\EditShippingMethod; use App\Filament\Resources\ShippingMethodResource\Pages\ListShippingMethods; use App\Models\ShippingMethod; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -24,6 +26,13 @@ class ShippingMethodResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::SHIPPING; + } + protected static ?string $model = ShippingMethod::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list'; diff --git a/admin/app/Filament/Resources/SlideResource.php b/admin/app/Filament/Resources/SlideResource.php index cf6a4cd5..c6bec382 100644 --- a/admin/app/Filament/Resources/SlideResource.php +++ b/admin/app/Filament/Resources/SlideResource.php @@ -4,10 +4,14 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; +use App\Enums\SliderPositionEnum; use App\Filament\Resources\SlideResource\Pages\CreateSlide; use App\Filament\Resources\SlideResource\Pages\EditSlide; use App\Filament\Resources\SlideResource\Pages\ListSlides; use App\Models\Slide; +use App\Models\Slider; +use App\Traits\AuthorizesWithPermissions; use Closure; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; @@ -17,6 +21,7 @@ use Filament\Forms\Components\TextInput; use Filament\Resources\Resource; use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; @@ -24,6 +29,13 @@ class SlideResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = Slide::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-rectangle-stack'; @@ -50,6 +62,7 @@ public static function form(Schema $schema): Schema return $schema ->components([ Select::make('slider_id') + ->live() ->label(trans('slide.slider_id')) ->relationship('slider', 'name') ->searchable() @@ -95,6 +108,12 @@ public static function form(Schema $schema): Schema FileUpload::make('path') ->label(trans('slide.path')) ->image() + ->imageEditor() + // A slide's shape comes from its slider's position: + // a home hero is a wide band, a product sidebar is + // a narrow portrait. + ->imageCropAspectRatio(fn (Get $get): ?string => SlideResource::positionOf($get('../slider_id'))?->aspectRatio()) + ->helperText(fn (Get $get): string => SlideResource::imageHint(SlideResource::positionOf($get('../slider_id')))) ->nullable() ->columnSpanFull(), TextInput::make('alt_text') @@ -113,6 +132,38 @@ public static function form(Schema $schema): Schema ]); } + /** + * The position of the slider a slide belongs to, or null while none is + * chosen. Drives the crop ratio, since a slide has no position of its own. + */ + public static function positionOf(mixed $sliderId): ?SliderPositionEnum + { + if (blank($sliderId)) { + return null; + } + + $slider = Slider::find($sliderId); + + return $slider === null ? null : SliderPositionEnum::tryFrom($slider->position); + } + + /** + * Tells the admin what to upload: the ratio the slot renders at and the + * pixel size that stays sharp on a retina screen. + */ + public static function imageHint(?SliderPositionEnum $position): string + { + if (! $position instanceof SliderPositionEnum) { + return trans('slide.path_hint_no_slider'); + } + + return trans('slide.path_hint', [ + 'position' => $position->label(), + 'ratio' => $position->aspectRatio(), + 'size' => $position->recommendedSize(), + ]); + } + public static function table(Table $table): Table { return $table diff --git a/admin/app/Filament/Resources/SliderResource.php b/admin/app/Filament/Resources/SliderResource.php index f1556da5..c5c3b00e 100644 --- a/admin/app/Filament/Resources/SliderResource.php +++ b/admin/app/Filament/Resources/SliderResource.php @@ -4,17 +4,21 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Enums\SliderPositionEnum; use App\Enums\SliderStatusEnum; use App\Filament\Resources\SliderResource\Pages\CreateSlider; use App\Filament\Resources\SliderResource\Pages\EditSlider; use App\Filament\Resources\SliderResource\Pages\ListSliders; use App\Models\Slider; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Forms\Components\Radio; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\ViewField; use Filament\Resources\Resource; use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; @@ -22,6 +26,13 @@ class SliderResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CONTENT; + } + protected static ?string $model = Slider::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-film'; @@ -53,13 +64,29 @@ public static function form(Schema $schema): Schema ->maxLength(255) ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('slider.name_hint')), - Select::make('position') + // Radio rather than a dropdown: each placement needs a line of + // explanation, and there are only four of them. + Radio::make('position') ->label(trans('slider.position')) ->required() ->options(SliderPositionEnum::options()) - ->native(false) + ->descriptions(SliderPositionEnum::descriptions()) + ->live() + // Filament wraps each component in a wire:partial, and the + // guide's own state never changes — so without this the + // browser keeps the stale wireframe even though the server + // renders the right one. + ->partiallyRenderComponentsAfterStateUpdated(['position_guide']) ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('slider.position_hint')), + // UI only — never written to the model. + ViewField::make('position_guide') + ->label(trans('position_guide.label')) + ->helperText(trans('position_guide.hint')) + ->view('filament.forms.position-guide') + ->viewData(['kind' => 'slider']) + ->dehydrated(false) + ->columnSpanFull(), Select::make('status') ->label(trans('slider.status')) ->required() diff --git a/admin/app/Filament/Resources/TagResource.php b/admin/app/Filament/Resources/TagResource.php index e2013667..f3c17c2c 100644 --- a/admin/app/Filament/Resources/TagResource.php +++ b/admin/app/Filament/Resources/TagResource.php @@ -5,10 +5,12 @@ namespace App\Filament\Resources; use AmidEsfahani\FilamentTinyEditor\TinyEditor; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\TagResource\Pages\CreateTag; use App\Filament\Resources\TagResource\Pages\EditTag; use App\Filament\Resources\TagResource\Pages\ListTags; use App\Models\Tag; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -17,8 +19,10 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\ViewField; use Filament\Resources\Resource; use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; @@ -28,6 +32,13 @@ class TagResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = Tag::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-hashtag'; @@ -94,8 +105,25 @@ public static function form(Schema $schema): Schema ->schema([ Toggle::make('show_on_home') ->label(trans('tag.show_on_home')) + ->live() ->hintIcon('heroicon-o-information-circle') ->hintIconTooltip(trans('tag.show_on_home_hint')), + // Featured tags have no position column — they always + // land in the same slot — so the guide is shown only + // once the toggle is on, to say where that slot is. + // UI only: never written to the model. + ViewField::make('position_guide') + ->label(trans('position_guide.label')) + ->helperText(trans('position_guide.hint')) + ->view('filament.forms.position-guide') + ->viewData([ + 'kind' => 'tags', + 'selected' => 'home-tags', + 'alpineExpression' => "'home-tags'", + ]) + ->visible(fn (Get $get): bool => (bool) $get('show_on_home')) + ->dehydrated(false) + ->columnSpanFull(), TextInput::make('home_order') ->label(trans('tag.home_order')) ->numeric() diff --git a/admin/app/Filament/Resources/TransactionResource.php b/admin/app/Filament/Resources/TransactionResource.php index 9ce77018..883fc8a1 100644 --- a/admin/app/Filament/Resources/TransactionResource.php +++ b/admin/app/Filament/Resources/TransactionResource.php @@ -4,12 +4,14 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Enums\TransactionPortEnum; use App\Enums\TransactionStatusEnum; use App\Filament\Resources\TransactionResource\Pages\CreateTransaction; use App\Filament\Resources\TransactionResource\Pages\EditTransaction; use App\Filament\Resources\TransactionResource\Pages\ListTransactions; use App\Models\Transaction; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -24,6 +26,13 @@ class TransactionResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::ORDERS; + } + protected static ?string $model = Transaction::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-credit-card'; diff --git a/admin/app/Filament/Resources/UserConfigResource.php b/admin/app/Filament/Resources/UserConfigResource.php index 3805f4aa..1386b283 100644 --- a/admin/app/Filament/Resources/UserConfigResource.php +++ b/admin/app/Filament/Resources/UserConfigResource.php @@ -4,10 +4,12 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\UserConfigResource\Pages\CreateUserConfig; use App\Filament\Resources\UserConfigResource\Pages\EditUserConfig; use App\Filament\Resources\UserConfigResource\Pages\ListUserConfigs; use App\Models\UserConfig; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -23,6 +25,13 @@ class UserConfigResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CUSTOMERS; + } + protected static ?string $model = UserConfig::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-adjustments-horizontal'; diff --git a/admin/app/Filament/Resources/UserResource.php b/admin/app/Filament/Resources/UserResource.php index e625cd22..693d11b2 100644 --- a/admin/app/Filament/Resources/UserResource.php +++ b/admin/app/Filament/Resources/UserResource.php @@ -4,6 +4,8 @@ namespace App\Filament\Resources; +use App\Enums\PermissionActionEnum; +use App\Enums\PermissionGroupEnum; use App\Enums\RolesEnum; use App\Enums\UserStatusEnum; use App\Filament\Resources\UserResource\Pages\EditUser; @@ -12,6 +14,7 @@ use App\Models\City; use App\Models\Province; use App\Models\User; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\EditAction; use Filament\Forms\Components\DateTimePicker; @@ -32,6 +35,13 @@ class UserResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CUSTOMERS; + } + protected static ?string $model = User::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-user'; @@ -235,9 +245,15 @@ public static function getEloquentQuery(): Builder return $query; } + /** + * Creating panel users stays super-admin only, on top of the customers + * permission the trait already requires. Defined here so the stricter of + * the two rules wins rather than the trait's shadowing this one. + */ public static function canCreate(): bool { - return Auth::user()->hasRole('super-admin'); + return static::allows(PermissionActionEnum::CREATE, 'create') + && Auth::user()?->hasRole(RolesEnum::SUPER_ADMIN->value) === true; } public static function getPages(): array diff --git a/admin/app/Filament/Resources/VarietyResource.php b/admin/app/Filament/Resources/VarietyResource.php index 1754a76f..a23ce4a5 100644 --- a/admin/app/Filament/Resources/VarietyResource.php +++ b/admin/app/Filament/Resources/VarietyResource.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Enums\VarietyStatusEnum; use App\Filament\Resources\VarietyResource\Pages\CreateVariety; use App\Filament\Resources\VarietyResource\Pages\EditVariety; @@ -11,6 +12,7 @@ use App\Models\Attribute; use App\Models\Product; use App\Models\Variety; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -32,6 +34,13 @@ class VarietyResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CATALOG; + } + protected static ?string $model = Variety::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-shopping-bag'; @@ -139,6 +148,12 @@ public static function form(Schema $schema): Schema FileUpload::make('path') ->label(trans('variety.path')) ->image() + ->imageEditor() + // Product imagery is square everywhere it appears: + // the card grid and the gallery are both + // aspect-square. + ->imageCropAspectRatio('1:1') + ->helperText(trans('variety.path_hint')) ->nullable() ->columnSpanFull(), TextInput::make('alt_text') diff --git a/admin/app/Filament/Resources/WishlistResource.php b/admin/app/Filament/Resources/WishlistResource.php index d68a6590..d08791b8 100644 --- a/admin/app/Filament/Resources/WishlistResource.php +++ b/admin/app/Filament/Resources/WishlistResource.php @@ -4,9 +4,11 @@ namespace App\Filament\Resources; +use App\Enums\PermissionGroupEnum; use App\Filament\Resources\WishlistResource\Pages\CreateWishlist; use App\Filament\Resources\WishlistResource\Pages\ListWishlists; use App\Models\Wishlist; +use App\Traits\AuthorizesWithPermissions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; @@ -18,6 +20,13 @@ class WishlistResource extends Resource { + use AuthorizesWithPermissions; + + public static function permissionGroup(): PermissionGroupEnum + { + return PermissionGroupEnum::CUSTOMERS; + } + protected static ?string $model = Wishlist::class; protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-heart'; diff --git a/admin/app/Traits/AuthorizesWithPermissions.php b/admin/app/Traits/AuthorizesWithPermissions.php new file mode 100644 index 00000000..c77152e8 --- /dev/null +++ b/admin/app/Traits/AuthorizesWithPermissions.php @@ -0,0 +1,104 @@ +can($action->for(static::permissionGroup()))) { + return false; + } + + return static::policyAllows($ability, $record); + } + + /** + * True unless a policy explicitly governs this ability. + * + * The `method_exists` check matters: Laravel denies an ability outright + * when a policy exists but does not implement it, so consulting the policy + * for everything would make a partial policy — CategoryPolicy defines only + * delete — silently forbid viewing and editing categories too. + */ + protected static function policyAllows(string $ability, ?Model $record): bool + { + $model = $record ?? static::getModel(); + $policy = Gate::getPolicyFor($model); + + if ($policy === null || ! method_exists($policy, $ability)) { + return true; + } + + return Gate::allows($ability, $record ?? static::getModel()); + } +} diff --git a/admin/database/seeders/RolePermissionSeeder.php b/admin/database/seeders/RolePermissionSeeder.php index f241b7cb..e5dd1c3f 100644 --- a/admin/database/seeders/RolePermissionSeeder.php +++ b/admin/database/seeders/RolePermissionSeeder.php @@ -4,51 +4,86 @@ namespace Database\Seeders; -use App\Enums\PermissionsEnum; +use App\Enums\PermissionActionEnum; +use App\Enums\PermissionGroupEnum; use App\Enums\RolesEnum; use Illuminate\Database\Seeder; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; +use Spatie\Permission\PermissionRegistrar; class RolePermissionSeeder extends Seeder { - /** - * Run the database seeds. - */ public function run(): void { - foreach (RolesEnum::options() as $value => $label) { - Role::firstOrCreate(['name' => $value]); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + foreach (RolesEnum::cases() as $role) { + Role::findOrCreate($role->value); } - foreach (PermissionsEnum::options() as $value => $label) { - Permission::firstOrCreate(['name' => $value]); + foreach (PermissionActionEnum::all() as $permission) { + Permission::findOrCreate($permission); } - $adminPermissions = [ - PermissionsEnum::VIEW_POSTS->value, - PermissionsEnum::CREATE_POSTS->value, - PermissionsEnum::EDIT_POSTS->value, - PermissionsEnum::DELETE_POSTS->value, - ]; + // Super-admins run the shop: everything, including staff accounts, + // payment gateways and settings. + Role::query() + ->whereName(RolesEnum::SUPER_ADMIN->value) + ->first() + ?->syncPermissions(PermissionActionEnum::all()); + // Admins do the day-to-day work. They can run the catalogue, content + // and promotions outright, and process orders, shipping and customers + // without being able to delete any of that history. Settings, payment + // gateways and staff accounts stay with super-admins. Role::query() ->whereName(RolesEnum::ADMIN->value) ->first() - ?->syncPermissions($adminPermissions); - - $superAdminPermissions = array_merge($adminPermissions, [ - PermissionsEnum::VIEW_USERS->value, - PermissionsEnum::CREATE_USERS->value, - PermissionsEnum::EDIT_USERS->value, - PermissionsEnum::DELETE_USERS->value, - PermissionsEnum::VIEW_SETTINGS->value, - PermissionsEnum::EDIT_SETTINGS->value, - ]); + ?->syncPermissions($this->adminPermissions()); + // `user` is the storefront customer role. It grants nothing in the + // panel — User::canAccessPanel() already keeps customers out, and this + // makes sure a stray role assignment cannot change that. Role::query() - ->whereName(RolesEnum::SUPER_ADMIN->value) + ->whereName(RolesEnum::USER->value) ->first() - ?->syncPermissions($superAdminPermissions); + ?->syncPermissions([]); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + /** + * @return array + */ + private function adminPermissions(): array + { + $fullControl = [ + PermissionGroupEnum::CATALOG, + PermissionGroupEnum::CONTENT, + PermissionGroupEnum::MARKETING, + ]; + + $withoutDeleting = [ + PermissionGroupEnum::ORDERS, + PermissionGroupEnum::SHIPPING, + PermissionGroupEnum::CUSTOMERS, + ]; + + $permissions = []; + + foreach ($fullControl as $group) { + foreach (PermissionActionEnum::cases() as $action) { + $permissions[] = $action->for($group); + } + } + + foreach ($withoutDeleting as $group) { + foreach ([PermissionActionEnum::VIEW, PermissionActionEnum::CREATE, PermissionActionEnum::UPDATE] as $action) { + $permissions[] = $action->for($group); + } + } + + return $permissions; } } diff --git a/admin/lang/en/permission.php b/admin/lang/en/permission.php new file mode 100644 index 00000000..69f0eeaa --- /dev/null +++ b/admin/lang/en/permission.php @@ -0,0 +1,18 @@ + 'Catalogue', + 'group_content' => 'Content', + 'group_orders' => 'Orders', + 'group_customers' => 'Customers', + 'group_shipping' => 'Shipping', + 'group_marketing' => 'Promotions', + 'group_settings' => 'Settings', + + 'action_view' => 'View', + 'action_create' => 'Create', + 'action_update' => 'Edit', + 'action_delete' => 'Delete', +]; diff --git a/admin/lang/fa/permission.php b/admin/lang/fa/permission.php new file mode 100644 index 00000000..d4b84ee9 --- /dev/null +++ b/admin/lang/fa/permission.php @@ -0,0 +1,18 @@ + 'کاتالوگ', + 'group_content' => 'محتوا', + 'group_orders' => 'سفارش‌ها', + 'group_customers' => 'مشتریان', + 'group_shipping' => 'ارسال', + 'group_marketing' => 'تخفیف‌ها و کمپین‌ها', + 'group_settings' => 'تنظیمات', + + 'action_view' => 'مشاهده', + 'action_create' => 'ایجاد', + 'action_update' => 'ویرایش', + 'action_delete' => 'حذف', +]; diff --git a/admin/tests/Feature/ResourcePermissionsTest.php b/admin/tests/Feature/ResourcePermissionsTest.php new file mode 100644 index 00000000..1862ec54 --- /dev/null +++ b/admin/tests/Feature/ResourcePermissionsTest.php @@ -0,0 +1,128 @@ + + */ +function allResourceClasses(): array +{ + return collect(glob(app_path('Filament/Resources/*Resource.php')) ?: []) + ->map(fn (string $path): string => 'App\\Filament\\Resources\\' . basename($path, '.php')) + ->all(); +} + +it('gates every resource behind a permission group', function () { + $ungated = collect(allResourceClasses()) + ->reject(fn (string $class): bool => method_exists($class, 'permissionGroup')) + ->all(); + + expect($ungated)->toBe([], 'Resources with no permission group: ' . implode(', ', $ungated)); +}); + +it('lets a super-admin reach every resource', function () { + login(); + + foreach (allResourceClasses() as $resource) { + expect($resource::canViewAny())->toBeTrue("super-admin cannot view {$resource}"); + } +}); + +it('keeps settings and payment gateways away from a plain admin', function () { + loginAsAdmin(); + + // Day-to-day staff run the catalogue and orders... + expect(ProductResource::canViewAny())->toBeTrue() + ->and(OrderResource::canViewAny())->toBeTrue(); + + // ...but settings and gateway credentials are super-admin territory. + expect(SettingResource::canViewAny())->toBeFalse() + ->and(GatewayResource::canViewAny())->toBeFalse() + ->and(SettingResource::canCreate())->toBeFalse() + ->and(GatewayResource::canCreate())->toBeFalse(); +}); + +it('lets an admin process orders without letting them delete the history', function () { + loginAsAdmin(); + + $order = Order::factory()->create(); + + expect(OrderResource::canEdit($order))->toBeTrue() + ->and(OrderResource::canDelete($order))->toBeFalse() + ->and(OrderResource::canDeleteAny())->toBeFalse(); +}); + +it('denies everything to a panel user holding no permissions', function () { + app(RolePermissionSeeder::class)->run(); + + $user = User::factory()->create(); + $user->assignRole(RolesEnum::USER->value); + actingAs($user); + + foreach (allResourceClasses() as $resource) { + expect($resource::canViewAny())->toBeFalse("{$resource} is reachable with no permissions"); + } +}); + +it('denies everything when nobody is logged in', function () { + Auth::logout(); + + expect(ProductResource::canViewAny())->toBeFalse() + ->and(OrderResource::canViewAny())->toBeFalse(); +}); + +it('still lets a policy tighten what the permission allows', function () { + login(); + + $leaf = Category::factory()->create(); + $withProduct = Category::factory()->create(); + Product::factory()->create(['category_id' => $withProduct->id]); + + // The super-admin holds delete_catalog for both, but CategoryPolicy + // refuses the one that still has products pointing at it. + expect(CategoryResource::canDelete($leaf))->toBeTrue() + ->and(CategoryResource::canDelete($withProduct))->toBeFalse(); +}); + +it('keeps creating panel users to super-admins even with the permission', function () { + loginAsAdmin(); + + // The admin role holds create_customers, but staff accounts stay + // super-admin only. + expect(UserResource::canViewAny())->toBeTrue() + ->and(UserResource::canCreate())->toBeFalse(); + + login(); + expect(UserResource::canCreate())->toBeTrue(); +}); + +it('names every permission the seeder grants', function () { + app(RolePermissionSeeder::class)->run(); + + $expected = PermissionActionEnum::all(); + + expect($expected)->toHaveCount(count(PermissionGroupEnum::cases()) * count(PermissionActionEnum::cases())) + ->and($expected)->toContain('view_orders', 'delete_settings', 'update_catalog'); +}); diff --git a/admin/tests/Pest.php b/admin/tests/Pest.php index 580f5ec9..d89455d9 100644 --- a/admin/tests/Pest.php +++ b/admin/tests/Pest.php @@ -15,6 +15,7 @@ use App\Enums\RolesEnum; use App\Models\User; +use Database\Seeders\RolePermissionSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Spatie\Permission\Models\Role; use Tests\TestCase; @@ -52,10 +53,36 @@ | */ +/** + * A logged-in super-admin. + * + * Roles and permissions come from RolePermissionSeeder rather than being + * hand-rolled, so resource authorization behaves in tests exactly as it does in + * the panel — a resource that a real super-admin could not reach must not be + * reachable here either. + */ function login(?User $user = null): void { $user ??= User::factory()->create(); - Role::create(['name' => RolesEnum::SUPER_ADMIN->value]); + + app(RolePermissionSeeder::class)->run(); + $user->assignRole(RolesEnum::SUPER_ADMIN->value); actingAs($user); } + +/** + * A logged-in admin — the day-to-day staff role, which deliberately cannot + * reach settings, gateways or staff accounts. + */ +function loginAsAdmin(?User $user = null): User +{ + $user ??= User::factory()->create(); + + app(RolePermissionSeeder::class)->run(); + + $user->assignRole(RolesEnum::ADMIN->value); + actingAs($user); + + return $user; +} diff --git a/infrastructure/production/deploy.sh b/infrastructure/production/deploy.sh index 059d96b9..1e188b59 100755 --- a/infrastructure/production/deploy.sh +++ b/infrastructure/production/deploy.sh @@ -43,6 +43,14 @@ compose up -d --wait db redis echo "==> running migrations (admin owns the schema)" compose run --rm --no-deps admin_app php artisan migrate --force +# Every Filament resource is gated by a permission row, so these have to exist +# before the panel is usable — without them staff log in to an empty panel with +# no resources at all. RolePermissionSeeder is idempotent (findOrCreate + +# syncPermissions), so running it on every deploy is safe and also picks up any +# permission added since the last release. +echo "==> syncing roles and permissions" +compose run --rm --no-deps admin_app php artisan db:seed --class="Database\\Seeders\\RolePermissionSeeder" --force + echo "==> replacing application containers" compose up -d --build --remove-orphans --wait From 60d3ef02d2930eee2362f98f08d1b6df8581e8f8 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:57:48 +0330 Subject: [PATCH 38/55] fix(admin): constrain categories.parent_id with a foreign key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was the only relationship in the schema with no foreign key — a bare integer. Deleting a parent left its children pointing at a row that no longer existed, and because the storefront walks `parent_id` to collect descendants, an orphaned subtree silently vanished from category listings instead of failing loudly. The column is widened to bigint to match `categories.id`, any already-orphaned child is promoted to a root, and the constraint restricts on delete rather than cascading — cascading would take out an entire subtree and, through products, a great deal more. This matches `products.category_id`, which already restricted. CategoryPolicy now refuses to delete a category while children or products still point at it, so the panel stops offering a delete button that could only ever produce a raw constraint error. Co-Authored-By: Claude Sonnet 5 --- admin/app/Policies/CategoryPolicy.php | 22 ++++++- ...parent_foreign_key_to_categories_table.php | 56 ++++++++++++++++ admin/tests/Feature/CategoryTreeTest.php | 64 +++++++++++++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 admin/database/migrations/2026_08_07_000001_add_parent_foreign_key_to_categories_table.php create mode 100644 admin/tests/Feature/CategoryTreeTest.php diff --git a/admin/app/Policies/CategoryPolicy.php b/admin/app/Policies/CategoryPolicy.php index 6a073d60..8b2f2454 100644 --- a/admin/app/Policies/CategoryPolicy.php +++ b/admin/app/Policies/CategoryPolicy.php @@ -5,18 +5,34 @@ namespace App\Policies; use App\Enums\RolesEnum; +use App\Models\Category; use App\Models\User; class CategoryPolicy { /** - * Determine whether the user can delete the model. + * Deleting a category is restricted to super-admins, and refused outright + * while anything still points at it. + * + * Both `products.category_id` and `categories.parent_id` restrict on + * delete, so the database would reject these anyway — but as a raw query + * error in the panel. Checking here turns that into a delete button that + * simply is not offered. */ - public function delete(User $user): bool + public function delete(User $user, Category $category): bool { - return $user->hasRole(RolesEnum::SUPER_ADMIN->value); + if (! $user->hasRole(RolesEnum::SUPER_ADMIN->value)) { + return false; + } + + return ! $category->children()->exists() + && ! $category->products()->exists(); } + /** + * Bulk delete cannot inspect the selection up front, so it stays a + * role check; individual rows are still protected by the constraints. + */ public function deleteAny(User $user): bool { return $user->hasRole(RolesEnum::SUPER_ADMIN->value); diff --git a/admin/database/migrations/2026_08_07_000001_add_parent_foreign_key_to_categories_table.php b/admin/database/migrations/2026_08_07_000001_add_parent_foreign_key_to_categories_table.php new file mode 100644 index 00000000..c122a707 --- /dev/null +++ b/admin/database/migrations/2026_08_07_000001_add_parent_foreign_key_to_categories_table.php @@ -0,0 +1,56 @@ +whereNotNull('parent_id') + ->whereNotIn('parent_id', DB::table('categories')->select('id')) + ->update(['parent_id' => null]); + + // `categories.id` is a bigint while `parent_id` was a 4-byte integer; + // the types have to match before one can reference the other. + Schema::table('categories', function (Blueprint $table): void { + $table->unsignedBigInteger('parent_id')->nullable()->change(); + }); + + Schema::table('categories', function (Blueprint $table): void { + // Restrict rather than cascade: cascading would silently delete an + // entire subtree (and, through products, a lot more). This matches + // products.category_id, which already restricts. + $table->foreign('parent_id') + ->references('id') + ->on('categories') + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('categories', function (Blueprint $table): void { + $table->dropForeign(['parent_id']); + }); + + Schema::table('categories', function (Blueprint $table): void { + $table->unsignedInteger('parent_id')->nullable()->change(); + }); + } +}; diff --git a/admin/tests/Feature/CategoryTreeTest.php b/admin/tests/Feature/CategoryTreeTest.php new file mode 100644 index 00000000..c9782d67 --- /dev/null +++ b/admin/tests/Feature/CategoryTreeTest.php @@ -0,0 +1,64 @@ +create(); + Category::factory()->create(['parent_id' => $parent->id]); + + expect(fn () => $parent->delete())->toThrow(QueryException::class); + + expect(Category::query()->whereKey($parent->id)->exists())->toBeTrue(); +}); + +it('allows deleting a leaf category', function () { + $leaf = Category::factory()->create(); + + $leaf->delete(); + + expect(Category::query()->whereKey($leaf->id)->exists())->toBeFalse(); +}); + +it('cannot orphan a child by pointing it at a category that does not exist', function () { + $category = Category::factory()->create(); + + expect(fn () => $category->update(['parent_id' => 999999])) + ->toThrow(QueryException::class); +}); + +it('hides the delete action for a category that still has children or products', function () { + Role::findOrCreate(RolesEnum::SUPER_ADMIN->value); + /** @var User $superAdmin */ + $superAdmin = User::factory()->create(); + $superAdmin->assignRole(RolesEnum::SUPER_ADMIN->value); + + $leaf = Category::factory()->create(); + $withChild = Category::factory()->create(); + Category::factory()->create(['parent_id' => $withChild->id]); + $withProduct = Category::factory()->create(); + Product::factory()->create(['category_id' => $withProduct->id]); + + expect($superAdmin->can('delete', $leaf))->toBeTrue() + ->and($superAdmin->can('delete', $withChild))->toBeFalse() + ->and($superAdmin->can('delete', $withProduct))->toBeFalse(); +}); + +it('still keeps deletion to super-admins', function () { + Role::findOrCreate(RolesEnum::ADMIN->value); + /** @var User $admin */ + $admin = User::factory()->create(); + $admin->assignRole(RolesEnum::ADMIN->value); + + expect($admin->can('delete', Category::factory()->create()))->toBeFalse(); +}); From 4bfcb10f885221d35bea279940be68080962d92f Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:57:48 +0330 Subject: [PATCH 39/55] feat(admin): keep inventory in step with order status changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ORDER.md states that stock is consumed from the moment an order is paid and released if it is canceled or returned. The storefront upheld that for gateway payments only. Every status change staff made in the panel — confirming a card-to-card receipt, canceling, accepting a return — left `varieties.inventory` untouched, so the two halves of the same invariant disagreed. `OrderStatusEnum::consumesStock()` is now the single definition of which statuses hold stock, and OrderObserver adjusts inventory on any move between the two sets. Only crossings act, so PAID -> SHIPPED and a plain re-save change nothing. Lines are row-locked in a consistent order, the same as DecrementInventoryAndMarkPaid. A transition that stock cannot cover is refused rather than pushing an unsigned column negative: EditOrder wraps the save in a transaction and surfaces which variety fell short, instead of a 500 over an order that had already moved. The observer is registered by the admin app only — the storefront has its own Order model, so a Zarinpal payment still decrements exactly once. Co-Authored-By: Claude Sonnet 5 --- admin/app/Enums/OrderStatusEnum.php | 16 +++ .../InsufficientInventoryException.php | 40 ++++++ .../OrderResource/Pages/EditOrder.php | 32 +++++ admin/app/Observers/OrderObserver.php | 128 ++++++++++++++++++ admin/app/Providers/AppServiceProvider.php | 6 +- admin/lang/en/order.php | 2 + admin/lang/fa/order.php | 2 + admin/tests/Feature/OrderInventoryTest.php | 117 ++++++++++++++++ 8 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 admin/app/Exceptions/InsufficientInventoryException.php create mode 100644 admin/app/Observers/OrderObserver.php create mode 100644 admin/tests/Feature/OrderInventoryTest.php diff --git a/admin/app/Enums/OrderStatusEnum.php b/admin/app/Enums/OrderStatusEnum.php index f8850888..c936e793 100644 --- a/admin/app/Enums/OrderStatusEnum.php +++ b/admin/app/Enums/OrderStatusEnum.php @@ -31,6 +31,22 @@ public function label(): string }; } + /** + * Whether an order in this status is holding stock. + * + * This is the single definition of the invariant docs/ORDER.md states: + * stock is consumed from the moment an order is paid and released again if + * it is canceled or returned. Everything that moves an order between these + * two sets has to keep `varieties.inventory` in step (OrderObserver). + */ + public function consumesStock(): bool + { + return match ($this) { + self::PAID, self::PROCESSING, self::SHIPPED, self::DELIVERED => true, + self::PENDING, self::CANCELED, self::RETURNED => false, + }; + } + public function color(): string { return match ($this) { diff --git a/admin/app/Exceptions/InsufficientInventoryException.php b/admin/app/Exceptions/InsufficientInventoryException.php new file mode 100644 index 00000000..aae4009e --- /dev/null +++ b/admin/app/Exceptions/InsufficientInventoryException.php @@ -0,0 +1,40 @@ + $this->varietyLabel, + 'available' => $this->available, + 'required' => $this->required, + ]); + } +} diff --git a/admin/app/Filament/Resources/OrderResource/Pages/EditOrder.php b/admin/app/Filament/Resources/OrderResource/Pages/EditOrder.php index a78bc5d6..f40377f2 100644 --- a/admin/app/Filament/Resources/OrderResource/Pages/EditOrder.php +++ b/admin/app/Filament/Resources/OrderResource/Pages/EditOrder.php @@ -4,9 +4,14 @@ namespace App\Filament\Resources\OrderResource\Pages; +use App\Exceptions\InsufficientInventoryException; use App\Filament\Resources\OrderResource; use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Support\Exceptions\Halt; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; class EditOrder extends EditRecord { @@ -23,4 +28,31 @@ protected function getHeaderActions(): array DeleteAction::make(), ]; } + + /** + * Saves inside a transaction so the stock adjustment OrderObserver makes + * and the status change itself either both land or neither does. + * + * Without the transaction a status change that cannot be covered by stock + * would still be written, and the observer's refusal would surface as a + * 500 over an order that had already moved. + * + * @param array $data + */ + protected function handleRecordUpdate(Model $record, array $data): Model + { + try { + return DB::transaction(fn (): Model => parent::handleRecordUpdate($record, $data)); + } catch (InsufficientInventoryException $exception) { + Notification::make() + ->title(trans('order.inventory_insufficient_title')) + ->body($exception->forHumans()) + ->danger() + ->persistent() + ->send(); + + // Halt keeps the user on the form with the record untouched. + throw new Halt; + } + } } diff --git a/admin/app/Observers/OrderObserver.php b/admin/app/Observers/OrderObserver.php new file mode 100644 index 00000000..f7f7078a --- /dev/null +++ b/admin/app/Observers/OrderObserver.php @@ -0,0 +1,128 @@ + SHIPPED, changes nothing. + * + * This observer is registered by the admin app only. The storefront has its + * own Order model with no observer, so a Zarinpal payment still decrements + * exactly once — in DecrementInventoryAndMarkPaid — and never twice. + */ + public function updated(Order $order): void + { + if (! $order->wasChanged('status')) { + return; + } + + $before = $this->statusBefore($order); + $after = $order->status; + + $wasHoldingStock = $before?->consumesStock() ?? false; + $isHoldingStock = $after->consumesStock(); + + if ($wasHoldingStock === $isHoldingStock) { + return; + } + + $isHoldingStock + ? $this->consume($order) + : $this->release($order); + } + + /** + * The status the order had before this save, or null when it cannot be + * read (a freshly created order has nothing to compare against). + */ + private function statusBefore(Order $order): ?OrderStatusEnum + { + $original = $order->getOriginal('status'); + + if ($original instanceof OrderStatusEnum) { + return $original; + } + + return is_numeric($original) ? OrderStatusEnum::tryFrom((int) $original) : null; + } + + /** + * @throws InsufficientInventoryException when a line cannot be covered + */ + private function consume(Order $order): void + { + DB::transaction(function () use ($order): void { + foreach ($this->lockedLines($order) as [$line, $variety]) { + if ($variety === null) { + // The variety was deleted; the line keeps its price + // snapshot but there is no stock left to take. + continue; + } + + if ($variety->inventory < $line->quantity) { + throw new InsufficientInventoryException( + varietyLabel: (string) $variety->id, + available: $variety->inventory, + required: $line->quantity, + ); + } + + $variety->decrement('inventory', $line->quantity); + } + }); + } + + private function release(Order $order): void + { + DB::transaction(function () use ($order): void { + foreach ($this->lockedLines($order) as [$line, $variety]) { + $variety?->increment('inventory', $line->quantity); + } + }); + } + + /** + * Order lines paired with their variety, locked for update and always + * taken in the same order so two concurrent changes cannot deadlock. + * + * @return array + */ + private function lockedLines(Order $order): array + { + $lines = $order->orderVarieties() + ->whereNotNull('variety_id') + ->orderBy('variety_id') + ->get(); + + $locked = []; + + foreach ($lines as $line) { + /** @var OrderVariety $line */ + $locked[] = [ + $line, + Variety::query()->whereKey($line->variety_id)->lockForUpdate()->first(), + ]; + } + + return $locked; + } +} diff --git a/admin/app/Providers/AppServiceProvider.php b/admin/app/Providers/AppServiceProvider.php index 8325058d..a0d626bb 100644 --- a/admin/app/Providers/AppServiceProvider.php +++ b/admin/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ namespace App\Providers; +use App\Models\Order; +use App\Observers\OrderObserver; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -21,6 +23,8 @@ public function register(): void */ public function boot(): void { - // + // Keeps varieties.inventory in step with order status changes made in + // the panel — the storefront covers gateway payments on its own side. + Order::observe(OrderObserver::class); } } diff --git a/admin/lang/en/order.php b/admin/lang/en/order.php index 05527cce..ba85d692 100644 --- a/admin/lang/en/order.php +++ b/admin/lang/en/order.php @@ -20,6 +20,8 @@ 'user_id' => 'Customer', 'coupon_id' => 'Coupon', 'status' => 'Status', + 'inventory_insufficient' => 'Cannot mark this order as paid: variety :variety has only :available in stock but the order needs :required. Adjust the stock or the order first.', + 'inventory_insufficient_title' => 'Not enough stock', 'src' => 'Source', 'for_partner' => 'For Partner', diff --git a/admin/lang/fa/order.php b/admin/lang/fa/order.php index fb186306..7d10edeb 100644 --- a/admin/lang/fa/order.php +++ b/admin/lang/fa/order.php @@ -20,6 +20,8 @@ 'user_id' => 'مشتری', 'coupon_id' => 'کوپن', 'status' => 'وضعیت', + 'inventory_insufficient' => 'امکان تغییر وضعیت این سفارش نیست: موجودی تنوع :variety برابر :available است اما سفارش به :required عدد نیاز دارد. ابتدا موجودی یا سفارش را اصلاح کنید.', + 'inventory_insufficient_title' => 'موجودی کافی نیست', 'src' => 'منبع', 'for_partner' => 'برای همکار', diff --git a/admin/tests/Feature/OrderInventoryTest.php b/admin/tests/Feature/OrderInventoryTest.php new file mode 100644 index 00000000..4f2bbc31 --- /dev/null +++ b/admin/tests/Feature/OrderInventoryTest.php @@ -0,0 +1,117 @@ +create(['inventory' => $inventory]); + $order = Order::factory()->create(['status' => $status]); + + OrderVariety::create([ + 'order_id' => $order->id, + 'product_id' => $variety->product_id, + 'variety_id' => $variety->id, + 'quantity' => $quantity, + 'price' => 1000, + 'final_price' => 1000, + ]); + + return [$order->refresh(), $variety]; +} + +it('takes stock when an order becomes paid', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + + expect($variety->fresh()->inventory)->toBe(7); +}); + +it('gives stock back when a paid order is canceled', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['status' => OrderStatusEnum::CANCELED]); + + expect($variety->fresh()->inventory)->toBe(10); +}); + +it('gives stock back when a delivered order is returned', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['status' => OrderStatusEnum::DELIVERED]); + $order->update(['status' => OrderStatusEnum::RETURNED]); + + expect($variety->fresh()->inventory)->toBe(10); +}); + +it('leaves stock alone while an order moves between fulfilment statuses', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + + // Paid -> processing -> shipped -> delivered all hold the same stock. + foreach ([OrderStatusEnum::PROCESSING, OrderStatusEnum::SHIPPED, OrderStatusEnum::DELIVERED] as $status) { + $order->update(['status' => $status]); + expect($variety->fresh()->inventory)->toBe(7); + } +}); + +it('leaves stock alone when the status does not change', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['content' => 'a note, not a status change']); + + expect($variety->fresh()->inventory)->toBe(7); +}); + +it('never takes stock twice for the same order', function () { + [$order, $variety] = stockOrder(inventory: 10, quantity: 3); + + $order->update(['status' => OrderStatusEnum::PAID]); + $order->update(['status' => OrderStatusEnum::PAID]); + + expect($variety->fresh()->inventory)->toBe(7); +}); + +it('refuses the transition rather than pushing stock negative', function () { + [$order, $variety] = stockOrder(inventory: 2, quantity: 3); + + expect(fn () => $order->update(['status' => OrderStatusEnum::PAID])) + ->toThrow(InsufficientInventoryException::class); + + expect($variety->fresh()->inventory)->toBe(2); +}); + +it('keeps the order pending when the panel cannot cover the stock', function () { + [$order, $variety] = stockOrder(inventory: 2, quantity: 3); + + livewire(EditOrder::class, ['record' => $order->getRouteKey()]) + ->fillForm(['status' => OrderStatusEnum::PAID->value]) + ->call('save') + ->assertNotified(); + + // Both the status and the stock are untouched — the save rolled back. + expect($order->fresh()->status)->toBe(OrderStatusEnum::PENDING) + ->and($variety->fresh()->inventory)->toBe(2); +}); From 81ce3ba7ee5293cc123a3948a99a70ed4fa8e9a6 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:58:14 +0330 Subject: [PATCH 40/55] docs: record that admin status changes now restock Both copies of ORDER.md said inventory is never restocked for a return, which stopped being true when OrderObserver landed. Documents the consumesStock() rule, which transitions adjust stock, that the transition is refused when stock cannot cover it, and that a damaged return still needs a manual adjustment afterwards. Co-Authored-By: Claude Sonnet 5 --- admin/docs/ORDER.md | 31 +++++++++++++++++++++++++++++-- shop/docs/ORDER.md | 31 +++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/admin/docs/ORDER.md b/admin/docs/ORDER.md index 88ccb9ff..280867bb 100644 --- a/admin/docs/ORDER.md +++ b/admin/docs/ORDER.md @@ -46,11 +46,38 @@ Both `App\Actions\Cart\AddToCart` and `App\Actions\Cart\MergeGuestCart` (storefr `App\Actions\Checkout\RetryOrderPayment` (storefront, used by `AccountController::retryOrder()`) pays directly — it does not touch the cart or send the customer back through checkout. It re-checks live stock for every original line (all-or-nothing; no partial retry), then resets the **same** order back to `PENDING` (not a clone — its line items/address/shipping/totals are untouched) and opens a fresh Zarinpal session for it via `App\Actions\Checkout\OpenZarinpalSession` (the same action the normal checkout flow uses), which adds a new `Transaction` row. A customer who cancels and retries repeatedly ends up with one order and several transactions (a full attempt history), not a new order per attempt. Because each attempt gets its own Zarinpal authority, `CompleteCheckoutPayment`'s callback handling needs no special-casing for retries — it resolves by authority either way. -## Returned orders (`RETURNED`): nothing happens automatically +## Admin status changes keep stock in step (2026-08-07) + +`App\Observers\OrderObserver` (admin) upholds the same invariant the storefront +does, for every status change staff make in the panel. + +`OrderStatusEnum::consumesStock()` is the single definition: an order holds +stock while it is `PAID`, `PROCESSING`, `SHIPPED` or `DELIVERED`, and holds none +while `PENDING`, `CANCELED` or `RETURNED`. Only a move **between those two sets** +adjusts anything, so `PAID -> SHIPPED` changes nothing and re-saving an order +changes nothing. + +- Entering the set (confirming a card-to-card receipt, say) decrements each + line, row-locked, exactly like `DecrementInventoryAndMarkPaid`. +- Leaving it (cancel, or accepting a return) puts the stock back. +- If a line cannot be covered, the transition is **refused** — `EditOrder` wraps + the save in a transaction, so the status change rolls back and staff get a + message naming the variety and the shortfall, instead of an unsigned-column + crash or a silently oversold order. + +This observer is registered by the admin app only. The storefront has its own +`Order` model with no observer, so a Zarinpal payment still decrements exactly +once, in `DecrementInventoryAndMarkPaid`. + +**Note this changes the `RETURNED` behaviour described below:** a return now +restocks automatically. If the goods came back damaged, adjust the variety's +inventory down by hand afterwards. + +## Returned orders (`RETURNED`): what still is not automatic Setting an order's `status` to `RETURNED` in the Filament admin panel (`OrderResource`'s `status` field is a plain `Select` — no path to this exists on the storefront) is a plain data write. Nothing else is triggered: -- **Inventory is not restocked.** No code anywhere increments `varieties.inventory` back; Strategy A only ever decrements on payment and never reverses it for a return. +- **Inventory is restocked** as of 2026-08-07 (see the section above). Damaged or unsellable returns need a manual adjustment afterwards. - **No `Receipt` or `Transaction` row is created or updated.** There's no observer/event tied to `orders.status` — changing it doesn't touch either table. - **No refund is tracked.** Neither `receipts` nor `transactions` has a refund-related column (`refunded_at`, `refund_amount`, etc.) — the free-text "بازگشت وجه" note in `Transaction.result_message` is written only for the unrelated oversold-payment race (`failPaidButOversold()` above), not for a manual return. - **No status-transition validation.** Any status can be set to `RETURNED` from any prior status. diff --git a/shop/docs/ORDER.md b/shop/docs/ORDER.md index 321ac335..aefd5683 100644 --- a/shop/docs/ORDER.md +++ b/shop/docs/ORDER.md @@ -54,11 +54,38 @@ Because each attempt gets its own Zarinpal authority (a new `Transaction` row), **Known tradeoff**: since retries reuse the order, an old *stale* Zarinpal callback URL (e.g. the customer navigates back to a previous attempt's return link after already starting a newer one) could re-verify against the wrong, now-superseded authority and cancel an order with a different attempt genuinely in flight. This is the same class of rare, accepted race as the last-unit-oversell case below — not specifically guarded against. -## Returned orders (`RETURNED`): nothing happens automatically +## Admin status changes keep stock in step (2026-08-07) + +`App\Observers\OrderObserver` (admin) upholds the same invariant the storefront +does, for every status change staff make in the panel. + +`OrderStatusEnum::consumesStock()` is the single definition: an order holds +stock while it is `PAID`, `PROCESSING`, `SHIPPED` or `DELIVERED`, and holds none +while `PENDING`, `CANCELED` or `RETURNED`. Only a move **between those two sets** +adjusts anything, so `PAID -> SHIPPED` changes nothing and re-saving an order +changes nothing. + +- Entering the set (confirming a card-to-card receipt, say) decrements each + line, row-locked, exactly like `DecrementInventoryAndMarkPaid`. +- Leaving it (cancel, or accepting a return) puts the stock back. +- If a line cannot be covered, the transition is **refused** — `EditOrder` wraps + the save in a transaction, so the status change rolls back and staff get a + message naming the variety and the shortfall, instead of an unsigned-column + crash or a silently oversold order. + +This observer is registered by the admin app only. The storefront has its own +`Order` model with no observer, so a Zarinpal payment still decrements exactly +once, in `DecrementInventoryAndMarkPaid`. + +**Note this changes the `RETURNED` behaviour described below:** a return now +restocks automatically. If the goods came back damaged, adjust the variety's +inventory down by hand afterwards. + +## Returned orders (`RETURNED`): what still is not automatic Setting an order's `status` to `RETURNED` (in the Filament admin panel — there's no storefront-side path to it) is a plain data write. Nothing else is triggered: -- **Inventory is not restocked.** No code anywhere increments `varieties.inventory` back; Strategy A only ever decrements on payment and never reverses it for a return. +- **Inventory is restocked** as of 2026-08-07 (see the section above). Damaged or unsellable returns need a manual adjustment afterwards. - **No `Receipt` or `Transaction` row is created or updated.** There's no observer/event tied to `orders.status` — changing it doesn't touch either table. - **No refund is tracked.** Neither `receipts` nor `transactions` has a refund-related column (`refunded_at`, `refund_amount`, etc.) — the free-text "بازگشت وجه" note in `Transaction.result_message` is written only for the unrelated oversold-payment race (`failPaidButOversold()` above), not for a manual return. - **No status-transition validation.** `status` is a plain Filament `Select`; any status can be set to `RETURNED` from any prior status. From ec5bf46fd41fa1fec0617956588128cc6d558139 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Thu, 20 Aug 2026 17:58:14 +0330 Subject: [PATCH 41/55] feat(shop): render every banner and slider position Three of the seven positions an admin could pick had no render site at all: choosing `home-top`, `category-top`, `category-side` or `product-side` published content that appeared nowhere, with nothing in the panel to say so. `SliderSlot` and `BannerSlot` replace the home-only HeroSlider and BannerGrid. Both take the slot's data and an arrangement (hero / wide / portrait, grid / wide / stack), render nothing when the slot is empty so a page can hand them an empty array without guarding, and pin their own aspect ratio to match the ratio the admin crops to. The two lookup actions move to `App\Actions\Layout` since they now serve the home, category and product pages rather than just the home page. Co-Authored-By: Claude Sonnet 5 --- .../{Home => Layout}/GetBannersByPosition.php | 2 +- .../{Home => Layout}/GetSliderByPosition.php | 2 +- shop/app/Enums/BannerPositionEnum.php | 9 +- shop/app/Enums/SliderPositionEnum.php | 8 +- .../Http/Controllers/CategoryController.php | 10 ++ shop/app/Http/Controllers/HomeController.php | 15 +- .../Http/Controllers/ProductController.php | 6 + shop/docs/BANNERS_SLIDERS.md | 145 ++++++++++++++++++ shop/resources/js/Components/BannerSlot.vue | 79 ++++++++++ .../js/Components/Home/BannerGrid.vue | 35 ----- .../{Home/HeroSlider.vue => SliderSlot.vue} | 69 ++++++--- shop/resources/js/Pages/Category/Show.vue | 18 +++ shop/resources/js/Pages/Home.vue | 35 +++-- shop/resources/js/Pages/Product/Show.vue | 14 +- shop/tests/Feature/HomePageTest.php | 91 +++++++++-- shop/tests/Feature/PositionSlotsTest.php | 131 ++++++++++++++++ shop/tests/Helpers.php | 50 ++++++ 17 files changed, 632 insertions(+), 87 deletions(-) rename shop/app/Actions/{Home => Layout}/GetBannersByPosition.php (97%) rename shop/app/Actions/{Home => Layout}/GetSliderByPosition.php (97%) create mode 100644 shop/docs/BANNERS_SLIDERS.md create mode 100644 shop/resources/js/Components/BannerSlot.vue delete mode 100644 shop/resources/js/Components/Home/BannerGrid.vue rename shop/resources/js/Components/{Home/HeroSlider.vue => SliderSlot.vue} (57%) create mode 100644 shop/tests/Feature/PositionSlotsTest.php diff --git a/shop/app/Actions/Home/GetBannersByPosition.php b/shop/app/Actions/Layout/GetBannersByPosition.php similarity index 97% rename from shop/app/Actions/Home/GetBannersByPosition.php rename to shop/app/Actions/Layout/GetBannersByPosition.php index e30e9ecf..14162ce4 100644 --- a/shop/app/Actions/Home/GetBannersByPosition.php +++ b/shop/app/Actions/Layout/GetBannersByPosition.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Actions\Home; +namespace App\Actions\Layout; use App\Actions\Catalog\TransformImage; use App\Enums\BannerPositionEnum; diff --git a/shop/app/Actions/Home/GetSliderByPosition.php b/shop/app/Actions/Layout/GetSliderByPosition.php similarity index 97% rename from shop/app/Actions/Home/GetSliderByPosition.php rename to shop/app/Actions/Layout/GetSliderByPosition.php index 182069c7..3d9622e0 100644 --- a/shop/app/Actions/Home/GetSliderByPosition.php +++ b/shop/app/Actions/Layout/GetSliderByPosition.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Actions\Home; +namespace App\Actions\Layout; use App\Actions\Catalog\TransformImage; use App\Enums\SliderPositionEnum; diff --git a/shop/app/Enums/BannerPositionEnum.php b/shop/app/Enums/BannerPositionEnum.php index 88da8efc..7526decb 100644 --- a/shop/app/Enums/BannerPositionEnum.php +++ b/shop/app/Enums/BannerPositionEnum.php @@ -9,10 +9,11 @@ /** * Fixed set of places a banner can be shown on the storefront. Mirrors the * admin enum (same string values) so the position an admin assigns and the - * position the frontend looks up can never disagree. Only HOME_MIDDLE is - * rendered today (the home banner grid); the others are valid placements for - * pages/slots that read them later (a slot may render a grid or a single - * banner — the position doesn't dictate that). + * position the frontend looks up can never disagree. + * + * Every case has a render site: HOME_TOP and HOME_MIDDLE on the home page, + * CATEGORY_SIDE on the category listing. Each renders nothing until a + * published banner is assigned to it. */ enum BannerPositionEnum: string { diff --git a/shop/app/Enums/SliderPositionEnum.php b/shop/app/Enums/SliderPositionEnum.php index 4aff2630..3bb6aa20 100644 --- a/shop/app/Enums/SliderPositionEnum.php +++ b/shop/app/Enums/SliderPositionEnum.php @@ -9,9 +9,11 @@ /** * Fixed set of places a slider can be shown on the storefront. Mirrors the * admin enum (same string values) so the position an admin assigns and the - * position the frontend looks up can never disagree. Only HOME_MAIN is - * rendered today (home hero); the others are valid placements for pages that - * read them later. + * position the frontend looks up can never disagree. + * + * Every case has a render site: HOME_MAIN and HOME_SECONDARY on the home page, + * CATEGORY_TOP on the category listing, PRODUCT_SIDE beside the buy box. Each + * renders nothing until a published slider is assigned to it. */ enum SliderPositionEnum: string { diff --git a/shop/app/Http/Controllers/CategoryController.php b/shop/app/Http/Controllers/CategoryController.php index 74f957d3..bc614aa0 100644 --- a/shop/app/Http/Controllers/CategoryController.php +++ b/shop/app/Http/Controllers/CategoryController.php @@ -9,6 +9,10 @@ use App\Actions\Category\CollectCategoryIds; use App\Actions\Category\GetCategoryFilters; use App\Actions\Category\GetCategoryProducts; +use App\Actions\Layout\GetBannersByPosition; +use App\Actions\Layout\GetSliderByPosition; +use App\Enums\BannerPositionEnum; +use App\Enums\SliderPositionEnum; use App\Models\Category; use Illuminate\Http\Request; use Inertia\Inertia; @@ -24,6 +28,8 @@ public function show( BuildCategoryBreadcrumbs $buildBreadcrumbs, GetCategoryProducts $getCategoryProducts, GetCategoryFilters $getCategoryFilters, + GetSliderByPosition $getSliderByPosition, + GetBannersByPosition $getBannersByPosition, ): Response { $category = Category::query() ->active() @@ -40,6 +46,10 @@ public function show( 'products' => $getCategoryProducts($categoryIds, $filters), 'filters' => $getCategoryFilters($categoryIds, $filters), 'applied' => $filters, + // Shared across every category: empty until an admin assigns a + // published slider/banner to the position. + 'topSlides' => $getSliderByPosition(SliderPositionEnum::CATEGORY_TOP), + 'sideBanners' => $getBannersByPosition(BannerPositionEnum::CATEGORY_SIDE), ]); } diff --git a/shop/app/Http/Controllers/HomeController.php b/shop/app/Http/Controllers/HomeController.php index 9132dac2..fa6a46f9 100644 --- a/shop/app/Http/Controllers/HomeController.php +++ b/shop/app/Http/Controllers/HomeController.php @@ -4,12 +4,12 @@ namespace App\Http\Controllers; -use App\Actions\Home\GetBannersByPosition; use App\Actions\Home\GetFeaturedBrands; use App\Actions\Home\GetHomeCategories; -use App\Actions\Home\GetHomeTags; use App\Actions\Home\GetProductRows; -use App\Actions\Home\GetSliderByPosition; +use App\Actions\Home\GetTagRows; +use App\Actions\Layout\GetBannersByPosition; +use App\Actions\Layout\GetSliderByPosition; use App\Enums\BannerPositionEnum; use App\Enums\SliderPositionEnum; use Inertia\Inertia; @@ -20,17 +20,22 @@ class HomeController extends Controller public function __invoke( GetSliderByPosition $getSliderByPosition, GetHomeCategories $getHomeCategories, - GetHomeTags $getHomeTags, GetBannersByPosition $getBannersByPosition, GetProductRows $getProductRows, + GetTagRows $getTagRows, GetFeaturedBrands $getFeaturedBrands, ): Response { return Inertia::render('Home', [ + // The layout is fixed; each slot is empty until an admin assigns a + // published banner/slider to that position. + 'topBanners' => $getBannersByPosition(BannerPositionEnum::HOME_TOP), 'slides' => $getSliderByPosition(SliderPositionEnum::HOME_MAIN), 'categories' => $getHomeCategories(), - 'tags' => $getHomeTags(), 'banners' => $getBannersByPosition(BannerPositionEnum::HOME_MIDDLE), + 'secondarySlides' => $getSliderByPosition(SliderPositionEnum::HOME_SECONDARY), 'productRows' => $getProductRows(), + // One carousel per featured tag, after the standard rows. + 'tagRows' => $getTagRows(), 'brands' => $getFeaturedBrands(), ]); } diff --git a/shop/app/Http/Controllers/ProductController.php b/shop/app/Http/Controllers/ProductController.php index 1412bb77..ab24696d 100644 --- a/shop/app/Http/Controllers/ProductController.php +++ b/shop/app/Http/Controllers/ProductController.php @@ -5,10 +5,12 @@ namespace App\Http\Controllers; use App\Actions\Cart\ResolveCartOwner; +use App\Actions\Layout\GetSliderByPosition; use App\Actions\Product\BuildProductBreadcrumbs; use App\Actions\Product\BuildProductDetail; use App\Actions\Product\GetRelatedProducts; use App\Enums\ReviewStatusEnum; +use App\Enums\SliderPositionEnum; use App\Enums\VarietyStatusEnum; use App\Models\Cart; use App\Models\Product; @@ -28,6 +30,7 @@ public function show( BuildProductDetail $buildProductDetail, BuildProductBreadcrumbs $buildBreadcrumbs, GetRelatedProducts $getRelatedProducts, + GetSliderByPosition $getSliderByPosition, ): Response { $product = Product::query() ->published() @@ -57,6 +60,9 @@ public function show( 'isWishlisted' => $this->isWishlisted($request, $product), // Any logged-in user may review; the form is hidden for guests. 'canReview' => $request->user() instanceof User, + // Shared across every product; empty until an admin assigns a + // published slider to the position. + 'sideSlides' => $getSliderByPosition(SliderPositionEnum::PRODUCT_SIDE), ]); } diff --git a/shop/docs/BANNERS_SLIDERS.md b/shop/docs/BANNERS_SLIDERS.md new file mode 100644 index 00000000..d53851bd --- /dev/null +++ b/shop/docs/BANNERS_SLIDERS.md @@ -0,0 +1,145 @@ +# Banners, Sliders & Positions + +Status: **built (2026-08-07).** Every position in both enums has a render site +on the storefront, and the admin form shows a wireframe of where the selected +position lands. The `home_sections` table — an admin-composed home page the +storefront never read — has been removed; the storefront layout is fixed in +code. + +## What a "position" is + +A position is a **named slot on the storefront** — "home page, main slider", +"product page, sidebar". It is a fixed list defined in code, not free text an +admin types, because something has to render each slot and that something is a +Vue component. + +Banners and sliders each carry one position. Slides do **not**: a slide belongs +to a slider (`slides.slider_id`), and the slider holds the position. + +| Position | Kind | Where it renders | Component | +| --- | --- | --- | --- | +| `home-top` | banner | Full-width strip above the hero, home page | `BannerSlot` (`wide`) | +| `home-main` | slider | The hero, home page | `SliderSlot` (`hero`) | +| `home-middle` | banner | Promo grid below the categories, home page | `BannerSlot` (`grid`) | +| `home-secondary` | slider | Wide band between the promo grid and product rows | `SliderSlot` (`wide`) | +| `category-top` | slider | Under the title on every category page | `SliderSlot` (`wide`) | +| `category-side` | banner | Stacked in the category sidebar, under the filters | `BannerSlot` (`stack`) | +| `product-side` | slider | Under the buy box on every product page | `SliderSlot` (`portrait`) | + +## Aspect ratios + +Each slot pins its own ratio, so one oddly-shaped upload cannot stretch a grid +row or push the page around. The admin crops to the same ratio on upload +(`BannerPositionEnum::aspectRatio()` / `SliderPositionEnum::aspectRatio()`), and +the storefront component enforces it in CSS — **keep the two in step**. + +| Position | Ratio | Upload at least | Storefront class | +| --- | --- | --- | --- | +| `home-top` | 5:1 | 1920 × 384 | `aspect-[3/1] sm:aspect-[5/1]` | +| `home-main` | 3:1 | 1920 × 640 | `aspect-[21/9] sm:aspect-[3/1]` | +| `home-middle` | 16:9 | 800 × 450 | `aspect-[16/9]` | +| `home-secondary` | 4:1 | 1920 × 480 | `aspect-[16/9] sm:aspect-[4/1]` | +| `category-top` | 4:1 | 1920 × 480 | `aspect-[16/9] sm:aspect-[4/1]` | +| `category-side` | 4:5 | 600 × 750 | `aspect-[4/5]` | +| `product-side` | 4:5 | 600 × 750 | `aspect-[4/5]` | + +Product imagery (variety photos) is **1:1** everywhere — the card grid and the +product gallery are both `aspect-square` — so `VarietyResource` crops to `1:1` +and asks for at least 1000 × 1000. + +The wide slots stay taller on phones (3:1 rather than 5:1, 16:9 rather than 4:1) +because a full-width strip at its desktop ratio would be an unreadable sliver on +a narrow screen. Recommended sizes are roughly 2× the rendered size, so images +stay sharp on a retina screen without being wasteful. + +Every slot renders **nothing at all** when no published banner/slider is +assigned to it — the components return early rather than emitting an empty +container, so a page never has to guard the call. + +Tables (all admin-owned migrations): + +- `banners` — `position`, `heading`, `url`, `sort`, `status` +- `sliders` — `name`, `position`, `status` +- `slides` — `slider_id`, `heading`, `label`, `url`, `order` + +## How admin and the storefront agree + +The position is never a loose string on either side. + +**Admin** offers a radio group built from the enum, each option carrying a +sentence describing exactly where it lands, plus a wireframe of the three +storefront pages with the selected slot highlighted +(`admin/resources/views/filament/forms/position-guide.blade.php`). Free text is +not possible. + +**The storefront** looks up by the same enum case. Two actions serve every slot +on every page: + +```php +// App\Actions\Layout +$getSliderByPosition(SliderPositionEnum::PRODUCT_SIDE); +$getBannersByPosition(BannerPositionEnum::CATEGORY_SIDE); +``` + +They live under `App\Actions\Layout` rather than `App\Actions\Home` because the +home, category and product pages all call them. + +### The enums are mirrored, not shared + +Each enum exists twice, once per app, with identical string values and +different translation keys. This matches how the rest of the repo mirrors admin +enums into read-focused shop models — but **nothing enforces the match**. Add a +case to one side only and the failure is silent: admins get a new option to +save, and the storefront never reads it. + +The admin copies carry two extra methods the storefront does not need: +`description()` (the help line under each option) and `page()` (which wireframe +to highlight). + +## Adding a new position + +Not a config change — a code change in both apps, by design, because a +component has to render the slot. + +1. `admin/app/Enums/PositionEnum.php` — add the case, its `label()`, + `description()`, `page()`, `aspectRatio()` and `recommendedSize()` arms. +2. `shop/app/Enums/PositionEnum.php` — add the **same string value**. + (`EnumMirrorTest` fails the build if you forget this step.) +3. `admin/lang/en/.php` and `admin/lang/fa/…` — the label and + description keys. +4. `shop/lang/fa/enums.php` — the label under `banner_position` / + `slider_position`. +5. `admin/resources/views/filament/forms/position-guide.blade.php` — add a rect + for the new slot so the wireframe shows it. +6. Render it: pass the slot from the page's controller and drop a `SliderSlot` / + `BannerSlot` into the Vue page, with an aspect matching `aspectRatio()`. + Without this the position is configurable but invisible. +7. `shop/tests/Feature/PositionSlotsTest.php` — assert it fills when assigned + and is empty when not. + +## Why `home_sections` was removed + +The `home_sections` table let staff compose the home page from an ordered list +of typed blocks. The admin side was complete — resource, drag-to-reorder, a +`config` JSON bag — but the storefront never read the table, so reordering or +disabling a row changed nothing on the site. + +Rather than finish the storefront half, the feature was dropped: the layout is +fixed in `Home.vue`, and the flexibility that was actually wanted — *what* +appears in each slot — is already covered by positions. What is no longer +possible is reordering blocks or adding a second product row without a deploy. + +The rollback is `2026_08_07_000000_drop_home_sections_table`. The original +create migration is deleted, so a fresh database never builds the table; +`dropIfExists` removes it from databases that already ran it. The migration's +`down()` recreates the table, but restoring the feature would also mean +restoring the model, resource, enum, factory and seeder from git history. + +## Keeping the two enums in step + +The mirroring is enforced: `shop/tests/Feature/EnumMirrorTest.php` reads +`admin/app/Enums/*.php` and asserts that every storefront enum has an admin twin +with identical case names and backing values. A case added to one side only +fails the build instead of silently giving staff a value the storefront ignores. + +That guard covers all 17 mirrored enums, not just the two position ones. diff --git a/shop/resources/js/Components/BannerSlot.vue b/shop/resources/js/Components/BannerSlot.vue new file mode 100644 index 00000000..edbd2d26 --- /dev/null +++ b/shop/resources/js/Components/BannerSlot.vue @@ -0,0 +1,79 @@ + + + diff --git a/shop/resources/js/Components/Home/BannerGrid.vue b/shop/resources/js/Components/Home/BannerGrid.vue deleted file mode 100644 index 0f0730b0..00000000 --- a/shop/resources/js/Components/Home/BannerGrid.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/shop/resources/js/Components/Home/HeroSlider.vue b/shop/resources/js/Components/SliderSlot.vue similarity index 57% rename from shop/resources/js/Components/Home/HeroSlider.vue rename to shop/resources/js/Components/SliderSlot.vue index bd8544d1..889ad528 100644 --- a/shop/resources/js/Components/Home/HeroSlider.vue +++ b/shop/resources/js/Components/SliderSlot.vue @@ -4,13 +4,43 @@ import AppLink from '@/Components/AppLink.vue'; import Icon from '@/Components/Icon.vue'; import { uiIcons } from '@/fontawesome'; +/** + * Renders the slider assigned to one SliderPositionEnum slot. + * + * `aspect` is the only thing that differs between slots: the home hero is a + * wide band, a category header is shorter, and a product sidebar is a narrow + * portrait. Everything else — autoplay, arrows, dots, captions — is shared. + * + * Renders nothing at all when the slot has no slides, so a page can hand it an + * empty array without guarding. + */ const props = defineProps({ slides: { type: Array, default: () => [], }, + aspect: { + type: String, + default: 'hero', + validator: (value) => ['hero', 'wide', 'portrait'].includes(value), + }, + rounded: { + type: String, + default: 'rounded-2xl', + }, }); +const aspectClasses = { + hero: 'aspect-[21/9] sm:aspect-[3/1]', + wide: 'aspect-[16/9] sm:aspect-[4/1]', + portrait: 'aspect-[4/5]', +}; + +const aspectClass = computed(() => aspectClasses[props.aspect] ?? aspectClasses.hero); + +// A narrow slot has no room for side arrows; dots are enough to navigate. +const showArrows = computed(() => props.aspect !== 'portrait'); + const current = ref(0); const count = computed(() => props.slides.length); @@ -43,10 +73,11 @@ onBeforeUnmount(() => {